Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d0b9de142 | ||
|
|
e503786211 | ||
|
|
d4ac449074 | ||
|
|
a67663f3c5 | ||
|
|
9e48727fce | ||
|
|
a09335b729 | ||
|
|
da05a8461f | ||
|
|
21366cb26b | ||
|
|
a6312af6d7 | ||
|
|
899f024a4f | ||
|
|
aa61199b43 | ||
|
|
98f3902839 | ||
|
|
d1d3604a6d | ||
|
|
ce0cbc0786 | ||
|
|
0bad722c7a | ||
|
|
247c118bdf | ||
|
|
c59cb412dd | ||
|
|
1b83429c06 | ||
|
|
3e2885f64d | ||
|
|
eac0f059fa | ||
|
|
aaa4228b82 | ||
|
|
e5c26ea502 | ||
|
|
38b4f98233 | ||
|
|
d592894f28 | ||
|
|
9ee2f71dc3 | ||
|
|
283779610a | ||
|
|
d33f576212 | ||
|
|
a3fd8f1ce8 | ||
|
|
de06e7a15d | ||
|
|
0105e0d46e | ||
|
|
0732f9ca6d | ||
|
|
fcaaa770d6 | ||
|
|
379350b9ee | ||
|
|
5f7899bcee | ||
|
|
b646a2c647 | ||
|
|
32497d53df | ||
|
|
6365e164aa | ||
|
|
f6af156fbd | ||
|
|
33cef059e8 |
@@ -78,7 +78,21 @@
|
|||||||
"Bash(rustfmt --edition 2024 --check src/badge.rs src/main.rs)",
|
"Bash(rustfmt --edition 2024 --check src/badge.rs src/main.rs)",
|
||||||
"Bash(rustfmt --edition 2024 --check src/git.rs)",
|
"Bash(rustfmt --edition 2024 --check src/git.rs)",
|
||||||
"Bash(rustfmt --edition 2024 --check src/badge.rs)",
|
"Bash(rustfmt --edition 2024 --check src/badge.rs)",
|
||||||
"Bash(rustfmt --edition 2024 --check src/git.rs src/main.rs)"
|
"Bash(rustfmt --edition 2024 --check src/git.rs src/main.rs)",
|
||||||
|
"Bash(pkg-config --list-all)",
|
||||||
|
"Bash(rustc --edition 2021 --crate-type lib -o /dev/null --emit=metadata src/git.rs)",
|
||||||
|
"Bash(rustc --edition 2021 --crate-type lib -o /dev/null --emit=metadata /mnt/d/Development/GitLite/src-tauri/src/git.rs)",
|
||||||
|
"Bash(rustc --edition 2021 --crate-type bin -o /dev/null --emit=metadata src/main.rs)",
|
||||||
|
"Bash(grep -B1 \"^error\\\\[E0432\\\\]\\\\|^error$\")",
|
||||||
|
"Bash(grep -v \"^--$\")",
|
||||||
|
"Bash(grep \"^error$\" -A2)",
|
||||||
|
"Bash(cargo run *)",
|
||||||
|
"Bash(node_modules/.bin/svelte-check --version)",
|
||||||
|
"Bash(rustfmt --check --edition 2021 src/git.rs src/main.rs)",
|
||||||
|
"Bash(rustfmt --check --edition 2021 src/git.rs)",
|
||||||
|
"Bash(awk *)",
|
||||||
|
"Bash(rustfmt --edition 2021 /tmp/blame_chunk.rs)",
|
||||||
|
"Read(//tmp/**)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,16 +32,21 @@ jobs:
|
|||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
max-parallel: 1
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
# - platform: 'macos-latest'
|
# - platform: 'macos-latest'
|
||||||
# args: '--target aarch64-apple-darwin'
|
# args: '--target aarch64-apple-darwin'
|
||||||
# - platform: 'macos-latest'
|
# - platform: 'macos-latest'
|
||||||
# args: '--target x86_64-apple-darwin'
|
# args: '--target x86_64-apple-darwin'
|
||||||
# - platform: 'ubuntu-22.04'
|
|
||||||
# args: ''
|
|
||||||
- platform: "windows-latest"
|
- platform: "windows-latest"
|
||||||
args: ""
|
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 }}
|
runs-on: ${{ matrix.platform }}
|
||||||
|
|
||||||
@@ -55,13 +60,21 @@ jobs:
|
|||||||
if: matrix.platform == 'ubuntu-22.04'
|
if: matrix.platform == 'ubuntu-22.04'
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
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
|
||||||
|
|
||||||
@@ -69,18 +82,13 @@ jobs:
|
|||||||
run: npm version ${{ github.ref_name }} --no-git-tag-version --allow-same-version
|
run: npm version ${{ github.ref_name }} --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Update tauri.conf.json Version
|
- name: Update tauri.conf.json Version
|
||||||
shell: powershell
|
env:
|
||||||
|
RELEASE_VERSION: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
$path = ".\src-tauri\tauri.conf.json"
|
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');"
|
||||||
$version = '${{ github.ref_name }}'
|
|
||||||
$content = Get-Content -Raw -Encoding UTF8 $path
|
|
||||||
$regex = [regex]'"version"\s*:\s*"[^"]*"'
|
|
||||||
$replacement = '"version": "' + $version + '"'
|
|
||||||
$updated = $regex.Replace($content, $replacement, 1)
|
|
||||||
$encoding = New-Object System.Text.UTF8Encoding($false)
|
|
||||||
[System.IO.File]::WriteAllText($path, $updated, $encoding)
|
|
||||||
|
|
||||||
- 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 }}'
|
||||||
@@ -131,12 +139,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}}
|
||||||
run: npm run tauri build
|
NO_STRIP: ${{ matrix.no_strip }}
|
||||||
|
run: npm run tauri -- build --bundles ${{ matrix.bundles }}
|
||||||
# ----------------------
|
# ----------------------
|
||||||
# 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:
|
||||||
|
PUBLISH_PLATFORM: ${{ matrix.updater_platform }}
|
||||||
run: |
|
run: |
|
||||||
uv sync
|
uv sync
|
||||||
uv run main.py
|
uv run main.py
|
||||||
|
|||||||
+122
-29
@@ -4,7 +4,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable, List, Optional
|
from typing import Any, Iterable, List, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from minio import Minio
|
from minio import Minio
|
||||||
@@ -115,23 +115,57 @@ def _ensure_bucket(client: Minio, bucket_name: str) -> None:
|
|||||||
|
|
||||||
def _collect_artifacts() -> List[Path]:
|
def _collect_artifacts() -> List[Path]:
|
||||||
artifacts: List[Path] = []
|
artifacts: List[Path] = []
|
||||||
for directory in (
|
bundle_dirs = {
|
||||||
ARTIFACT_ROOT / "msi",
|
ARTIFACT_ROOT / "msi": (".msi", ".sig"),
|
||||||
ARTIFACT_ROOT / "nsis",
|
ARTIFACT_ROOT / "nsis": (".exe", ".sig"),
|
||||||
ARTIFACT_ROOT / "app",
|
ARTIFACT_ROOT / "appimage": (".appimage", ".sig"),
|
||||||
):
|
ARTIFACT_ROOT / "deb": (".deb", ".sig"),
|
||||||
|
ARTIFACT_ROOT / "rpm": (".rpm", ".sig"),
|
||||||
|
ARTIFACT_ROOT / "app": (".dmg", ".zip", ".sig"),
|
||||||
|
}
|
||||||
|
for directory, suffixes in bundle_dirs.items():
|
||||||
if directory.exists():
|
if directory.exists():
|
||||||
artifacts.extend(p for p in directory.rglob("*") if p.is_file())
|
artifacts.extend(
|
||||||
|
p
|
||||||
|
for p in directory.iterdir()
|
||||||
|
if p.is_file() and p.suffix.lower() in suffixes
|
||||||
|
)
|
||||||
return artifacts
|
return artifacts
|
||||||
|
|
||||||
|
|
||||||
def _select_primary_artifact(artifacts: Iterable[Path]) -> Optional[Path]:
|
def _select_primary_artifact(
|
||||||
priority = (".exe", ".msi", ".zip", ".dmg")
|
artifacts: Iterable[Path], platform: Optional[str] = None
|
||||||
|
) -> Optional[Path]:
|
||||||
|
artifact_list = list(artifacts)
|
||||||
|
priorities = {
|
||||||
|
"windows-x86_64": (".exe", ".msi"),
|
||||||
|
"linux-x86_64": (".appimage", ".deb", ".rpm"),
|
||||||
|
"darwin-x86_64": (".dmg", ".zip"),
|
||||||
|
"darwin-aarch64": (".dmg", ".zip"),
|
||||||
|
}
|
||||||
|
priority = priorities.get(platform or "", ())
|
||||||
|
priority += (".exe", ".msi", ".appimage", ".deb", ".rpm", ".zip", ".dmg")
|
||||||
for ext in priority:
|
for ext in priority:
|
||||||
for artifact in artifacts:
|
for artifact in artifact_list:
|
||||||
if artifact.suffix.lower() == ext:
|
if artifact.suffix.lower() == ext:
|
||||||
return artifact
|
return artifact
|
||||||
return next(iter(artifacts), None)
|
return next(
|
||||||
|
(artifact for artifact in artifact_list if artifact.suffix != ".sig"), None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_platform(artifact: Path) -> str:
|
||||||
|
suffix = artifact.suffix.lower()
|
||||||
|
if suffix in {".exe", ".msi"}:
|
||||||
|
return "windows-x86_64"
|
||||||
|
if suffix in {".appimage", ".deb", ".rpm"}:
|
||||||
|
return "linux-x86_64"
|
||||||
|
if suffix in {".dmg", ".zip"}:
|
||||||
|
return "darwin-x86_64"
|
||||||
|
raise ConfigurationError(
|
||||||
|
f"Cannot infer updater platform from artifact: {artifact.name}. "
|
||||||
|
"Set PUBLISH_PLATFORM explicitly."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _read_signature(artifact: Path) -> str:
|
def _read_signature(artifact: Path) -> str:
|
||||||
@@ -145,16 +179,55 @@ def _read_signature(artifact: Path) -> str:
|
|||||||
raise FileNotFoundError(f"Signature not found for {artifact.name}")
|
raise FileNotFoundError(f"Signature not found for {artifact.name}")
|
||||||
|
|
||||||
|
|
||||||
def _build_latest_json(
|
def _build_artifact_url(
|
||||||
version: str,
|
version: str,
|
||||||
primary_artifact: Path,
|
primary_artifact: Path,
|
||||||
signature: str,
|
|
||||||
artifact_base_url: str,
|
artifact_base_url: str,
|
||||||
app_dir: str,
|
app_dir: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
base_url = artifact_base_url.rstrip("/")
|
base_url = artifact_base_url.rstrip("/")
|
||||||
path_parts = [app_dir.strip("/"), version, primary_artifact.name]
|
path_parts = [app_dir.strip("/"), version, primary_artifact.name]
|
||||||
artifact_url = "/".join([base_url, *filter(None, path_parts)])
|
return "/".join([base_url, *filter(None, path_parts)])
|
||||||
|
|
||||||
|
|
||||||
|
def _read_existing_latest_json(
|
||||||
|
client: Minio, bucket_name: str
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
response = client.get_object(bucket_name, "latest.json")
|
||||||
|
except S3Error as exc:
|
||||||
|
if exc.code in {"NoSuchKey", "NoSuchObject"}:
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ConfigurationError("Existing latest.json is invalid JSON") from exc
|
||||||
|
finally:
|
||||||
|
response.close()
|
||||||
|
response.release_conn()
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ConfigurationError("Existing latest.json must be a JSON object")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _build_latest_payload(
|
||||||
|
version: str,
|
||||||
|
platform: str,
|
||||||
|
primary_artifact: Path,
|
||||||
|
signature: str,
|
||||||
|
artifact_base_url: str,
|
||||||
|
app_dir: str,
|
||||||
|
existing_payload: Optional[dict[str, Any]] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
artifact_url = _build_artifact_url(
|
||||||
|
version=version,
|
||||||
|
primary_artifact=primary_artifact,
|
||||||
|
artifact_base_url=artifact_base_url,
|
||||||
|
app_dir=app_dir,
|
||||||
|
)
|
||||||
|
|
||||||
pub_date = (
|
pub_date = (
|
||||||
datetime.now(timezone.utc)
|
datetime.now(timezone.utc)
|
||||||
@@ -163,18 +236,27 @@ def _build_latest_json(
|
|||||||
.replace("+00:00", "Z")
|
.replace("+00:00", "Z")
|
||||||
)
|
)
|
||||||
|
|
||||||
latest_payload = {
|
platforms: dict[str, Any] = {}
|
||||||
|
existing_notes = None
|
||||||
|
if existing_payload and existing_payload.get("version") == version:
|
||||||
|
existing_platforms = existing_payload.get("platforms")
|
||||||
|
if isinstance(existing_platforms, dict):
|
||||||
|
platforms.update(existing_platforms)
|
||||||
|
existing_notes = existing_payload.get("notes")
|
||||||
|
|
||||||
|
platforms[platform] = {
|
||||||
|
"signature": signature,
|
||||||
|
"url": artifact_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
"version": version,
|
"version": version,
|
||||||
"pub_date": pub_date,
|
"pub_date": pub_date,
|
||||||
"notes": os.environ.get("RELEASE_NOTES", "Automated release"),
|
"notes": os.environ.get("RELEASE_NOTES")
|
||||||
"platforms": {
|
or existing_notes
|
||||||
"windows-x86_64": {
|
or "Automated release",
|
||||||
"signature": signature,
|
"platforms": platforms,
|
||||||
"url": artifact_url,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
return json.dumps(latest_payload, separators=(",", ":"))
|
|
||||||
|
|
||||||
|
|
||||||
def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
|
def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
|
||||||
@@ -194,28 +276,36 @@ def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
|
|||||||
"No release artifacts found in src-tauri/target/release/bundle."
|
"No release artifacts found in src-tauri/target/release/bundle."
|
||||||
)
|
)
|
||||||
|
|
||||||
primary = _select_primary_artifact(artifacts)
|
publish_platform = os.environ.get("PUBLISH_PLATFORM") or os.environ.get(
|
||||||
|
"UPDATER_PLATFORM"
|
||||||
|
)
|
||||||
|
primary = _select_primary_artifact(artifacts, publish_platform)
|
||||||
if not primary:
|
if not primary:
|
||||||
raise FileNotFoundError("Unable to determine primary artifact to publish.")
|
raise FileNotFoundError("Unable to determine primary artifact to publish.")
|
||||||
|
|
||||||
|
platform = publish_platform or _infer_platform(primary)
|
||||||
signature = _read_signature(primary)
|
signature = _read_signature(primary)
|
||||||
|
|
||||||
latest_json = _build_latest_json(
|
client = _create_minio_client()
|
||||||
|
_ensure_bucket(client, bucket_name)
|
||||||
|
existing_latest_json = _read_existing_latest_json(client, bucket_name)
|
||||||
|
|
||||||
|
latest_payload = _build_latest_payload(
|
||||||
version=resolved_version,
|
version=resolved_version,
|
||||||
|
platform=platform,
|
||||||
primary_artifact=primary,
|
primary_artifact=primary,
|
||||||
signature=signature,
|
signature=signature,
|
||||||
artifact_base_url=artifact_base_url,
|
artifact_base_url=artifact_base_url,
|
||||||
app_dir=bucket_prefix,
|
app_dir=bucket_prefix,
|
||||||
|
existing_payload=existing_latest_json,
|
||||||
)
|
)
|
||||||
|
latest_json = json.dumps(latest_payload, separators=(",", ":"))
|
||||||
|
|
||||||
local_dist_dir = PROJECT_DIR / "dist"
|
local_dist_dir = PROJECT_DIR / "dist"
|
||||||
local_dist_dir.mkdir(parents=True, exist_ok=True)
|
local_dist_dir.mkdir(parents=True, exist_ok=True)
|
||||||
latest_json_path = local_dist_dir / "latest.json"
|
latest_json_path = local_dist_dir / "latest.json"
|
||||||
latest_json_path.write_text(latest_json + "\n", encoding="utf-8")
|
latest_json_path.write_text(latest_json + "\n", encoding="utf-8")
|
||||||
|
|
||||||
client = _create_minio_client()
|
|
||||||
_ensure_bucket(client, bucket_name)
|
|
||||||
|
|
||||||
for artifact in artifacts:
|
for artifact in artifacts:
|
||||||
object_segments = [part for part in (resolved_version, artifact.name) if part]
|
object_segments = [part for part in (resolved_version, artifact.name) if part]
|
||||||
object_name = "/".join(object_segments)
|
object_name = "/".join(object_segments)
|
||||||
@@ -231,7 +321,10 @@ def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
|
|||||||
length=len(latest_bytes),
|
length=len(latest_bytes),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
print(f"Uploaded latest.json -> s3://{bucket_name}/{latest_object_name}")
|
print(
|
||||||
|
f"Uploaded latest.json for {platform} -> "
|
||||||
|
f"s3://{bucket_name}/{latest_object_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Generated
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "tauri-git-lite",
|
"name": "git-lite",
|
||||||
"version": "2026.7.10",
|
"version": "2026.7.14",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "tauri-git-lite",
|
"name": "git-lite",
|
||||||
"version": "2026.7.10",
|
"version": "2026.7.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucide/svelte": "^1.21.0",
|
"@lucide/svelte": "^1.21.0",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "git-lite",
|
"name": "git-lite",
|
||||||
"version": "2026.7.10",
|
"version": "2026.7.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+166
-3
@@ -1128,7 +1128,7 @@ dependencies = [
|
|||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types 0.5.0",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2027,6 +2027,15 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||||
|
dependencies = [
|
||||||
|
"foreign-types-shared 0.1.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -2034,7 +2043,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foreign-types-macros",
|
"foreign-types-macros",
|
||||||
"foreign-types-shared",
|
"foreign-types-shared 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2048,6 +2057,12 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types-shared"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types-shared"
|
name = "foreign-types-shared"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -2526,8 +2541,10 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-aptabase",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2895,6 +2912,22 @@ dependencies = [
|
|||||||
"webpki-roots 1.0.8",
|
"webpki-roots 1.0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-tls"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -4007,6 +4040,23 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "native-tls"
|
||||||
|
version = "0.2.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"openssl",
|
||||||
|
"openssl-probe",
|
||||||
|
"openssl-sys",
|
||||||
|
"schannel",
|
||||||
|
"security-framework 3.7.0",
|
||||||
|
"security-framework-sys",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -4059,6 +4109,18 @@ dependencies = [
|
|||||||
"memoffset",
|
"memoffset",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.31.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "no_std_io2"
|
name = "no_std_io2"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -4523,12 +4585,49 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl"
|
||||||
|
version = "0.10.81"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"cfg-if",
|
||||||
|
"foreign-types 0.3.2",
|
||||||
|
"libc",
|
||||||
|
"openssl-macros",
|
||||||
|
"openssl-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.118",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-sys"
|
||||||
|
version = "0.9.117"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -4554,6 +4653,22 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "os_info"
|
||||||
|
version = "3.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b"
|
||||||
|
dependencies = [
|
||||||
|
"android_system_properties",
|
||||||
|
"log",
|
||||||
|
"nix 0.31.3",
|
||||||
|
"objc2",
|
||||||
|
"objc2-foundation",
|
||||||
|
"objc2-ui-kit",
|
||||||
|
"serde",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "osakit"
|
name = "osakit"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -5342,11 +5457,13 @@ dependencies = [
|
|||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-rustls",
|
"hyper-rustls",
|
||||||
|
"hyper-tls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
|
"native-tls",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
@@ -5357,6 +5474,7 @@ dependencies = [
|
|||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
@@ -6618,6 +6736,15 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sys-locale"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sysctl"
|
name = "sysctl"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -6877,6 +7004,26 @@ dependencies = [
|
|||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-aptabase"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "075ebe083b5f311bdfbf436f8383d316dfb12267be5fd977b57d7701bdb99987"
|
||||||
|
dependencies = [
|
||||||
|
"futures",
|
||||||
|
"log",
|
||||||
|
"os_info",
|
||||||
|
"rand 0.9.4",
|
||||||
|
"reqwest 0.12.28",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sys-locale",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-dialog"
|
name = "tauri-plugin-dialog"
|
||||||
version = "2.7.0"
|
version = "2.7.0"
|
||||||
@@ -7308,6 +7455,16 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-native-tls"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||||
|
dependencies = [
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-rayon"
|
name = "tokio-rayon"
|
||||||
version = "2.1.0"
|
version = "2.1.0"
|
||||||
@@ -7942,6 +8099,12 @@ dependencies = [
|
|||||||
"uuid 0.8.2",
|
"uuid 0.8.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -8952,7 +9115,7 @@ dependencies = [
|
|||||||
"futures-sink",
|
"futures-sink",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
"nix",
|
"nix 0.29.0",
|
||||||
"ordered-stream",
|
"ordered-stream",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-dialog = "=2.7.0"
|
tauri-plugin-dialog = "=2.7.0"
|
||||||
|
tauri-plugin-aptabase = "1.0"
|
||||||
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
|
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
|
||||||
commit_ai = { path = "crates/commit_ai" }
|
commit_ai = { path = "crates/commit_ai" }
|
||||||
|
tokio = "1.52.3"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-env-changed=APTABASE_KEY");
|
||||||
tauri_build::build();
|
tauri_build::build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-is-maximized",
|
"core:window:allow-is-maximized",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"dialog:allow-open"
|
"dialog:allow-open",
|
||||||
|
"aptabase:allow-track-event"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+738
-5
@@ -48,6 +48,7 @@ pub struct GitStatus {
|
|||||||
pub files: Vec<GitFileStatus>,
|
pub files: Vec<GitFileStatus>,
|
||||||
pub clean: bool,
|
pub clean: bool,
|
||||||
pub rebase_in_progress: bool,
|
pub rebase_in_progress: bool,
|
||||||
|
pub cherry_pick_in_progress: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
@@ -57,6 +58,16 @@ pub struct GitBranch {
|
|||||||
pub remote: bool,
|
pub remote: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct GitTag {
|
||||||
|
pub name: String,
|
||||||
|
pub hash: String,
|
||||||
|
pub short_hash: String,
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub date: String,
|
||||||
|
pub annotated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
pub struct GitStash {
|
pub struct GitStash {
|
||||||
pub selector: String,
|
pub selector: String,
|
||||||
@@ -106,6 +117,25 @@ pub struct GitCommitComparison {
|
|||||||
pub patch: String,
|
pub patch: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct GitBlameLine {
|
||||||
|
pub line_number: u32,
|
||||||
|
pub content: String,
|
||||||
|
pub commit_hash: String,
|
||||||
|
pub short_hash: String,
|
||||||
|
pub author_name: String,
|
||||||
|
pub author_email: String,
|
||||||
|
pub author_time: i64,
|
||||||
|
pub summary: String,
|
||||||
|
pub is_uncommitted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct GitBlameResult {
|
||||||
|
pub path: String,
|
||||||
|
pub lines: Vec<GitBlameLine>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
pub struct ConflictFile {
|
pub struct ConflictFile {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
@@ -245,11 +275,35 @@ pub fn open_repository_file(path: String, file: String) -> Result<(), String> {
|
|||||||
pub struct RepositoryBundle {
|
pub struct RepositoryBundle {
|
||||||
pub status: GitStatus,
|
pub status: GitStatus,
|
||||||
pub branches: Vec<GitBranch>,
|
pub branches: Vec<GitBranch>,
|
||||||
|
pub tags: Vec<GitTag>,
|
||||||
pub stashes: Vec<GitStash>,
|
pub stashes: Vec<GitStash>,
|
||||||
pub commits: Vec<GitCommit>,
|
pub commits: Vec<GitCommit>,
|
||||||
pub files: Vec<GitRepositoryFile>,
|
pub files: Vec<GitRepositoryFile>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clone_repository(
|
||||||
|
remote_url: String,
|
||||||
|
parent_path: String,
|
||||||
|
directory_name: Option<String>,
|
||||||
|
username: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
|
commit_limit: Option<u32>,
|
||||||
|
) -> Result<RepositoryBundle, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
clone_repository_core(
|
||||||
|
&remote_url,
|
||||||
|
&parent_path,
|
||||||
|
directory_name.as_deref(),
|
||||||
|
username.as_deref(),
|
||||||
|
password.as_deref(),
|
||||||
|
commit_limit,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not clone repository: {err}"))?
|
||||||
|
}
|
||||||
|
|
||||||
/// Opens a repository and gathers everything the UI needs in a single call.
|
/// Opens a repository and gathers everything the UI needs in a single call.
|
||||||
///
|
///
|
||||||
/// Runs on a blocking thread (so the UI/overlay stays responsive) and resolves
|
/// Runs on a blocking thread (so the UI/overlay stays responsive) and resolves
|
||||||
@@ -264,12 +318,14 @@ pub async fn open_repository_bundle(
|
|||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
let status = status_for_repo(&repo)?;
|
let status = status_for_repo(&repo)?;
|
||||||
let branches = branches_for_repo(&repo)?;
|
let branches = branches_for_repo(&repo)?;
|
||||||
|
let tags = tags_for_repo(&repo)?;
|
||||||
let stashes = stashes_for_repo(&repo)?;
|
let stashes = stashes_for_repo(&repo)?;
|
||||||
let commits = commits_for_repo(&repo, commit_limit)?;
|
let commits = commits_for_repo(&repo, commit_limit)?;
|
||||||
let files = repository_files_with_status(&repo, &status)?;
|
let files = repository_files_with_status(&repo, &status)?;
|
||||||
Ok(RepositoryBundle {
|
Ok(RepositoryBundle {
|
||||||
status,
|
status,
|
||||||
branches,
|
branches,
|
||||||
|
tags,
|
||||||
stashes,
|
stashes,
|
||||||
commits,
|
commits,
|
||||||
files,
|
files,
|
||||||
@@ -297,6 +353,63 @@ pub fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
|
|||||||
stashes_for_repo(&repo)
|
stashes_for_repo(&repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_tags(path: String) -> Result<Vec<GitTag>, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
tags_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tags_for_repo(repo: &Path) -> Result<Vec<GitTag>, String> {
|
||||||
|
let output = run_git(
|
||||||
|
repo,
|
||||||
|
[
|
||||||
|
"for-each-ref",
|
||||||
|
"--sort=-creatordate",
|
||||||
|
"refs/tags",
|
||||||
|
"--format=%(refname:short)%00%(objectname)%00%(objectname:short)%00%(*objectname)%00%(*objectname:short)%00%(contents:subject)%00%(creatordate:iso-strict)",
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
let text = String::from_utf8_lossy(&output);
|
||||||
|
let mut tags = Vec::new();
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
let mut parts = line.splitn(7, '\0');
|
||||||
|
let name = parts.next().unwrap_or_default().trim();
|
||||||
|
let object_hash = parts.next().unwrap_or_default().trim();
|
||||||
|
let object_short = parts.next().unwrap_or_default().trim();
|
||||||
|
let deref_hash = parts.next().unwrap_or_default().trim();
|
||||||
|
let deref_short = parts.next().unwrap_or_default().trim();
|
||||||
|
let subject = parts.next().unwrap_or_default().trim();
|
||||||
|
let date = parts.next().unwrap_or_default().trim();
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Annotated tags are their own object with a `taggerdate`/subject and
|
||||||
|
// dereference (`*...`) to the commit they point at; lightweight tags
|
||||||
|
// point straight at the commit, so the dereferenced fields are empty.
|
||||||
|
let annotated = !deref_hash.is_empty();
|
||||||
|
let hash = if annotated { deref_hash } else { object_hash };
|
||||||
|
let short_hash = if annotated { deref_short } else { object_short };
|
||||||
|
|
||||||
|
tags.push(GitTag {
|
||||||
|
name: name.to_string(),
|
||||||
|
hash: hash.to_string(),
|
||||||
|
short_hash: short_hash.to_string(),
|
||||||
|
message: if annotated && !subject.is_empty() {
|
||||||
|
Some(subject.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
date: date.to_string(),
|
||||||
|
annotated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(tags)
|
||||||
|
}
|
||||||
|
|
||||||
fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
|
fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
|
||||||
let output = run_git(
|
let output = run_git(
|
||||||
repo,
|
repo,
|
||||||
@@ -547,6 +660,123 @@ pub fn delete_branch(
|
|||||||
status_for_repo(&repo)
|
status_for_repo(&repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn create_tag(
|
||||||
|
path: String,
|
||||||
|
name: String,
|
||||||
|
target: Option<String>,
|
||||||
|
message: Option<String>,
|
||||||
|
) -> Result<Vec<GitTag>, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let name = validate_new_tag_name(&repo, &name)?;
|
||||||
|
let target = match target {
|
||||||
|
Some(target) if !target.trim().is_empty() => verify_commit(&repo, &target)?,
|
||||||
|
_ => verify_commit(&repo, "HEAD")?,
|
||||||
|
};
|
||||||
|
let message = message
|
||||||
|
.map(|message| message.trim().to_string())
|
||||||
|
.filter(|message| !message.is_empty());
|
||||||
|
|
||||||
|
match message {
|
||||||
|
Some(message) => {
|
||||||
|
run_git(
|
||||||
|
&repo,
|
||||||
|
["tag", "-a", name.as_str(), "-m", message.as_str(), target.as_str()],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
run_git(&repo, ["tag", name.as_str(), target.as_str()])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tags_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_tag(path: String, name: String) -> Result<Vec<GitTag>, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let name = validate_existing_tag_name(&repo, &name)?;
|
||||||
|
run_git(&repo, ["tag", "-d", name.as_str()])?;
|
||||||
|
tags_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn push_tag(
|
||||||
|
path: String,
|
||||||
|
name: String,
|
||||||
|
username: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let name = validate_existing_tag_name(&repo, &name)?;
|
||||||
|
let remote = initial_push_remote_name(&repo)?;
|
||||||
|
let tag_ref = format!("refs/tags/{name}");
|
||||||
|
let push_args = ["push", remote.as_str(), tag_ref.as_str()];
|
||||||
|
|
||||||
|
match (username.as_deref(), password.as_deref()) {
|
||||||
|
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
||||||
|
run_git_authenticated(&repo, push_args, u, p)?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
run_git(&repo, push_args)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_new_tag_name(repo: &Path, name: &str) -> Result<String, String> {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("Tag name must not be empty.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = validate_tag_ref_name(name)?;
|
||||||
|
if ref_exists(repo, &format!("refs/tags/{normalized}"))? {
|
||||||
|
return Err(format!("Tag '{normalized}' already exists."));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_existing_tag_name(repo: &Path, name: &str) -> Result<String, String> {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("Tag name must not be empty.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = validate_tag_ref_name(name)?;
|
||||||
|
if !ref_exists(repo, &format!("refs/tags/{normalized}"))? {
|
||||||
|
return Err(format!("Tag '{normalized}' was not found."));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_tag_ref_name(name: &str) -> Result<String, String> {
|
||||||
|
let output = git_command()
|
||||||
|
.args(["check-ref-format", "--allow-onelevel", &format!("refs/tags/{name}")])
|
||||||
|
.output()
|
||||||
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let details = command_output_details(&output);
|
||||||
|
return Err(format!("Invalid tag name: {details}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let normalized = normalized
|
||||||
|
.strip_prefix("refs/tags/")
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or(normalized);
|
||||||
|
|
||||||
|
Ok(if normalized.is_empty() {
|
||||||
|
name.to_string()
|
||||||
|
} else {
|
||||||
|
normalized
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
@@ -829,6 +1059,63 @@ pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
|
|||||||
status_for_repo(&repo)
|
status_for_repo(&repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn amend_commit(path: String, message: Option<String>) -> Result<GitStatus, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
if verify_commit(&repo, "HEAD").is_err() {
|
||||||
|
return Err("There is no commit to amend.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_status = status_for_repo(&repo)?;
|
||||||
|
if has_unresolved_conflicts(¤t_status) {
|
||||||
|
return Err("Merge conflicts must be resolved before you can commit.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = message
|
||||||
|
.map(|message| message.trim().to_string())
|
||||||
|
.filter(|message| !message.is_empty());
|
||||||
|
|
||||||
|
match message {
|
||||||
|
Some(message) => {
|
||||||
|
run_git(&repo, ["commit", "--amend", "-m", message.as_str()])?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
run_git(&repo, ["commit", "--amend", "--no-edit"])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn last_commit_message(path: String) -> Result<Option<String>, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
if verify_commit(&repo, "HEAD").is_err() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = run_git(&repo, ["log", "-1", "--format=%B", "HEAD"])?;
|
||||||
|
let message = String::from_utf8_lossy(&output).trim_end().to_string();
|
||||||
|
Ok(if message.is_empty() { None } else { Some(message) })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn undo_last_commit(path: String) -> Result<GitStatus, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
if verify_commit(&repo, "HEAD").is_err() {
|
||||||
|
return Err("There is no commit to undo.".to_string());
|
||||||
|
}
|
||||||
|
if verify_commit(&repo, "HEAD~1").is_err() {
|
||||||
|
return Err("This is the first commit; there is nothing to undo to.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mixed reset: moves HEAD back one commit and unstages the difference, but
|
||||||
|
// leaves the working tree files untouched, so the undone commit's changes
|
||||||
|
// reappear as ordinary uncommitted changes instead of being discarded.
|
||||||
|
run_git(&repo, ["reset", "HEAD~1"])?;
|
||||||
|
status_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn pull(
|
pub fn pull(
|
||||||
path: String,
|
path: String,
|
||||||
@@ -1191,6 +1478,68 @@ fn rebase_status_or_error(
|
|||||||
Err(format!("{context}: {}", command_output_details(&output)))
|
Err(format!("{context}: {}", command_output_details(&output)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatus, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let commit_hash = verify_commit(&repo, &commit)?;
|
||||||
|
|
||||||
|
let output = git_command()
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&repo)
|
||||||
|
.args(["cherry-pick", commit_hash.as_str()])
|
||||||
|
.env("GIT_EDITOR", "true")
|
||||||
|
.output()
|
||||||
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||||
|
|
||||||
|
cherry_pick_status_or_error(&repo, output, "Cherry-pick failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn cherry_pick_continue(path: String) -> Result<GitStatus, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
if !cherry_pick_in_progress(&repo) {
|
||||||
|
return Err("No cherry-pick is currently in progress.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = git_command()
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&repo)
|
||||||
|
.args(["cherry-pick", "--continue"])
|
||||||
|
.env("GIT_EDITOR", "true")
|
||||||
|
.output()
|
||||||
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||||
|
|
||||||
|
cherry_pick_status_or_error(&repo, output, "Cherry-pick continue failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn cherry_pick_abort(path: String) -> Result<GitStatus, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
if !cherry_pick_in_progress(&repo) {
|
||||||
|
return Err("No cherry-pick is currently in progress.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
run_git(&repo, ["cherry-pick", "--abort"])?;
|
||||||
|
status_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cherry_pick_status_or_error(
|
||||||
|
repo: &Path,
|
||||||
|
output: Output,
|
||||||
|
context: &str,
|
||||||
|
) -> Result<GitStatus, String> {
|
||||||
|
if output.status.success() {
|
||||||
|
return status_for_repo(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = status_for_repo(repo)?;
|
||||||
|
if has_unresolved_conflicts(&status) || status.cherry_pick_in_progress {
|
||||||
|
return Ok(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!("{context}: {}", command_output_details(&output)))
|
||||||
|
}
|
||||||
|
|
||||||
#[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>) -> Result<Vec<GitCommit>, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
@@ -1277,6 +1626,107 @@ pub fn cancel_file_history(
|
|||||||
state.cancel(request_id)
|
state.cancel(request_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UNCOMMITTED_BLAME_HASH: &str = "0000000000000000000000000000000000000000";
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_file_blame(path: String, file: String) -> Result<GitBlameResult, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
validate_files(std::slice::from_ref(&file))?;
|
||||||
|
|
||||||
|
if verify_commit(&repo, "HEAD").is_err() {
|
||||||
|
return Err("Repository has no commits yet.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let args = vec![
|
||||||
|
OsString::from("blame"),
|
||||||
|
OsString::from("--line-porcelain"),
|
||||||
|
OsString::from("--"),
|
||||||
|
OsString::from(file.clone()),
|
||||||
|
];
|
||||||
|
let output =
|
||||||
|
run_git(&repo, args).map_err(|err| format!("Could not load blame for '{file}': {err}"))?;
|
||||||
|
|
||||||
|
Ok(GitBlameResult {
|
||||||
|
path: file,
|
||||||
|
lines: parse_blame_porcelain(&output),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_blame_porcelain(output: &[u8]) -> Vec<GitBlameLine> {
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
struct BlameMeta {
|
||||||
|
author_name: String,
|
||||||
|
author_email: String,
|
||||||
|
author_time: i64,
|
||||||
|
summary: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = String::from_utf8_lossy(output);
|
||||||
|
let mut lines_out: Vec<GitBlameLine> = Vec::new();
|
||||||
|
let mut commit_meta: BTreeMap<String, BlameMeta> = BTreeMap::new();
|
||||||
|
let mut current_hash = String::new();
|
||||||
|
let mut current_final_line: u32 = 0;
|
||||||
|
|
||||||
|
for line in text.split('\n') {
|
||||||
|
if let Some(content) = line.strip_prefix('\t') {
|
||||||
|
let meta = commit_meta.get(¤t_hash).cloned().unwrap_or_default();
|
||||||
|
lines_out.push(GitBlameLine {
|
||||||
|
line_number: current_final_line,
|
||||||
|
content: content.to_string(),
|
||||||
|
commit_hash: current_hash.clone(),
|
||||||
|
short_hash: short_hash(¤t_hash),
|
||||||
|
author_name: meta.author_name,
|
||||||
|
author_email: meta.author_email,
|
||||||
|
author_time: meta.author_time,
|
||||||
|
summary: meta.summary,
|
||||||
|
is_uncommitted: current_hash == UNCOMMITTED_BLAME_HASH,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut parts = line.splitn(2, ' ');
|
||||||
|
let head = parts.next().unwrap_or("");
|
||||||
|
let tail = parts.next().unwrap_or("");
|
||||||
|
|
||||||
|
if head.len() == 40 && head.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||||
|
if let Some(final_line) = tail.split_whitespace().nth(1) {
|
||||||
|
current_final_line = final_line.parse().unwrap_or(current_final_line);
|
||||||
|
}
|
||||||
|
current_hash = head.to_string();
|
||||||
|
commit_meta.entry(current_hash.clone()).or_default();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match head {
|
||||||
|
"author" => {
|
||||||
|
commit_meta
|
||||||
|
.entry(current_hash.clone())
|
||||||
|
.or_default()
|
||||||
|
.author_name = tail.to_string()
|
||||||
|
}
|
||||||
|
"author-mail" => {
|
||||||
|
let email = tail.trim_matches(|c| c == '<' || c == '>').to_string();
|
||||||
|
commit_meta
|
||||||
|
.entry(current_hash.clone())
|
||||||
|
.or_default()
|
||||||
|
.author_email = email;
|
||||||
|
}
|
||||||
|
"author-time" => {
|
||||||
|
commit_meta
|
||||||
|
.entry(current_hash.clone())
|
||||||
|
.or_default()
|
||||||
|
.author_time = tail.parse().unwrap_or(0);
|
||||||
|
}
|
||||||
|
"summary" => {
|
||||||
|
commit_meta.entry(current_hash.clone()).or_default().summary = tail.to_string()
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines_out
|
||||||
|
}
|
||||||
|
|
||||||
fn list_file_history_core(
|
fn list_file_history_core(
|
||||||
repo: &Path,
|
repo: &Path,
|
||||||
file: String,
|
file: String,
|
||||||
@@ -2026,6 +2476,7 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
|
|||||||
clean: files.is_empty(),
|
clean: files.is_empty(),
|
||||||
files,
|
files,
|
||||||
rebase_in_progress: rebase_in_progress(repo),
|
rebase_in_progress: rebase_in_progress(repo),
|
||||||
|
cherry_pick_in_progress: cherry_pick_in_progress(repo),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2033,6 +2484,10 @@ fn rebase_in_progress(repo: &Path) -> bool {
|
|||||||
git_path_exists(repo, "rebase-merge") || git_path_exists(repo, "rebase-apply")
|
git_path_exists(repo, "rebase-merge") || git_path_exists(repo, "rebase-apply")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cherry_pick_in_progress(repo: &Path) -> bool {
|
||||||
|
git_path_exists(repo, "CHERRY_PICK_HEAD")
|
||||||
|
}
|
||||||
|
|
||||||
fn git_path_exists(repo: &Path, name: &str) -> bool {
|
fn git_path_exists(repo: &Path, name: &str) -> bool {
|
||||||
let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else {
|
let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else {
|
||||||
return false;
|
return false;
|
||||||
@@ -2208,6 +2663,168 @@ fn repository_files_with_status(
|
|||||||
Ok(files.into_values().collect())
|
Ok(files.into_values().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn clone_repository_core(
|
||||||
|
remote_url: &str,
|
||||||
|
parent_path: &str,
|
||||||
|
directory_name: Option<&str>,
|
||||||
|
username: Option<&str>,
|
||||||
|
password: Option<&str>,
|
||||||
|
commit_limit: Option<u32>,
|
||||||
|
) -> Result<RepositoryBundle, String> {
|
||||||
|
let target = clone_target_path(remote_url, parent_path, directory_name)?;
|
||||||
|
run_git_clone(remote_url.trim(), &target, username, password)?;
|
||||||
|
|
||||||
|
let repo = resolve_repo(&target.to_string_lossy())?;
|
||||||
|
let status = status_for_repo(&repo)?;
|
||||||
|
let branches = branches_for_repo(&repo)?;
|
||||||
|
let tags = tags_for_repo(&repo)?;
|
||||||
|
let stashes = stashes_for_repo(&repo)?;
|
||||||
|
let commits = commits_for_repo(&repo, commit_limit)?;
|
||||||
|
let files = repository_files_with_status(&repo, &status)?;
|
||||||
|
|
||||||
|
Ok(RepositoryBundle {
|
||||||
|
status,
|
||||||
|
branches,
|
||||||
|
tags,
|
||||||
|
stashes,
|
||||||
|
commits,
|
||||||
|
files,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clone_target_path(
|
||||||
|
remote_url: &str,
|
||||||
|
parent_path: &str,
|
||||||
|
directory_name: Option<&str>,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
|
let remote = remote_url.trim();
|
||||||
|
if remote.is_empty() {
|
||||||
|
return Err("Remote URL must not be empty.".to_string());
|
||||||
|
}
|
||||||
|
if remote.starts_with('-') || remote.chars().any(|c| c.is_control()) {
|
||||||
|
return Err("Remote URL contains invalid characters.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent = PathBuf::from(parent_path.trim());
|
||||||
|
if parent_path.trim().is_empty() {
|
||||||
|
return Err("Destination folder must not be empty.".to_string());
|
||||||
|
}
|
||||||
|
if !parent.exists() {
|
||||||
|
return Err("Destination folder does not exist.".to_string());
|
||||||
|
}
|
||||||
|
if !parent.is_dir() {
|
||||||
|
return Err("Destination path must be a folder.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw_name = directory_name
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.unwrap_or_else(|| infer_clone_directory_name(remote));
|
||||||
|
let name = validate_clone_directory_name(&raw_name)?;
|
||||||
|
let target = parent.join(name);
|
||||||
|
|
||||||
|
if target.exists() {
|
||||||
|
if !target.is_dir() {
|
||||||
|
return Err("Clone destination already exists and is not a folder.".to_string());
|
||||||
|
}
|
||||||
|
let mut entries = target
|
||||||
|
.read_dir()
|
||||||
|
.map_err(|err| format!("Could not inspect clone destination: {err}"))?;
|
||||||
|
if entries.next().is_some() {
|
||||||
|
return Err("Clone destination already exists and is not empty.".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn infer_clone_directory_name(remote_url: &str) -> String {
|
||||||
|
let trimmed = remote_url
|
||||||
|
.trim()
|
||||||
|
.split(['?', '#'])
|
||||||
|
.next()
|
||||||
|
.unwrap_or(remote_url)
|
||||||
|
.trim_end_matches(['/', '\\']);
|
||||||
|
let last_segment = trimmed
|
||||||
|
.rsplit(['/', '\\', ':'])
|
||||||
|
.find(|part| !part.trim().is_empty())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
last_segment
|
||||||
|
.strip_suffix(".git")
|
||||||
|
.unwrap_or(last_segment)
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_clone_directory_name(name: &str) -> Result<String, String> {
|
||||||
|
let trimmed = name.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err("Folder name could not be inferred. Enter a folder name.".to_string());
|
||||||
|
}
|
||||||
|
if trimmed == "." || trimmed == ".." {
|
||||||
|
return Err("Folder name is not valid.".to_string());
|
||||||
|
}
|
||||||
|
if trimmed.chars().any(|c| {
|
||||||
|
c.is_control() || matches!(c, '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*')
|
||||||
|
}) {
|
||||||
|
return Err("Folder name contains invalid characters.".to_string());
|
||||||
|
}
|
||||||
|
if Path::new(trimmed).is_absolute() {
|
||||||
|
return Err("Folder name must be relative.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(trimmed.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_git_clone(
|
||||||
|
remote_url: &str,
|
||||||
|
target: &Path,
|
||||||
|
username: Option<&str>,
|
||||||
|
password: Option<&str>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut command = git_command();
|
||||||
|
command
|
||||||
|
.arg("clone")
|
||||||
|
.arg("--")
|
||||||
|
.arg(remote_url)
|
||||||
|
.arg(target)
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0");
|
||||||
|
|
||||||
|
let askpass = match (username, password) {
|
||||||
|
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
||||||
|
let askpass = write_askpass_script()?;
|
||||||
|
command
|
||||||
|
.env("GIT_ASKPASS", &askpass)
|
||||||
|
.env("GIT_CRED_USER", u)
|
||||||
|
.env("GIT_CRED_PASS", p);
|
||||||
|
Some(askpass)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let output = command
|
||||||
|
.output()
|
||||||
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"));
|
||||||
|
if let Some(path) = askpass {
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
let output = output?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let details = command_output_details(&output);
|
||||||
|
if is_auth_error(&details) {
|
||||||
|
return Err(format!("AUTH_FAILED:{details}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!("Git clone failed: {}", details))
|
||||||
|
}
|
||||||
|
|
||||||
fn is_repository_folder_path(repo: &Path, path: &str) -> Result<bool, String> {
|
fn is_repository_folder_path(repo: &Path, path: &str) -> Result<bool, String> {
|
||||||
let normalized = normalize_git_path(path);
|
let normalized = normalize_git_path(path);
|
||||||
if repo.join(path).is_dir() {
|
if repo.join(path).is_dir() {
|
||||||
@@ -3232,16 +3849,47 @@ fn is_auth_error(details: &str) -> bool {
|
|||||||
|| d.contains("authentication required")
|
|| d.contains("authentication required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Windows' CreateProcess rejects command lines longer than ~32K chars with
|
||||||
|
// "os error 206" (filename or extension too long). Staging/restoring a large
|
||||||
|
// number of files can easily exceed that, so split the paths across multiple
|
||||||
|
// invocations and concatenate their output.
|
||||||
|
const MAX_PATH_ARGS_CHARS: usize = 8_000;
|
||||||
|
|
||||||
fn run_git_with_paths(
|
fn run_git_with_paths(
|
||||||
repo: &Path,
|
repo: &Path,
|
||||||
base_args: &[&str],
|
base_args: &[&str],
|
||||||
files: &[String],
|
files: &[String],
|
||||||
) -> Result<Vec<u8>, String> {
|
) -> Result<Vec<u8>, String> {
|
||||||
let mut args = Vec::with_capacity(base_args.len() + files.len() + 1);
|
if files.is_empty() {
|
||||||
args.extend(base_args.iter().map(OsString::from));
|
let args: Vec<OsString> = base_args.iter().map(OsString::from).collect();
|
||||||
args.push(OsString::from("--"));
|
return run_git(repo, args);
|
||||||
args.extend(files.iter().map(OsString::from));
|
}
|
||||||
run_git(repo, args)
|
|
||||||
|
let mut combined = Vec::new();
|
||||||
|
let mut start = 0;
|
||||||
|
while start < files.len() {
|
||||||
|
let mut end = start;
|
||||||
|
let mut chunk_chars = 0usize;
|
||||||
|
while end < files.len() {
|
||||||
|
let len = files[end].len() + 1;
|
||||||
|
if end > start && chunk_chars + len > MAX_PATH_ARGS_CHARS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
chunk_chars += len;
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
let chunk = &files[start..end];
|
||||||
|
|
||||||
|
let mut args = Vec::with_capacity(base_args.len() + chunk.len() + 1);
|
||||||
|
args.extend(base_args.iter().map(OsString::from));
|
||||||
|
args.push(OsString::from("--"));
|
||||||
|
args.extend(chunk.iter().map(OsString::from));
|
||||||
|
combined.extend(run_git(repo, args)?);
|
||||||
|
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(combined)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_git_with_paths_cancellable(
|
fn run_git_with_paths_cancellable(
|
||||||
@@ -3682,6 +4330,51 @@ mod tests {
|
|||||||
run_git_test(repo, ["commit", "-q", "-m", "init"]);
|
run_git_test(repo, ["commit", "-q", "-m", "init"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clone_directory_name_is_inferred_from_common_remote_urls() {
|
||||||
|
assert_eq!(
|
||||||
|
infer_clone_directory_name("https://github.com/example/project.git"),
|
||||||
|
"project"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
infer_clone_directory_name("git@github.com:example/project.git"),
|
||||||
|
"project"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
infer_clone_directory_name("ssh://git@example.com/example/project.git/"),
|
||||||
|
"project"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clone_repository_core_clones_and_returns_repository_bundle() {
|
||||||
|
let source = init_temp_repo("clone_source");
|
||||||
|
commit_initial_file(&source.path);
|
||||||
|
let parent = temp_dir("clone_parent");
|
||||||
|
|
||||||
|
let bundle = clone_repository_core(
|
||||||
|
source.path.to_str().expect("source path should be UTF-8"),
|
||||||
|
parent.path.to_str().expect("parent path should be UTF-8"),
|
||||||
|
Some("local-copy"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(100),
|
||||||
|
)
|
||||||
|
.expect("repository should clone");
|
||||||
|
|
||||||
|
let cloned_repo = parent.path.join("local-copy");
|
||||||
|
assert_eq!(
|
||||||
|
PathBuf::from(bundle.status.repo_path),
|
||||||
|
cloned_repo
|
||||||
|
.canonicalize()
|
||||||
|
.expect("clone path should resolve")
|
||||||
|
);
|
||||||
|
assert!(cloned_repo.join("old.txt").exists());
|
||||||
|
assert!(bundle.status.clean);
|
||||||
|
assert_eq!(bundle.commits.len(), 1);
|
||||||
|
assert!(bundle.files.iter().any(|file| file.path == "old.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn search_code_introductions_finds_added_string() {
|
fn search_code_introductions_finds_added_string() {
|
||||||
let repo = init_temp_repo("search_added_string");
|
let repo = init_temp_repo("search_added_string");
|
||||||
@@ -4846,4 +5539,44 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(result.unwrap_err(), SEARCH_CANCELLED_MESSAGE);
|
assert_eq!(result.unwrap_err(), SEARCH_CANCELLED_MESSAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_file_blame_attributes_lines_to_the_commits_that_introduced_them() {
|
||||||
|
let repo = init_temp_repo("file_blame");
|
||||||
|
commit_initial_file(&repo.path);
|
||||||
|
fs::write(repo.path.join("old.txt"), "original\ntwo\n").expect("tracked file should change");
|
||||||
|
run_git_test(&repo.path, ["add", "old.txt"]);
|
||||||
|
run_git_test(&repo.path, ["commit", "-q", "-m", "add second line"]);
|
||||||
|
|
||||||
|
let result = get_file_blame(
|
||||||
|
repo.path.to_string_lossy().to_string(),
|
||||||
|
"old.txt".to_string(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.path, "old.txt");
|
||||||
|
assert_eq!(result.lines.len(), 2);
|
||||||
|
assert_eq!(result.lines[0].content, "original");
|
||||||
|
assert_eq!(result.lines[0].summary, "init");
|
||||||
|
assert_eq!(result.lines[1].content, "two");
|
||||||
|
assert_eq!(result.lines[1].summary, "add second line");
|
||||||
|
assert!(!result.lines[1].author_name.is_empty());
|
||||||
|
assert!(!result.lines[1].is_uncommitted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_file_blame_marks_uncommitted_working_tree_changes() {
|
||||||
|
let repo = init_temp_repo("file_blame_uncommitted");
|
||||||
|
commit_initial_file(&repo.path);
|
||||||
|
fs::write(repo.path.join("old.txt"), "changed\n").expect("tracked file should change");
|
||||||
|
|
||||||
|
let result = get_file_blame(
|
||||||
|
repo.path.to_string_lossy().to_string(),
|
||||||
|
"old.txt".to_string(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.lines.len(), 1);
|
||||||
|
assert!(result.lines[0].is_uncommitted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-11
@@ -5,26 +5,37 @@ mod git;
|
|||||||
|
|
||||||
use badge::set_sync_badge;
|
use badge::set_sync_badge;
|
||||||
use git::{
|
use git::{
|
||||||
SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history,
|
SearchCancellationState, amend_commit, apply_file_patch, cancel_code_search,
|
||||||
checkout_branch, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models,
|
cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit,
|
||||||
commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch,
|
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load,
|
||||||
cred_delete, cred_load, cred_save, delete_branch, diff_file_against_working_tree, fetch,
|
commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head,
|
||||||
get_file_patch, get_remote_url, get_status, list_branches, list_commits, list_file_history,
|
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
|
||||||
list_repository_files, list_stashes, merge_branch, open_repo_in_explorer, open_repository,
|
delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_blame,
|
||||||
open_repository_bundle, open_repository_file, pull, push, read_conflict, rebase_abort,
|
get_file_patch, get_remote_url, get_status, last_commit_message, list_branches, list_commits,
|
||||||
rebase_branch, rebase_continue, rename_branch, resolve_conflict, resolve_conflict_side,
|
list_file_history, list_repository_files, list_stashes, list_tags, merge_branch,
|
||||||
restore_file_from_commit, restore_files, restore_to_commit, search_code_introductions,
|
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
|
||||||
stage_files, stash_apply, stash_drop, stash_pop, stash_push, unstage_files,
|
push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch,
|
||||||
|
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
|
||||||
|
restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop,
|
||||||
|
stash_push, undo_last_commit, unstage_files,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
|
.plugin(tauri_plugin_aptabase::Builder::new("A-SH-1344793789").with_options(
|
||||||
|
tauri_plugin_aptabase::InitOptions {
|
||||||
|
host: Some("https://aptabase.cbsk-tech.de".to_string()),
|
||||||
|
flush_interval:None
|
||||||
|
}
|
||||||
|
).build())
|
||||||
.manage(SearchCancellationState::default())
|
.manage(SearchCancellationState::default())
|
||||||
.manage(commit_ai::CommitAiEngine::new())
|
.manage(commit_ai::CommitAiEngine::new())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
open_repository,
|
open_repository,
|
||||||
|
clone_repository,
|
||||||
open_repo_in_explorer,
|
open_repo_in_explorer,
|
||||||
open_repository_file,
|
open_repository_file,
|
||||||
get_status,
|
get_status,
|
||||||
@@ -34,6 +45,13 @@ fn main() {
|
|||||||
create_branch,
|
create_branch,
|
||||||
rename_branch,
|
rename_branch,
|
||||||
delete_branch,
|
delete_branch,
|
||||||
|
list_tags,
|
||||||
|
create_tag,
|
||||||
|
delete_tag,
|
||||||
|
push_tag,
|
||||||
|
cherry_pick_commit,
|
||||||
|
cherry_pick_continue,
|
||||||
|
cherry_pick_abort,
|
||||||
stage_files,
|
stage_files,
|
||||||
unstage_files,
|
unstage_files,
|
||||||
stash_push,
|
stash_push,
|
||||||
@@ -44,6 +62,9 @@ fn main() {
|
|||||||
get_file_patch,
|
get_file_patch,
|
||||||
apply_file_patch,
|
apply_file_patch,
|
||||||
commit,
|
commit,
|
||||||
|
amend_commit,
|
||||||
|
undo_last_commit,
|
||||||
|
last_commit_message,
|
||||||
commit_ai_status,
|
commit_ai_status,
|
||||||
commit_ai_load,
|
commit_ai_load,
|
||||||
commit_ai_local_models,
|
commit_ai_local_models,
|
||||||
@@ -62,6 +83,7 @@ fn main() {
|
|||||||
open_repository_bundle,
|
open_repository_bundle,
|
||||||
list_file_history,
|
list_file_history,
|
||||||
cancel_file_history,
|
cancel_file_history,
|
||||||
|
get_file_blame,
|
||||||
compare_commits,
|
compare_commits,
|
||||||
compare_file_to_head,
|
compare_file_to_head,
|
||||||
compare_file_to_parent,
|
compare_file_to_parent,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "GitLite",
|
"productName": "GitLite",
|
||||||
"version": "2026.7.10",
|
"version": "2026.7.14",
|
||||||
"identifier": "com.git-lite",
|
"identifier": "com.git-lite",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@@ -28,8 +28,13 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": ["nsis"],
|
"targets": [
|
||||||
"icon": ["icons/icon.ico"],
|
"nsis"
|
||||||
|
],
|
||||||
|
"icon": [
|
||||||
|
"icons/icon.png",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
"createUpdaterArtifacts": true,
|
"createUpdaterArtifacts": true,
|
||||||
"windows": {
|
"windows": {
|
||||||
"nsis": {
|
"nsis": {
|
||||||
@@ -40,7 +45,7 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"updater": {
|
"updater": {
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDdDNEJGRTZERTREOTc5QjkKUldTNWVkbmtiZjVMZk02YzdpenVLOVltREJpRkFrc2F4dUwzaTV5M3pacFRVR0tmd25xSGtjK1MK",
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDdDNEJGRTZERTREOTc5QjkKUldTNWVkbmtiZjVMZk02YzdpenVLOVltREJpRkFrc2F4dUwzaTV5M3pacFRVR0tmd25xSGtjK1MK",
|
||||||
"endpoints" : [
|
"endpoints": [
|
||||||
"https://cdn.cbsk-tech.de/gitlite/latest.json"
|
"https://cdn.cbsk-tech.de/gitlite/latest.json"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"bundle": {
|
||||||
|
"targets": ["appimage"]
|
||||||
|
}
|
||||||
|
}
|
||||||
+706
-26
File diff suppressed because it is too large
Load Diff
+446
-28
@@ -234,8 +234,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
background: rgba(4,8,18,0.58);
|
background: #050712;
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-filter-dialog {
|
.branch-filter-dialog {
|
||||||
@@ -247,8 +246,8 @@
|
|||||||
border: 1px solid rgba(94,110,156,0.24);
|
border: 1px solid rgba(94,110,156,0.24);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255,255,255,0.045), transparent 70%),
|
linear-gradient(180deg, #20253f 0%, #151827 72%),
|
||||||
var(--color-surface);
|
#151827;
|
||||||
box-shadow: 0 24px 80px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.05);
|
box-shadow: 0 24px 80px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,7 +439,13 @@
|
|||||||
|
|
||||||
/* --- App shell --- */
|
/* --- App shell --- */
|
||||||
|
|
||||||
.shell { display: flex; flex-direction: column; height: 100%; background: transparent; }
|
.shell {
|
||||||
|
--app-titlebar-height: 42px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.shell-body {
|
.shell-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -454,10 +459,12 @@
|
|||||||
/* --- Titlebar --- */
|
/* --- Titlebar --- */
|
||||||
|
|
||||||
.titlebar {
|
.titlebar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 500;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr auto;
|
grid-template-columns: auto 1fr auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 42px;
|
height: var(--app-titlebar-height);
|
||||||
background:
|
background:
|
||||||
linear-gradient(90deg, rgba(100,108,255,0.16), rgba(65,209,255,0.08), rgba(255,211,67,0.035)),
|
linear-gradient(90deg, rgba(100,108,255,0.16), rgba(65,209,255,0.08), rgba(255,211,67,0.035)),
|
||||||
var(--color-bar);
|
var(--color-bar);
|
||||||
@@ -833,8 +840,8 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
.repo-row-meta .ahead { color: #4eca76; }
|
.repo-row-meta .ahead { color: #e0a040; background: rgba(224,160,64,0.13); }
|
||||||
.repo-row-meta .behind { color: #e0a040; }
|
.repo-row-meta .behind { color: #7aacff; background: rgba(122,172,255,0.13); }
|
||||||
.repo-row-icon {
|
.repo-row-icon {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
min-width: 36px;
|
min-width: 36px;
|
||||||
@@ -1550,6 +1557,12 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-group-head { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.tag-group-head .branch-group-toggle { flex: 1; min-width: 0; }
|
||||||
|
.tag-group-head .branch-create-toggle { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.tag-create-form { grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr) auto auto; }
|
||||||
|
|
||||||
.branch-empty {
|
.branch-empty {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
color: var(--color-ink-faint);
|
color: var(--color-ink-faint);
|
||||||
@@ -1639,6 +1652,7 @@
|
|||||||
.branch-actions .btn-sm { min-height: 24px; padding: 0 6px; font-size: 11px; }
|
.branch-actions .btn-sm { min-height: 24px; padding: 0 6px; font-size: 11px; }
|
||||||
|
|
||||||
.branch-context-menu,
|
.branch-context-menu,
|
||||||
|
.history-context-menu,
|
||||||
.explorer-context-menu {
|
.explorer-context-menu {
|
||||||
z-index: 120;
|
z-index: 120;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1651,10 +1665,12 @@
|
|||||||
box-shadow: 0 18px 50px rgba(0,0,0,0.5);
|
box-shadow: 0 18px 50px rgba(0,0,0,0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-context-menu { position: absolute; }
|
.branch-context-menu,
|
||||||
|
.history-context-menu { position: absolute; }
|
||||||
.explorer-context-menu { position: fixed; }
|
.explorer-context-menu { position: fixed; }
|
||||||
|
|
||||||
.branch-context-menu button,
|
.branch-context-menu button,
|
||||||
|
.history-context-menu button,
|
||||||
.explorer-context-menu button {
|
.explorer-context-menu button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1673,13 +1689,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.branch-context-menu button:hover:not(:disabled),
|
.branch-context-menu button:hover:not(:disabled),
|
||||||
|
.history-context-menu button:hover:not(:disabled),
|
||||||
.explorer-context-menu button:hover:not(:disabled) {
|
.explorer-context-menu button:hover:not(:disabled) {
|
||||||
border-color: var(--color-border-subtle);
|
border-color: var(--color-border-subtle);
|
||||||
background: rgba(255,255,255,0.06);
|
background: rgba(255,255,255,0.06);
|
||||||
color: var(--color-ink);
|
color: var(--color-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-context-menu .menu-separator {
|
.branch-context-menu .menu-separator,
|
||||||
|
.history-context-menu .menu-separator {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
margin: 4px 3px;
|
margin: 4px 3px;
|
||||||
background: var(--color-border-subtle);
|
background: var(--color-border-subtle);
|
||||||
@@ -1696,6 +1714,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.branch-context-menu button:disabled,
|
.branch-context-menu button:disabled,
|
||||||
|
.history-context-menu button:disabled,
|
||||||
.explorer-context-menu button:disabled {
|
.explorer-context-menu button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.48;
|
opacity: 0.48;
|
||||||
@@ -1795,6 +1814,56 @@
|
|||||||
resize: none;
|
resize: none;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
.commit-amend-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.commit-amend-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--color-ink-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.commit-amend-toggle input {
|
||||||
|
appearance: none;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
min-width: 14px;
|
||||||
|
min-height: 14px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid rgba(94,110,156,0.42);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(13,14,26,0.78);
|
||||||
|
box-shadow: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.commit-amend-toggle input:checked {
|
||||||
|
border-color: rgba(65,209,255,0.62);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(65,209,255,0.92), rgba(100,108,255,0.92));
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(13,14,26,0.82);
|
||||||
|
}
|
||||||
|
.commit-amend-toggle input:focus {
|
||||||
|
box-shadow: 0 0 0 2px rgba(65,209,255,0.2);
|
||||||
|
}
|
||||||
|
.commit-amend-toggle input:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.commit-undo-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.commit-actions-row {
|
.commit-actions-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -1817,6 +1886,7 @@
|
|||||||
|
|
||||||
/* --- Commit history --- */
|
/* --- Commit history --- */
|
||||||
|
|
||||||
|
.history-panel { position: relative; }
|
||||||
.history-list { min-width: 0; padding: 6px; overflow: auto; }
|
.history-list { min-width: 0; padding: 6px; overflow: auto; }
|
||||||
|
|
||||||
.commit-row { display: grid; min-width: 0; gap: 8px; padding: 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); transition: border-color 120ms; }
|
.commit-row { display: grid; min-width: 0; gap: 8px; padding: 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); transition: border-color 120ms; }
|
||||||
@@ -2017,6 +2087,22 @@
|
|||||||
}
|
}
|
||||||
.commit-action-buttons { display: flex; flex: 0 0 auto; align-items: center; gap: 5px; }
|
.commit-action-buttons { display: flex; flex: 0 0 auto; align-items: center; gap: 5px; }
|
||||||
.commit-action-buttons button { flex: 0 0 auto; white-space: nowrap; }
|
.commit-action-buttons button { flex: 0 0 auto; white-space: nowrap; }
|
||||||
|
.commit-menu-button {
|
||||||
|
width: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border-color: rgba(94,110,156,0.16);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: rgba(255,255,255,0.035);
|
||||||
|
color: var(--color-ink-dim);
|
||||||
|
}
|
||||||
|
.commit-menu-button:hover:not(:disabled),
|
||||||
|
.commit-menu-button[aria-expanded="true"] {
|
||||||
|
border-color: rgba(65,209,255,0.28);
|
||||||
|
background: rgba(65,209,255,0.08);
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
.file-history-head { align-items: flex-start; }
|
.file-history-head { align-items: flex-start; }
|
||||||
.file-history-heading { min-width: 0; flex: 1 1 auto; overflow: hidden; }
|
.file-history-heading { min-width: 0; flex: 1 1 auto; overflow: hidden; }
|
||||||
.section-head .file-history-name {
|
.section-head .file-history-name {
|
||||||
@@ -2162,6 +2248,15 @@
|
|||||||
.graph-svg path.hidden-branch {
|
.graph-svg path.hidden-branch {
|
||||||
opacity: 0.08;
|
opacity: 0.08;
|
||||||
}
|
}
|
||||||
|
.graph-svg path.graph-segment-ahead {
|
||||||
|
stroke: #e0a040;
|
||||||
|
filter: drop-shadow(0 0 3px rgba(224,160,64,0.24));
|
||||||
|
}
|
||||||
|
.graph-svg path.graph-segment-behind {
|
||||||
|
stroke: #7aacff;
|
||||||
|
stroke-dasharray: 4 4;
|
||||||
|
filter: drop-shadow(0 0 3px rgba(122,172,255,0.24));
|
||||||
|
}
|
||||||
.graph-dot {
|
.graph-dot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
@@ -2179,6 +2274,14 @@
|
|||||||
opacity: 0.16;
|
opacity: 0.16;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
.graph-dot.ahead {
|
||||||
|
background: #e0a040;
|
||||||
|
box-shadow: 0 0 0 1px rgba(224,160,64,0.42), 0 0 10px rgba(224,160,64,0.16);
|
||||||
|
}
|
||||||
|
.graph-dot.behind {
|
||||||
|
background: #7aacff;
|
||||||
|
box-shadow: 0 0 0 1px rgba(122,172,255,0.46), 0 0 10px rgba(122,172,255,0.18);
|
||||||
|
}
|
||||||
.graph-dot.tip { width: 14px; height: 14px; box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8); }
|
.graph-dot.tip { width: 14px; height: 14px; box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8); }
|
||||||
.graph-dot.merge {
|
.graph-dot.merge {
|
||||||
width: 15px;
|
width: 15px;
|
||||||
@@ -2200,8 +2303,7 @@
|
|||||||
transform: translateY(-50%) translateX(-4px);
|
transform: translateY(-50%) translateX(-4px);
|
||||||
transition: opacity 120ms ease, transform 120ms ease;
|
transition: opacity 120ms ease, transform 120ms ease;
|
||||||
}
|
}
|
||||||
.graph-gutter:hover .graph-hover-branches,
|
.graph-gutter:hover .graph-hover-branches {
|
||||||
.graph-row:hover .graph-hover-branches {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(-50%) translateX(0);
|
transform: translateY(-50%) translateX(0);
|
||||||
}
|
}
|
||||||
@@ -2225,6 +2327,22 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.graph-hover-branches span.remote,
|
||||||
|
.branch-filter-option.remote {
|
||||||
|
border-color: rgba(122,172,255,0.32);
|
||||||
|
color: #bcd2ff;
|
||||||
|
background: rgba(31,43,72,0.88);
|
||||||
|
}
|
||||||
|
.graph-hover-branches span.ahead {
|
||||||
|
border-color: rgba(224,160,64,0.42);
|
||||||
|
color: #ffd99a;
|
||||||
|
background: rgba(58,42,20,0.94);
|
||||||
|
}
|
||||||
|
.graph-hover-branches span.behind {
|
||||||
|
border-color: rgba(122,172,255,0.46);
|
||||||
|
color: #c7dbff;
|
||||||
|
background: rgba(25,39,70,0.94);
|
||||||
|
}
|
||||||
.graph-hover-branches svg {
|
.graph-hover-branches svg {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
color: #76d995;
|
color: #76d995;
|
||||||
@@ -2238,6 +2356,12 @@
|
|||||||
transition: background 120ms ease;
|
transition: background 120ms ease;
|
||||||
}
|
}
|
||||||
.graph-row + .graph-row .commit-body { border-top: 1px solid rgba(94,110,156,0.1); }
|
.graph-row + .graph-row .commit-body { border-top: 1px solid rgba(94,110,156,0.1); }
|
||||||
|
.graph-row.graph-ahead-row .commit-body {
|
||||||
|
box-shadow: inset 3px 0 0 rgba(224,160,64,0.72);
|
||||||
|
}
|
||||||
|
.graph-row.graph-behind-row .commit-body {
|
||||||
|
box-shadow: inset 3px 0 0 rgba(122,172,255,0.72);
|
||||||
|
}
|
||||||
.graph-row:hover .commit-body { background: rgba(37,40,62,0.54); }
|
.graph-row:hover .commit-body { background: rgba(37,40,62,0.54); }
|
||||||
.graph-row:hover .graph-svg path { opacity: 1; stroke-width: 2.65; }
|
.graph-row:hover .graph-svg path { opacity: 1; stroke-width: 2.65; }
|
||||||
.graph-row:hover .graph-svg path.hidden-branch { opacity: 0.12; stroke-width: 2.2; }
|
.graph-row:hover .graph-svg path.hidden-branch { opacity: 0.12; stroke-width: 2.2; }
|
||||||
@@ -2299,8 +2423,12 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
background: rgba(0, 0, 0, 0.65);
|
background-color: #050712;
|
||||||
backdrop-filter: blur(2px);
|
background-image: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chrome-backdrop {
|
||||||
|
top: var(--app-titlebar-height, 42px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog {
|
.dialog {
|
||||||
@@ -2310,7 +2438,8 @@
|
|||||||
height: min(760px, 100%);
|
height: min(760px, 100%);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: var(--color-surface);
|
background-color: #111321;
|
||||||
|
background-image: none;
|
||||||
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.6), 0 2px 12px rgba(0,0,0,0.4);
|
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.6), 0 2px 12px rgba(0,0,0,0.4);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -2327,6 +2456,11 @@
|
|||||||
width: min(1320px, calc(100vw - 32px));
|
width: min(1320px, calc(100vw - 32px));
|
||||||
height: min(860px, calc(100vh - 32px));
|
height: min(860px, calc(100vh - 32px));
|
||||||
}
|
}
|
||||||
|
.blame-dialog {
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
width: min(1320px, calc(100vw - 32px));
|
||||||
|
height: min(860px, calc(100vh - 32px));
|
||||||
|
}
|
||||||
.compare-select-dialog {
|
.compare-select-dialog {
|
||||||
display: block;
|
display: block;
|
||||||
width: min(720px, calc(100vw - 32px));
|
width: min(720px, calc(100vw - 32px));
|
||||||
@@ -2363,12 +2497,163 @@
|
|||||||
max-height: calc(100vh - 32px);
|
max-height: calc(100vh - 32px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
.app-settings-dialog,
|
||||||
|
.analytics-notice-dialog {
|
||||||
|
display: block;
|
||||||
|
width: min(620px, calc(100vw - 32px));
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.clone-repository-dialog {
|
||||||
|
display: block;
|
||||||
|
width: min(620px, calc(100vw - 32px));
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.clone-dialog-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.clone-dialog-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.clone-dialog-field > span {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.clone-dialog-path-field {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.clone-dialog-error {
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid rgba(232,96,90,0.3);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: #f09090;
|
||||||
|
background: rgba(232,96,90,0.08);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.clone-dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
.ai-settings-form {
|
.ai-settings-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
.app-settings-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.settings-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.settings-section > header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
.settings-section > header svg {
|
||||||
|
color: var(--color-accent);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.settings-section h3 {
|
||||||
|
margin: 1px 0 0;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
.settings-toggle-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #151827;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.settings-toggle-row input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-top: 2px;
|
||||||
|
accent-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.settings-toggle-row span {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.settings-toggle-row strong {
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.settings-toggle-row small {
|
||||||
|
color: var(--color-ink-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.analytics-notice-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px 16px 16px;
|
||||||
|
}
|
||||||
|
.analytics-notice-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid rgba(65,209,255,0.28);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
background: rgba(65,209,255,0.08);
|
||||||
|
}
|
||||||
|
.analytics-notice-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.analytics-notice-copy p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.analytics-notice-toggle {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.analytics-notice-footer {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
.ai-provider-options {
|
.ai-provider-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -2478,7 +2763,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); }
|
.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: #171a2b; }
|
||||||
.dialog-header > div:first-child { min-width: 0; }
|
.dialog-header > div:first-child { min-width: 0; }
|
||||||
.dialog-header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex: 0 0 auto; min-width: 0; }
|
.dialog-header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex: 0 0 auto; min-width: 0; }
|
||||||
.compare-restore { max-width: 170px; min-width: 0; }
|
.compare-restore { max-width: 170px; min-width: 0; }
|
||||||
@@ -2492,7 +2777,7 @@
|
|||||||
.dialog-body { display: grid; grid-template-columns: 280px minmax(0, 1fr); min-height: 0; }
|
.dialog-body { display: grid; grid-template-columns: 280px minmax(0, 1fr); min-height: 0; }
|
||||||
.compare-dialog .dialog-body { grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); }
|
.compare-dialog .dialog-body { grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); }
|
||||||
|
|
||||||
.dialog-files { display: grid; align-content: start; gap: 4px; padding: 8px; overflow: auto; border-right: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); }
|
.dialog-files { display: grid; align-content: start; gap: 4px; padding: 8px; overflow: auto; border-right: 1px solid var(--color-border-subtle); background: #171a2b; }
|
||||||
|
|
||||||
.dialog-file-row {
|
.dialog-file-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -2522,7 +2807,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 7px 12px;
|
padding: 7px 12px;
|
||||||
border-bottom: 1px solid var(--color-border-subtle);
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
background: var(--color-surface-dim);
|
background: #171a2b;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--color-ink-muted);
|
color: var(--color-ink-muted);
|
||||||
@@ -2544,7 +2829,7 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
tab-size: 2;
|
tab-size: 2;
|
||||||
background: var(--color-surface-raised);
|
background: #111321;
|
||||||
}
|
}
|
||||||
|
|
||||||
.split-pane {
|
.split-pane {
|
||||||
@@ -2568,7 +2853,7 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
.split-span.split-meta { color: var(--color-ink-faint); background: rgba(0,0,0,0.18); font-size: 11px; }
|
.split-span.split-meta { color: var(--color-ink-faint); background: #0c0e18; font-size: 11px; }
|
||||||
.split-span.split-hunk { color: #7aacff; background: rgba(122,172,255,0.08); padding: 3px 10px; }
|
.split-span.split-hunk { color: #7aacff; background: rgba(122,172,255,0.08); padding: 3px 10px; }
|
||||||
|
|
||||||
.split-num {
|
.split-num {
|
||||||
@@ -2578,11 +2863,11 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
border-right: 1px solid var(--color-border-subtle);
|
border-right: 1px solid var(--color-border-subtle);
|
||||||
background: rgba(0,0,0,0.14);
|
background: #0d101a;
|
||||||
}
|
}
|
||||||
.split-num.del { background: rgba(232,96,96,0.12); color: rgba(232,96,96,0.6); border-right-color: rgba(232,96,96,0.2); }
|
.split-num.del { background: rgba(232,96,96,0.12); color: rgba(232,96,96,0.6); border-right-color: rgba(232,96,96,0.2); }
|
||||||
.split-num.add { background: rgba(78,202,118,0.1); color: rgba(78,202,118,0.6); border-right-color: rgba(78,202,118,0.2); }
|
.split-num.add { background: rgba(78,202,118,0.1); color: rgba(78,202,118,0.6); border-right-color: rgba(78,202,118,0.2); }
|
||||||
.split-num.empty { background: rgba(0,0,0,0.08); }
|
.split-num.empty { background: #10131e; }
|
||||||
|
|
||||||
.split-cell {
|
.split-cell {
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
@@ -2593,7 +2878,7 @@
|
|||||||
}
|
}
|
||||||
.split-cell.del { background: rgba(232,96,96,0.1); color: #ef8080; }
|
.split-cell.del { background: rgba(232,96,96,0.1); color: #ef8080; }
|
||||||
.split-cell.add { background: rgba(78,202,118,0.09); color: #5dd88a; }
|
.split-cell.add { background: rgba(78,202,118,0.09); color: #5dd88a; }
|
||||||
.split-cell.empty { background: rgba(0,0,0,0.06); }
|
.split-cell.empty { background: #10131e; }
|
||||||
|
|
||||||
/* Search-hit highlight: amber, distinct from add (green) / del (red).
|
/* Search-hit highlight: amber, distinct from add (green) / del (red).
|
||||||
Higher specificity so it overrides the add/del backgrounds on a matched line. */
|
Higher specificity so it overrides the add/del backgrounds on a matched line. */
|
||||||
@@ -2624,7 +2909,7 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--color-ink-faint);
|
color: var(--color-ink-faint);
|
||||||
background: rgba(0,0,0,0.1);
|
background: #0d101a;
|
||||||
}
|
}
|
||||||
.split-col-label + .split-col-label { border-left: 1px solid var(--color-border-subtle); }
|
.split-col-label + .split-col-label { border-left: 1px solid var(--color-border-subtle); }
|
||||||
.split-col-hash {
|
.split-col-hash {
|
||||||
@@ -2640,7 +2925,7 @@
|
|||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); }
|
.dialog-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--color-border-subtle); background: #171a2b; }
|
||||||
.dialog-footer-info { color: var(--color-ink-dim); font-size: 13px; font-weight: 700; }
|
.dialog-footer-info { color: var(--color-ink-dim); font-size: 13px; font-weight: 700; }
|
||||||
|
|
||||||
.prepared-tag { display: inline-flex; align-items: center; gap: 4px; margin-right: auto; color: #4eca76; font-size: 12px; font-weight: 700; }
|
.prepared-tag { display: inline-flex; align-items: center; gap: 4px; margin-right: auto; color: #4eca76; font-size: 12px; font-weight: 700; }
|
||||||
@@ -2815,6 +3100,134 @@
|
|||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blame-body {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #111321;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blame-code-header strong {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blame-column-headers {
|
||||||
|
grid-template-columns: minmax(300px, 340px) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.blame-commit-col-label {
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
.blame-code-col-label {
|
||||||
|
padding-left: calc(3.2rem + 10px);
|
||||||
|
}
|
||||||
|
.blame-commit-col-label + .blame-code-col-label {
|
||||||
|
border-left: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blame-diff {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blame-scroll {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blame-code-table {
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blame-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(300px, 340px) minmax(max-content, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
min-width: max-content;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
.blame-group.uncommitted {
|
||||||
|
background: #171725;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blame-meta {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-right: 1px solid var(--color-border-subtle);
|
||||||
|
background: #0d101a;
|
||||||
|
box-shadow: 8px 0 18px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
.blame-hash {
|
||||||
|
align-self: flex-start;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border: 1px solid rgba(90,140,248,0.2);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #141b2d;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.blame-author {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.blame-summary {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #aeb6d8;
|
||||||
|
font-size: 11px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.blame-date {
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-size: 10.5px;
|
||||||
|
}
|
||||||
|
.blame-group:hover .blame-meta {
|
||||||
|
background: #111728;
|
||||||
|
}
|
||||||
|
.blame-group.uncommitted .blame-hash,
|
||||||
|
.blame-group.uncommitted .blame-author {
|
||||||
|
color: #e8b45a;
|
||||||
|
}
|
||||||
|
.blame-group.uncommitted .blame-hash {
|
||||||
|
border-color: rgba(232, 180, 90, 0.26);
|
||||||
|
background: #271f14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blame-lines {
|
||||||
|
min-width: max-content;
|
||||||
|
grid-template-columns: 4rem minmax(max-content, 1fr);
|
||||||
|
}
|
||||||
|
.blame-line-number {
|
||||||
|
min-height: 20px;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
.blame-line-code {
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
.blame-group:hover .blame-line-number {
|
||||||
|
background: #111728;
|
||||||
|
}
|
||||||
|
.blame-group:hover .blame-line-code {
|
||||||
|
background: #151a2b;
|
||||||
|
}
|
||||||
|
|
||||||
.global-search-body {
|
.global-search-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto auto minmax(0, 1fr);
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
@@ -3205,11 +3618,10 @@
|
|||||||
border: 1px solid rgba(100, 108, 255, 0.36);
|
border: 1px solid rgba(100, 108, 255, 0.36);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255,255,255,0.06), transparent 42%),
|
linear-gradient(180deg, #222743 0%, #111321 42%),
|
||||||
rgba(15, 16, 28, 0.96);
|
#111321;
|
||||||
box-shadow: 0 32px 90px rgba(0, 0, 0, 0.68), 0 0 0 1px rgba(255,255,255,0.04) inset;
|
box-shadow: 0 32px 90px rgba(0, 0, 0, 0.68), 0 0 0 1px rgba(255,255,255,0.04) inset;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
backdrop-filter: blur(18px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cred-hero {
|
.cred-hero {
|
||||||
@@ -3779,6 +4191,12 @@
|
|||||||
.repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; }
|
.repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; }
|
||||||
.repo-tab.management { min-width: 0; }
|
.repo-tab.management { min-width: 0; }
|
||||||
.repo-tabs-scroll { grid-column: 1 / -1; order: 2; border-top: 1px solid var(--color-border-subtle); }
|
.repo-tabs-scroll { grid-column: 1 / -1; order: 2; border-top: 1px solid var(--color-border-subtle); }
|
||||||
|
.repo-management-head,
|
||||||
|
.repo-management-tools { align-items: stretch; flex-direction: column; }
|
||||||
|
.repo-management-actions { justify-content: flex-start; }
|
||||||
|
.clone-dialog-path-field { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.clone-dialog-actions { flex-direction: column-reverse; }
|
||||||
|
.clone-dialog-actions button { width: 100%; }
|
||||||
.repo-row-main { grid-template-columns: minmax(0, 1fr); gap: 2px; align-content: center; padding-left: 12px; }
|
.repo-row-main { grid-template-columns: minmax(0, 1fr); gap: 2px; align-content: center; padding-left: 12px; }
|
||||||
.repo-row { min-height: 58px; }
|
.repo-row { min-height: 58px; }
|
||||||
.repo-row-icon { min-height: 58px; }
|
.repo-row-icon { min-height: 58px; }
|
||||||
|
|||||||
+12
-1
@@ -2,7 +2,7 @@
|
|||||||
import { onDestroy, onMount } from "svelte";
|
import { onDestroy, onMount } from "svelte";
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
|
import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Settings, Upload, X } from "@lucide/svelte";
|
||||||
|
|
||||||
export let branch: string = "";
|
export let branch: string = "";
|
||||||
export let ahead: number = 0;
|
export let ahead: number = 0;
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
export let onCompare: () => void = () => {};
|
export let onCompare: () => void = () => {};
|
||||||
export let onOpenInExplorer: () => void = () => {};
|
export let onOpenInExplorer: () => void = () => {};
|
||||||
export let onToggleAutoRefresh: () => void = () => {};
|
export let onToggleAutoRefresh: () => void = () => {};
|
||||||
|
export let onOpenSettings: () => void = () => {};
|
||||||
|
|
||||||
const win = getCurrentWindow();
|
const win = getCurrentWindow();
|
||||||
let isMaximized = false;
|
let isMaximized = false;
|
||||||
@@ -198,6 +199,16 @@
|
|||||||
/>
|
/>
|
||||||
<span class="tb-action-label">Auto</span>
|
<span class="tb-action-label">Auto</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="tb-action"
|
||||||
|
onclick={onOpenSettings}
|
||||||
|
title="Settings"
|
||||||
|
aria-label="Settings"
|
||||||
|
>
|
||||||
|
<Settings size={14} aria-hidden="true" />
|
||||||
|
<span class="tb-action-label">Settings</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="titlebar-divider" aria-hidden="true"></div>
|
<div class="titlebar-divider" aria-hidden="true"></div>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
export type AnalyticsEventProperties = Record<string, string | number>;
|
||||||
|
|
||||||
|
export function trackAnalyticsEvent(name: string, props?: AnalyticsEventProperties) {
|
||||||
|
void invoke("plugin:aptabase|track_event", {
|
||||||
|
name,
|
||||||
|
props: props && Object.keys(props).length > 0 ? props : undefined,
|
||||||
|
}).catch(() => {
|
||||||
|
// Analytics must never interrupt the Git workflow.
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onDestroy, onMount } from "svelte";
|
||||||
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte";
|
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte";
|
||||||
import { credDelete, credLoad, credSave } from "../git";
|
import { credDelete, credLoad, credSave } from "../git";
|
||||||
import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
|
import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
let loadingKeys = $state(true);
|
let loadingKeys = $state(true);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
|
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
provider = settings.provider;
|
provider = settings.provider;
|
||||||
@@ -47,6 +48,16 @@
|
|||||||
customModel = settings.customModel;
|
customModel = settings.customModel;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (errorHideTimer) clearTimeout(errorHideTimer);
|
||||||
|
const currentError = error;
|
||||||
|
if (currentError) {
|
||||||
|
errorHideTimer = setTimeout(() => {
|
||||||
|
if (error === currentError) error = "";
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -66,6 +77,10 @@
|
|||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (errorHideTimer) clearTimeout(errorHideTimer);
|
||||||
|
});
|
||||||
|
|
||||||
async function persistKey(target: CloudProvider, value: string) {
|
async function persistKey(target: CloudProvider, value: string) {
|
||||||
const key = CRED_KEYS[target];
|
const key = CRED_KEYS[target];
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -126,7 +141,6 @@
|
|||||||
<div
|
<div
|
||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
>
|
>
|
||||||
<div class="dialog ai-settings-dialog" role="dialog" aria-modal="true" aria-label="AI settings" tabindex="-1">
|
<div class="dialog ai-settings-dialog" role="dialog" aria-modal="true" aria-label="AI settings" tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { BarChart3, Check, ShieldCheck } from "@lucide/svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
enabled: boolean;
|
||||||
|
onContinue: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { enabled = true, onContinue = () => {} }: Props = $props();
|
||||||
|
let allowAnalytics = $state(true);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
allowAnalytics = enabled;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
||||||
|
<div class="dialog analytics-notice-dialog" role="dialog" aria-modal="true" aria-label="Analytics notice" tabindex="-1">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Privacy</span>
|
||||||
|
<h2 class="dialog-title">Anonymous usage analytics</h2>
|
||||||
|
</div>
|
||||||
|
<ShieldCheck size={20} aria-hidden="true" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="analytics-notice-body">
|
||||||
|
<div class="analytics-notice-icon">
|
||||||
|
<BarChart3 size={24} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div class="analytics-notice-copy">
|
||||||
|
<p>
|
||||||
|
GitLite can send anonymous usage events to Aptabase so crashes, rough edges, and commonly used workflows are easier to improve.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Events do not include repository paths, remote URLs, branch names, commit messages, diffs, file names, credentials, or source code.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="settings-toggle-row analytics-notice-toggle">
|
||||||
|
<input type="checkbox" bind:checked={allowAnalytics} />
|
||||||
|
<span>
|
||||||
|
<strong>Allow anonymous analytics</strong>
|
||||||
|
<small>You can change this later in Settings.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="dialog-footer analytics-notice-footer">
|
||||||
|
<span class="dialog-footer-info">Privacy-friendly, optional, and limited to product usage events.</span>
|
||||||
|
<button class="btn-primary" type="button" onclick={() => onContinue(allowAnalytics)}>
|
||||||
|
<Check size={16} aria-hidden="true" />
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Check, Settings, X } from "@lucide/svelte";
|
||||||
|
import type { AnalyticsSettings } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
analytics: AnalyticsSettings;
|
||||||
|
onSave: (settings: AnalyticsSettings) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { analytics, onSave = () => {}, onClose = () => {} }: Props = $props();
|
||||||
|
|
||||||
|
let analyticsEnabled = $state(true);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
analyticsEnabled = analytics.enabled;
|
||||||
|
});
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
onSave({
|
||||||
|
...analytics,
|
||||||
|
enabled: analyticsEnabled,
|
||||||
|
noticeSeen: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop" role="presentation">
|
||||||
|
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label="Settings" tabindex="-1">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">GitLite</span>
|
||||||
|
<h2 class="dialog-title">Settings</h2>
|
||||||
|
</div>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="app-settings-form" onsubmit={(event) => { event.preventDefault(); save(); }}>
|
||||||
|
<section class="settings-section">
|
||||||
|
<header>
|
||||||
|
<Settings size={16} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Analytics</span>
|
||||||
|
<h3>Anonymous usage analytics</h3>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<label class="settings-toggle-row">
|
||||||
|
<input type="checkbox" bind:checked={analyticsEnabled} />
|
||||||
|
<span>
|
||||||
|
<strong>Allow anonymous Aptabase events</strong>
|
||||||
|
<small>No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="new-branch-actions">
|
||||||
|
<button class="btn-secondary" type="button" onclick={onClose}>Cancel</button>
|
||||||
|
<button class="btn-primary" type="submit">
|
||||||
|
<Check size={16} aria-hidden="true" />
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { FileCode, LoaderCircle, X } from "@lucide/svelte";
|
||||||
|
import type { GitBlameLine } from "../types";
|
||||||
|
|
||||||
|
interface BlameGroup {
|
||||||
|
id: string;
|
||||||
|
hash: string;
|
||||||
|
shortHash: string;
|
||||||
|
authorName: string;
|
||||||
|
authorEmail: string;
|
||||||
|
summary: string;
|
||||||
|
authorTime: number;
|
||||||
|
isUncommitted: boolean;
|
||||||
|
lines: GitBlameLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
filePath: string;
|
||||||
|
lines: GitBlameLine[];
|
||||||
|
isBusy: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
filePath = "",
|
||||||
|
lines = [],
|
||||||
|
isBusy = false,
|
||||||
|
isLoading = false,
|
||||||
|
error = "",
|
||||||
|
onClose = () => {},
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
function groupBlameLines(source: GitBlameLine[]): BlameGroup[] {
|
||||||
|
const groups: BlameGroup[] = [];
|
||||||
|
for (const line of source) {
|
||||||
|
const last = groups[groups.length - 1];
|
||||||
|
if (last && last.hash === line.commit_hash) {
|
||||||
|
last.lines.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
groups.push({
|
||||||
|
id: `${line.commit_hash}-${line.line_number}`,
|
||||||
|
hash: line.commit_hash,
|
||||||
|
shortHash: line.short_hash,
|
||||||
|
authorName: line.author_name,
|
||||||
|
authorEmail: line.author_email,
|
||||||
|
summary: line.summary,
|
||||||
|
authorTime: line.author_time,
|
||||||
|
isUncommitted: line.is_uncommitted,
|
||||||
|
lines: [line],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBlameDate(seconds: number): string {
|
||||||
|
if (!seconds) return "";
|
||||||
|
const date = new Date(seconds * 1000);
|
||||||
|
if (Number.isNaN(date.getTime())) return "";
|
||||||
|
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupTooltip(group: BlameGroup): string {
|
||||||
|
if (group.isUncommitted) return "Not committed yet";
|
||||||
|
return `${group.authorName} <${group.authorEmail}>\n${group.summary}\n${group.hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let groups = $derived(groupBlameLines(lines));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop" role="presentation">
|
||||||
|
<div class="dialog blame-dialog" role="dialog" aria-modal="true" aria-label="File blame">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Blame</span>
|
||||||
|
<p class="dialog-title" title={filePath}>{filePath}</p>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-header-actions">
|
||||||
|
<span class="pill pill-count">{lines.length}</span>
|
||||||
|
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="dialog-diff blame-body">
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="blank-state">
|
||||||
|
<LoaderCircle class="spin" size={18} aria-hidden="true" />
|
||||||
|
Loading blame...
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="blank-state">{error}</div>
|
||||||
|
{:else if groups.length === 0}
|
||||||
|
<div class="blank-state">No blame information available for this file.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="diff-header blame-code-header">
|
||||||
|
<FileCode size={13} aria-hidden="true" />
|
||||||
|
<span title={filePath}>{filePath}</span>
|
||||||
|
<strong>{lines.length} lines</strong>
|
||||||
|
</div>
|
||||||
|
<div class="split-col-headers blame-column-headers">
|
||||||
|
<div class="split-col-label blame-commit-col-label">Commit</div>
|
||||||
|
<div class="split-col-label blame-code-col-label">Code</div>
|
||||||
|
</div>
|
||||||
|
<div class="split-diff blame-diff" role="table" aria-label="File blame">
|
||||||
|
<div class="split-pane blame-scroll">
|
||||||
|
<div class="blame-code-table">
|
||||||
|
{#each groups as group (group.id)}
|
||||||
|
<div class="blame-group" class:uncommitted={group.isUncommitted} title={groupTooltip(group)}>
|
||||||
|
<div class="blame-meta">
|
||||||
|
<span class="blame-hash">{group.isUncommitted ? "Uncommitted" : group.shortHash}</span>
|
||||||
|
<span class="blame-author">{group.isUncommitted ? "Not committed yet" : group.authorName}</span>
|
||||||
|
<span class="blame-summary">{group.summary || "No commit message"}</span>
|
||||||
|
{#if !group.isUncommitted}
|
||||||
|
<span class="blame-date">{formatBlameDate(group.authorTime)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="split-pane-grid blame-lines">
|
||||||
|
{#each group.lines as line (line.line_number)}
|
||||||
|
<span class="split-num blame-line-number">{line.line_number}</span>
|
||||||
|
<code class="split-cell blame-line-code">{line.content}</code>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -20,13 +20,9 @@
|
|||||||
|
|
||||||
let title = $derived(force ? "Force delete branch?" : "Delete branch?");
|
let title = $derived(force ? "Force delete branch?" : "Delete branch?");
|
||||||
|
|
||||||
function closeFromBackdrop(event: MouseEvent) {
|
|
||||||
if (isBusy || event.target !== event.currentTarget) return;
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="dialog-backdrop" role="presentation" onclick={closeFromBackdrop}>
|
<div class="dialog-backdrop" role="presentation">
|
||||||
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-label={title}>
|
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-label={title}>
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Trash2, X } from "@lucide/svelte";
|
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
|
||||||
import type { GitBranch as GitBranchInfo } from "../types";
|
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
|
||||||
|
|
||||||
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
||||||
|
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
branches: GitBranchInfo[];
|
branches: GitBranchInfo[];
|
||||||
localBranches: GitBranchInfo[];
|
localBranches: GitBranchInfo[];
|
||||||
remoteBranches: GitBranchInfo[];
|
remoteBranches: GitBranchInfo[];
|
||||||
|
tags: GitTag[];
|
||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
onCheckout: (branch: GitBranchInfo) => void;
|
onCheckout: (branch: GitBranchInfo) => void;
|
||||||
@@ -53,12 +54,16 @@
|
|||||||
onCreateBranch: (branchName: string) => void | Promise<void>;
|
onCreateBranch: (branchName: string) => void | Promise<void>;
|
||||||
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||||
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||||
|
onCreateTag: (name: string, message: string) => void | Promise<void>;
|
||||||
|
onDeleteTag: (tag: GitTag) => void | Promise<void>;
|
||||||
|
onPushTag: (tag: GitTag) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
branches = [],
|
branches = [],
|
||||||
localBranches = [],
|
localBranches = [],
|
||||||
remoteBranches = [],
|
remoteBranches = [],
|
||||||
|
tags = [],
|
||||||
hasRepository = false,
|
hasRepository = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
onCheckout = () => {},
|
onCheckout = () => {},
|
||||||
@@ -67,17 +72,28 @@
|
|||||||
onCreateBranch = () => {},
|
onCreateBranch = () => {},
|
||||||
onRenameBranch = () => {},
|
onRenameBranch = () => {},
|
||||||
onDeleteBranch = () => {},
|
onDeleteBranch = () => {},
|
||||||
|
onCreateTag = () => {},
|
||||||
|
onDeleteTag = () => {},
|
||||||
|
onPushTag = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let localOpen = $state(true);
|
let localOpen = $state(true);
|
||||||
let remoteOpen = $state(false);
|
let remoteOpen = $state(false);
|
||||||
|
let tagsOpen = $state(false);
|
||||||
let createOpen = $state(false);
|
let createOpen = $state(false);
|
||||||
let newBranchName = $state("");
|
let newBranchName = $state("");
|
||||||
let createInput = $state<HTMLInputElement | null>(null);
|
let createInput = $state<HTMLInputElement | null>(null);
|
||||||
|
let tagCreateOpen = $state(false);
|
||||||
|
let newTagName = $state("");
|
||||||
|
let newTagMessage = $state("");
|
||||||
|
let tagCreateInput = $state<HTMLInputElement | null>(null);
|
||||||
let panelElement = $state<HTMLElement | null>(null);
|
let panelElement = $state<HTMLElement | null>(null);
|
||||||
let contextBranch = $state<GitBranchInfo | null>(null);
|
let contextBranch = $state<GitBranchInfo | null>(null);
|
||||||
let contextMenuX = $state(0);
|
let contextMenuX = $state(0);
|
||||||
let contextMenuY = $state(0);
|
let contextMenuY = $state(0);
|
||||||
|
let contextTag = $state<GitTag | null>(null);
|
||||||
|
let tagContextMenuX = $state(0);
|
||||||
|
let tagContextMenuY = $state(0);
|
||||||
let collapsedBranchFolders = $state<Set<string>>(new Set());
|
let collapsedBranchFolders = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
let localBranchRows = $derived(buildBranchRows("local", localBranches, "local"));
|
let localBranchRows = $derived(buildBranchRows("local", localBranches, "local"));
|
||||||
@@ -270,12 +286,74 @@
|
|||||||
await onRebase(branch);
|
await onRebase(branch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openTagCreateForm() {
|
||||||
|
if (!hasRepository || isBusy) return;
|
||||||
|
tagCreateOpen = true;
|
||||||
|
queueMicrotask(() => tagCreateInput?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTagCreateForm() {
|
||||||
|
tagCreateOpen = false;
|
||||||
|
newTagName = "";
|
||||||
|
newTagMessage = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreateTag(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const name = newTagName.trim();
|
||||||
|
if (!name || !hasRepository || isBusy) return;
|
||||||
|
await onCreateTag(name, newTagMessage.trim());
|
||||||
|
newTagName = "";
|
||||||
|
newTagMessage = "";
|
||||||
|
tagCreateOpen = false;
|
||||||
|
tagsOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTagContextMenu(event: MouseEvent, tag: GitTag) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (isBusy) return;
|
||||||
|
|
||||||
|
const rect = panelElement?.getBoundingClientRect();
|
||||||
|
const rawX = rect ? event.clientX - rect.left : event.offsetX;
|
||||||
|
const rawY = rect ? event.clientY - rect.top : event.offsetY;
|
||||||
|
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
|
||||||
|
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 130);
|
||||||
|
|
||||||
|
contextTag = tag;
|
||||||
|
tagContextMenuX = Math.max(8, Math.min(rawX, maxX));
|
||||||
|
tagContextMenuY = Math.max(8, Math.min(rawY, maxY));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTagContextMenu() {
|
||||||
|
contextTag = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushContextTag() {
|
||||||
|
const tag = contextTag;
|
||||||
|
if (!tag || isBusy) return;
|
||||||
|
closeTagContextMenu();
|
||||||
|
await onPushTag(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteContextTag() {
|
||||||
|
const tag = contextTag;
|
||||||
|
if (!tag || isBusy) return;
|
||||||
|
closeTagContextMenu();
|
||||||
|
await onDeleteTag(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllContextMenus() {
|
||||||
|
closeBranchContextMenu();
|
||||||
|
closeTagContextMenu();
|
||||||
|
}
|
||||||
|
|
||||||
function handleWindowKeydown(event: KeyboardEvent) {
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === "Escape") closeBranchContextMenu();
|
if (event.key === "Escape") closeAllContextMenus();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:click={closeBranchContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeBranchContextMenu} />
|
<svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} />
|
||||||
|
|
||||||
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
|
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
@@ -300,7 +378,7 @@
|
|||||||
|
|
||||||
{#if !hasRepository}
|
{#if !hasRepository}
|
||||||
<p class="blank-state">Open a repository to list branches.</p>
|
<p class="blank-state">Open a repository to list branches.</p>
|
||||||
{:else if branches.length === 0}
|
{:else if branches.length === 0 && tags.length === 0}
|
||||||
<p class="blank-state">No branches returned.</p>
|
<p class="blank-state">No branches returned.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
|
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
|
||||||
@@ -458,6 +536,86 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="branch-group">
|
||||||
|
<div class="tag-group-head">
|
||||||
|
<button
|
||||||
|
class="branch-group-toggle"
|
||||||
|
type="button"
|
||||||
|
onclick={() => { tagsOpen = !tagsOpen; }}
|
||||||
|
aria-expanded={tagsOpen}
|
||||||
|
>
|
||||||
|
{#if tagsOpen}
|
||||||
|
<ChevronDown size={14} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<ChevronRight size={14} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
<span>Tags</span>
|
||||||
|
<span class="branch-group-count">{tags.length}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="branch-create-toggle"
|
||||||
|
type="button"
|
||||||
|
onclick={openTagCreateForm}
|
||||||
|
disabled={!hasRepository || isBusy}
|
||||||
|
title="Create new tag"
|
||||||
|
aria-label="Create new tag"
|
||||||
|
>
|
||||||
|
<Plus size={13} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tagCreateOpen}
|
||||||
|
<form class="branch-create-form tag-create-form" onsubmit={submitCreateTag}>
|
||||||
|
<TagIcon size={15} aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
bind:this={tagCreateInput}
|
||||||
|
bind:value={newTagName}
|
||||||
|
disabled={isBusy}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="v1.0.0"
|
||||||
|
aria-label="New tag name"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
bind:value={newTagMessage}
|
||||||
|
disabled={isBusy}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="Message (optional)"
|
||||||
|
aria-label="Tag message"
|
||||||
|
/>
|
||||||
|
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newTagName.trim().length === 0} title="Create tag">
|
||||||
|
<Check size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button class="branch-create-action" type="button" onclick={closeTagCreateForm} disabled={isBusy} title="Cancel">
|
||||||
|
<X size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if tagsOpen}
|
||||||
|
{#if tags.length === 0}
|
||||||
|
<div class="branch-empty">No tags.</div>
|
||||||
|
{:else}
|
||||||
|
{#each tags as tag (tag.name)}
|
||||||
|
<article
|
||||||
|
class="branch-row"
|
||||||
|
oncontextmenu={(event) => openTagContextMenu(event, tag)}
|
||||||
|
title={tag.message ?? tag.name}
|
||||||
|
>
|
||||||
|
<div class="branch-info">
|
||||||
|
<TagIcon size={16} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>{tag.name}</strong>
|
||||||
|
<span>{tag.short_hash}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -499,4 +657,24 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if contextTag}
|
||||||
|
<div
|
||||||
|
class="branch-context-menu"
|
||||||
|
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
|
||||||
|
role="menu"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label={`Actions for ${contextTag.name}`}
|
||||||
|
>
|
||||||
|
<button type="button" role="menuitem" onclick={pushContextTag} disabled={isBusy}>
|
||||||
|
<Upload size={14} aria-hidden="true" />
|
||||||
|
Push to remote
|
||||||
|
</button>
|
||||||
|
<div class="menu-separator" role="separator"></div>
|
||||||
|
<button class="danger" type="button" role="menuitem" onclick={deleteContextTag} disabled={isBusy} title="Delete local tag">
|
||||||
|
<Trash2 size={14} aria-hidden="true" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from "svelte";
|
||||||
|
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { Download, FolderOpen, LoaderCircle, X } from "@lucide/svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isBusy: boolean;
|
||||||
|
error: string;
|
||||||
|
onClone: (remoteUrl: string, parentPath: string, directoryName: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
isBusy = false,
|
||||||
|
error = "",
|
||||||
|
onClone = () => {},
|
||||||
|
onClose = () => {},
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let remoteUrl = $state("");
|
||||||
|
let parentPath = $state("");
|
||||||
|
let directoryName = $state("");
|
||||||
|
let directoryNameEdited = $state(false);
|
||||||
|
let directoryAutoName = $state("");
|
||||||
|
let browseError = $state("");
|
||||||
|
let visibleError = $state("");
|
||||||
|
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
let directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl));
|
||||||
|
let canSubmit = $derived(
|
||||||
|
!isBusy &&
|
||||||
|
remoteUrl.trim().length > 0 &&
|
||||||
|
parentPath.trim().length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const nextError = error || browseError;
|
||||||
|
if (errorHideTimer) clearTimeout(errorHideTimer);
|
||||||
|
visibleError = nextError;
|
||||||
|
if (nextError) {
|
||||||
|
errorHideTimer = setTimeout(() => {
|
||||||
|
visibleError = "";
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (errorHideTimer) clearTimeout(errorHideTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
function directoryNameFromRemoteUrl(url: string): string {
|
||||||
|
const trimmed = url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "") ?? "";
|
||||||
|
const lastSegment = trimmed.split(/[\\/:]/).filter(Boolean).pop() ?? "";
|
||||||
|
return lastSegment.replace(/\.git$/i, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorToMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
if (typeof error === "string") return error;
|
||||||
|
try { return JSON.stringify(error) ?? "Unknown error"; } catch { return "Unknown error"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseParentFolder() {
|
||||||
|
if (isBusy) return;
|
||||||
|
browseError = "";
|
||||||
|
try {
|
||||||
|
const selected = await openDialog({
|
||||||
|
title: "Select clone destination",
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
defaultPath: parentPath.trim() || undefined,
|
||||||
|
});
|
||||||
|
if (typeof selected !== "string") return;
|
||||||
|
parentPath = selected;
|
||||||
|
} catch (error) {
|
||||||
|
browseError = errorToMessage(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoteInput(event: Event) {
|
||||||
|
const nextRemoteUrl = (event.currentTarget as HTMLInputElement).value;
|
||||||
|
if (directoryNameEdited) return;
|
||||||
|
directoryAutoName = directoryNameFromRemoteUrl(nextRemoteUrl);
|
||||||
|
directoryName = directoryAutoName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDirectoryInput(event: Event) {
|
||||||
|
const nextDirectoryName = (event.currentTarget as HTMLInputElement).value;
|
||||||
|
directoryNameEdited = nextDirectoryName.trim().length > 0 && nextDirectoryName !== directoryAutoName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
onClone(remoteUrl.trim(), parentPath.trim(), directoryName.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop" role="presentation">
|
||||||
|
<div class="dialog clone-repository-dialog" role="dialog" aria-modal="true" aria-label="Clone repository" tabindex="-1">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Repository Management</span>
|
||||||
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Clone repository</h2>
|
||||||
|
</div>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close">
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="clone-dialog-form" onsubmit={submit}>
|
||||||
|
<label class="clone-dialog-field">
|
||||||
|
<span>Remote URL</span>
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input
|
||||||
|
bind:value={remoteUrl}
|
||||||
|
oninput={handleRemoteInput}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="https://github.com/org/project.git"
|
||||||
|
disabled={isBusy}
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="clone-dialog-field">
|
||||||
|
<span>Destination</span>
|
||||||
|
<div class="clone-dialog-path-field">
|
||||||
|
<input
|
||||||
|
bind:value={parentPath}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="Choose parent folder"
|
||||||
|
disabled={isBusy}
|
||||||
|
/>
|
||||||
|
<button class="btn-secondary" type="button" onclick={chooseParentFolder} disabled={isBusy}>
|
||||||
|
<FolderOpen size={14} aria-hidden="true" />
|
||||||
|
Browse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="clone-dialog-field">
|
||||||
|
<span>Folder name</span>
|
||||||
|
<input
|
||||||
|
bind:value={directoryName}
|
||||||
|
oninput={handleDirectoryInput}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder={directorySuggestion || "Optional"}
|
||||||
|
disabled={isBusy}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if visibleError}
|
||||||
|
<div class="clone-dialog-error" role="alert">{visibleError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="clone-dialog-actions">
|
||||||
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button class="btn-primary" type="submit" disabled={!canSubmit}>
|
||||||
|
{#if isBusy}
|
||||||
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<Download size={16} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
Clone
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, LoaderCircle, Settings, Sparkles } from "@lucide/svelte";
|
import { Check, LoaderCircle, RotateCcw, Settings, Sparkles } from "@lucide/svelte";
|
||||||
import type { CommitAiPhase, CommitAiProvider } from "../types";
|
import type { CommitAiPhase, CommitAiProvider } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -13,10 +13,14 @@
|
|||||||
commitAiProvider: CommitAiProvider;
|
commitAiProvider: CommitAiProvider;
|
||||||
commitAiPhase: CommitAiPhase;
|
commitAiPhase: CommitAiPhase;
|
||||||
commitAiGenerating: boolean;
|
commitAiGenerating: boolean;
|
||||||
|
canAmend: boolean;
|
||||||
|
amendMode: boolean;
|
||||||
onCommit: () => void;
|
onCommit: () => void;
|
||||||
onCommitMessageChange: (msg: string) => void;
|
onCommitMessageChange: (msg: string) => void;
|
||||||
onGenerateCommitMessage: () => void;
|
onGenerateCommitMessage: () => void;
|
||||||
onOpenAiSettings: () => void;
|
onOpenAiSettings: () => void;
|
||||||
|
onToggleAmend: (checked: boolean) => void;
|
||||||
|
onUndoLastCommit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -30,10 +34,14 @@
|
|||||||
commitAiProvider = "local",
|
commitAiProvider = "local",
|
||||||
commitAiPhase = "idle",
|
commitAiPhase = "idle",
|
||||||
commitAiGenerating = false,
|
commitAiGenerating = false,
|
||||||
|
canAmend = false,
|
||||||
|
amendMode = false,
|
||||||
onCommit = () => {},
|
onCommit = () => {},
|
||||||
onCommitMessageChange = () => {},
|
onCommitMessageChange = () => {},
|
||||||
onGenerateCommitMessage = () => {},
|
onGenerateCommitMessage = () => {},
|
||||||
onOpenAiSettings = () => {},
|
onOpenAiSettings = () => {},
|
||||||
|
onToggleAmend = () => {},
|
||||||
|
onUndoLastCommit = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
function handleSubmit(event: SubmitEvent) {
|
function handleSubmit(event: SubmitEvent) {
|
||||||
@@ -77,14 +85,37 @@
|
|||||||
{#if commitBlockReason}
|
{#if commitBlockReason}
|
||||||
<p class="commit-block-reason">{commitBlockReason}</p>
|
<p class="commit-block-reason">{commitBlockReason}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if canAmend}
|
||||||
|
<div class="commit-amend-row">
|
||||||
|
<label class="commit-amend-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={amendMode}
|
||||||
|
disabled={isBusy}
|
||||||
|
onchange={(e) => onToggleAmend((e.target as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
Amend last commit
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
class="btn-secondary commit-undo-button"
|
||||||
|
type="button"
|
||||||
|
onclick={onUndoLastCommit}
|
||||||
|
disabled={isBusy}
|
||||||
|
title="Undo the last commit — its changes come back as uncommitted changes (not pushed yet, so safe)"
|
||||||
|
>
|
||||||
|
<RotateCcw size={13} aria-hidden="true" />
|
||||||
|
Undo last commit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="commit-actions-row flex-shrink-0">
|
<div class="commit-actions-row flex-shrink-0">
|
||||||
<button class="btn-primary flex-1" type="submit" disabled={!canCommit} title={commitBlockReason || "Commit staged changes"}>
|
<button class="btn-primary flex-1" type="submit" disabled={!canCommit} title={commitBlockReason || (amendMode ? "Amend the last commit" : "Commit staged changes")}>
|
||||||
{#if operation === "Committing"}
|
{#if operation === "Committing" || operation === "Amending"}
|
||||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||||
{:else}
|
{:else}
|
||||||
<Check size={16} aria-hidden="true" />
|
<Check size={16} aria-hidden="true" />
|
||||||
{/if}
|
{/if}
|
||||||
Commit
|
{amendMode ? "Amend" : "Commit"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn-secondary commit-ai-button"
|
class="btn-secondary commit-ai-button"
|
||||||
|
|||||||
@@ -182,7 +182,6 @@
|
|||||||
<div
|
<div
|
||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
>
|
>
|
||||||
<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">
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,6 @@
|
|||||||
<div
|
<div
|
||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
>
|
>
|
||||||
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1">
|
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
action: "push" | "pull" | "fetch";
|
action: "push" | "pull" | "fetch" | "clone";
|
||||||
error: string;
|
error: string;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void;
|
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void;
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
let username = $state("");
|
let username = $state("");
|
||||||
let password = $state("");
|
let password = $state("");
|
||||||
let showPassword = $state(false);
|
let showPassword = $state(false);
|
||||||
let saveSession = $state(false);
|
let saveSession = $state(true);
|
||||||
let expiresAt = $state("");
|
let expiresAt = $state("");
|
||||||
|
|
||||||
let canSubmit = $derived(
|
let canSubmit = $derived(
|
||||||
@@ -43,13 +43,21 @@
|
|||||||
password.trim().length > 0 &&
|
password.trim().length > 0 &&
|
||||||
(mode === "token" || username.trim().length > 0),
|
(mode === "token" || username.trim().length > 0),
|
||||||
);
|
);
|
||||||
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : "Pull");
|
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : "Pull");
|
||||||
let actionTitle = $derived(
|
let actionTitle = $derived(
|
||||||
action === "push" ? "Authenticate push" : action === "fetch" ? "Authenticate fetch" : "Authenticate pull",
|
action === "push"
|
||||||
|
? "Authenticate push"
|
||||||
|
: action === "fetch"
|
||||||
|
? "Authenticate fetch"
|
||||||
|
: action === "clone"
|
||||||
|
? "Authenticate clone"
|
||||||
|
: "Authenticate pull",
|
||||||
);
|
);
|
||||||
let actionHint = $derived(action === "push"
|
let actionHint = $derived(action === "push"
|
||||||
? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
|
? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
|
||||||
: "The remote needs access to the repository. Use your Git credentials or a personal access token.");
|
: action === "clone"
|
||||||
|
? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token."
|
||||||
|
: "The remote needs access to the repository. Use your Git credentials or a personal access token.");
|
||||||
|
|
||||||
function handleSubmit(e: SubmitEvent) {
|
function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -66,7 +74,6 @@
|
|||||||
<div
|
<div
|
||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
|
||||||
>
|
>
|
||||||
<div class="cred-card" role="dialog" aria-modal="true" aria-label="Git credentials" tabindex="-1">
|
<div class="cred-card" role="dialog" aria-modal="true" aria-label="Git credentials" tabindex="-1">
|
||||||
<div class="cred-hero">
|
<div class="cred-hero">
|
||||||
|
|||||||
@@ -25,13 +25,9 @@
|
|||||||
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
|
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
|
||||||
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
|
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
|
||||||
|
|
||||||
function closeFromBackdrop(event: MouseEvent) {
|
|
||||||
if (isBusy || event.target !== event.currentTarget) return;
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="dialog-backdrop" role="presentation" onclick={closeFromBackdrop}>
|
<div class="dialog-backdrop" role="presentation">
|
||||||
<div class="dialog discard-confirm-dialog" role="dialog" aria-modal="true" aria-label={title}>
|
<div class="dialog discard-confirm-dialog" role="dialog" aria-modal="true" aria-label={title}>
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
History,
|
||||||
Terminal,
|
Terminal,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import { languageIconForPath } from "../languageIcons";
|
import { languageIconForPath } from "../languageIcons";
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
onCollapseAllFolders: () => void;
|
onCollapseAllFolders: () => void;
|
||||||
onSelectNode: (node: ExplorerNode) => void;
|
onSelectNode: (node: ExplorerNode) => void;
|
||||||
onOpenFile: (node: ExplorerNode) => void;
|
onOpenFile: (node: ExplorerNode) => void;
|
||||||
|
onBlame: (node: ExplorerNode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -50,6 +52,7 @@
|
|||||||
onCollapseAllFolders = () => {},
|
onCollapseAllFolders = () => {},
|
||||||
onSelectNode = () => {},
|
onSelectNode = () => {},
|
||||||
onOpenFile = () => {},
|
onOpenFile = () => {},
|
||||||
|
onBlame = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let contextNode = $state<ExplorerNode | null>(null);
|
let contextNode = $state<ExplorerNode | null>(null);
|
||||||
@@ -182,6 +185,13 @@
|
|||||||
onOpenFile(node);
|
onOpenFile(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openContextBlame() {
|
||||||
|
const node = contextNode;
|
||||||
|
if (!node || node.kind !== "file") return;
|
||||||
|
closeFileContextMenu();
|
||||||
|
onBlame(node);
|
||||||
|
}
|
||||||
|
|
||||||
function handleWindowKeydown(event: KeyboardEvent) {
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === "Escape") closeFileContextMenu();
|
if (event.key === "Escape") closeFileContextMenu();
|
||||||
}
|
}
|
||||||
@@ -335,5 +345,15 @@
|
|||||||
<ExternalLink size={14} aria-hidden="true" />
|
<ExternalLink size={14} aria-hidden="true" />
|
||||||
Open in Explorer
|
Open in Explorer
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onclick={openContextBlame}
|
||||||
|
disabled={!contextNode.tracked || contextNode.status === "deleted"}
|
||||||
|
title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}
|
||||||
|
>
|
||||||
|
<History size={14} aria-hidden="true" />
|
||||||
|
Blame
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -129,7 +129,6 @@
|
|||||||
<div
|
<div
|
||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
>
|
>
|
||||||
<div class="dialog global-search-dialog" role="dialog" aria-modal="true" aria-label="Global search" tabindex="-1">
|
<div class="dialog global-search-dialog" role="dialog" aria-modal="true" aria-label="Global search" tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ChevronDown, ChevronRight, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
|
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
|
||||||
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
||||||
|
|
||||||
interface GraphSegment {
|
interface GraphSegment {
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
commits: GitCommit[];
|
commits: GitCommit[];
|
||||||
localBranchNames: string[];
|
localBranchNames: string[];
|
||||||
activeBranch: string;
|
activeBranch: string;
|
||||||
|
activeUpstream: string;
|
||||||
repositoryKey: string;
|
repositoryKey: string;
|
||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
@@ -40,12 +41,14 @@
|
|||||||
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
||||||
onToggleCommitFiles: (hash: string) => void;
|
onToggleCommitFiles: (hash: string) => void;
|
||||||
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
||||||
|
onCherryPickCommit: (commit: GitCommit) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
commits = [],
|
commits = [],
|
||||||
localBranchNames = [],
|
localBranchNames = [],
|
||||||
activeBranch = "",
|
activeBranch = "",
|
||||||
|
activeUpstream = "",
|
||||||
repositoryKey = "",
|
repositoryKey = "",
|
||||||
hasRepository = false,
|
hasRepository = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
@@ -54,12 +57,17 @@
|
|||||||
onPreviewCommitFile = () => {},
|
onPreviewCommitFile = () => {},
|
||||||
onToggleCommitFiles = () => {},
|
onToggleCommitFiles = () => {},
|
||||||
onCreateBranchFromCommit = () => {},
|
onCreateBranchFromCommit = () => {},
|
||||||
|
onCherryPickCommit = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let hiddenGraphBranches = $state<Set<string>>(new Set());
|
let hiddenGraphBranches = $state<Set<string>>(new Set());
|
||||||
let branchDialogOpen = $state(false);
|
let branchDialogOpen = $state(false);
|
||||||
let userAdjustedBranchFilter = $state(false);
|
let userAdjustedBranchFilter = $state(false);
|
||||||
let lastDefaultFilterKey = $state("");
|
let lastDefaultFilterKey = $state("");
|
||||||
|
let panelElement = $state<HTMLElement | null>(null);
|
||||||
|
let contextCommit = $state<GitCommit | null>(null);
|
||||||
|
let contextMenuX = $state(0);
|
||||||
|
let contextMenuY = $state(0);
|
||||||
|
|
||||||
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];
|
||||||
@@ -195,7 +203,13 @@
|
|||||||
return [...new Set(values.filter(Boolean))];
|
return [...new Set(values.filter(Boolean))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, activeUpstream].filter(Boolean)));
|
||||||
|
let graphBranchNameSet = $derived(new Set(graphBranchNames));
|
||||||
|
|
||||||
function branchIsVisible(branch: string): boolean {
|
function branchIsVisible(branch: string): boolean {
|
||||||
|
if (branch === activeUpstream && activeUpstream) {
|
||||||
|
return !activeBranch || !hiddenGraphBranches.has(activeBranch);
|
||||||
|
}
|
||||||
return !hiddenGraphBranches.has(branch);
|
return !hiddenGraphBranches.has(branch);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,12 +217,39 @@
|
|||||||
return labels.filter(branchIsVisible);
|
return labels.filter(branchIsVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function commitHoverBranchLabels(commit: GitCommit, row: GraphRow | undefined): string[] {
|
||||||
|
const directBranches = graphBranchRefs(commit);
|
||||||
|
if (directBranches.length > 0) return directBranches;
|
||||||
|
|
||||||
|
const containingBranches = row?.branchLabels ?? [];
|
||||||
|
if (containingBranches.length <= 3) return containingBranches;
|
||||||
|
return [...containingBranches.slice(0, 3), `+${containingBranches.length - 3} more`];
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
|
||||||
|
const directBranches = graphBranchRefs(commit);
|
||||||
|
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
|
||||||
|
|
||||||
|
const containingBranches = row?.branchLabels ?? [];
|
||||||
|
if (containingBranches.length === 0) return commit.short_hash;
|
||||||
|
return `Branches containing this commit: ${containingBranches.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
function segmentIsVisible(segment: GraphSegment): boolean {
|
function segmentIsVisible(segment: GraphSegment): boolean {
|
||||||
return segment.branches.length === 0 || segment.branches.some(branchIsVisible);
|
return segment.branches.length === 0 || segment.branches.some(branchIsVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncClassForBranches(labels: string[]): "" | "ahead" | "behind" {
|
||||||
|
if (!activeBranch || !activeUpstream) return "";
|
||||||
|
const hasLocal = labels.includes(activeBranch);
|
||||||
|
const hasRemote = labels.includes(activeUpstream);
|
||||||
|
if (hasLocal && !hasRemote) return "ahead";
|
||||||
|
if (hasRemote && !hasLocal) return "behind";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function branchesAreVisible(branches: string[]): boolean {
|
function branchesAreVisible(branches: string[]): boolean {
|
||||||
if (localBranchNames.length === 0) return true;
|
if (graphBranchNames.length === 0) return true;
|
||||||
return branches.some(branchIsVisible);
|
return branches.some(branchIsVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +281,7 @@
|
|||||||
const membership = new Map<string, Set<string>>();
|
const membership = new Map<string, Set<string>>();
|
||||||
|
|
||||||
for (const commit of items) {
|
for (const commit of items) {
|
||||||
for (const branch of localBranchRefs(commit)) {
|
for (const branch of graphBranchRefs(commit)) {
|
||||||
const stack = [commit.hash];
|
const stack = [commit.hash];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
@@ -265,7 +306,7 @@
|
|||||||
return new Map(
|
return new Map(
|
||||||
items.map((commit) => [
|
items.map((commit) => [
|
||||||
commit.hash,
|
commit.hash,
|
||||||
localBranchNames.filter((branch) => membership.get(commit.hash)?.has(branch)),
|
graphBranchNames.filter((branch) => membership.get(commit.hash)?.has(branch)),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -316,10 +357,59 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBranchDialogBackdropClick(event: MouseEvent) {
|
function openCommitActionMenu(event: MouseEvent, commit: GitCommit) {
|
||||||
if (event.target === event.currentTarget) {
|
event.preventDefault();
|
||||||
closeBranchDialog();
|
event.stopPropagation();
|
||||||
|
if (isBusy) return;
|
||||||
|
|
||||||
|
if (contextCommit?.hash === commit.hash) {
|
||||||
|
closeCommitContextMenu();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const panelRect = panelElement?.getBoundingClientRect();
|
||||||
|
const buttonRect = event.currentTarget instanceof HTMLElement
|
||||||
|
? event.currentTarget.getBoundingClientRect()
|
||||||
|
: null;
|
||||||
|
const rawX = panelRect && buttonRect ? buttonRect.right - panelRect.left - 184 : event.offsetX;
|
||||||
|
const rawY = panelRect && buttonRect ? buttonRect.bottom - panelRect.top + 4 : event.offsetY;
|
||||||
|
const maxX = Math.max(8, (panelRect?.width ?? window.innerWidth) - 192);
|
||||||
|
const maxY = Math.max(8, (panelRect?.height ?? window.innerHeight) - 96);
|
||||||
|
|
||||||
|
contextCommit = commit;
|
||||||
|
contextMenuX = Math.max(8, Math.min(rawX, maxX));
|
||||||
|
contextMenuY = Math.max(8, Math.min(rawY, maxY));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCommitContextMenu() {
|
||||||
|
contextCommit = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBranchFromContextCommit() {
|
||||||
|
const commit = contextCommit;
|
||||||
|
if (!commit || isBusy) return;
|
||||||
|
closeCommitContextMenu();
|
||||||
|
await onCreateBranchFromCommit(commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreContextCommit() {
|
||||||
|
const commit = contextCommit;
|
||||||
|
if (!commit || isBusy) return;
|
||||||
|
closeCommitContextMenu();
|
||||||
|
await onRestoreCommit(commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cherryPickContextCommit() {
|
||||||
|
const commit = contextCommit;
|
||||||
|
if (!commit || isBusy) return;
|
||||||
|
closeCommitContextMenu();
|
||||||
|
await onCherryPickCommit(commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
closeCommitContextMenu();
|
||||||
|
handleBranchDialogKeydown(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
function localBranchRefs(commit: GitCommit): string[] {
|
function localBranchRefs(commit: GitCommit): string[] {
|
||||||
@@ -334,6 +424,18 @@
|
|||||||
return labels;
|
return labels;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function graphBranchRefs(commit: GitCommit): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const labels: string[] = [];
|
||||||
|
for (const ref of commit.refs) {
|
||||||
|
const label = refLabel(ref);
|
||||||
|
if (!graphBranchNameSet.has(label) || seen.has(label)) continue;
|
||||||
|
seen.add(label);
|
||||||
|
labels.push(label);
|
||||||
|
}
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
function visibleRefs(commit: GitCommit): string[] {
|
function visibleRefs(commit: GitCommit): string[] {
|
||||||
return commit.refs.filter((ref) => !localBranchNameSet.has(refLabel(ref)));
|
return commit.refs.filter((ref) => !localBranchNameSet.has(refLabel(ref)));
|
||||||
}
|
}
|
||||||
@@ -367,7 +469,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
|
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
|
||||||
? activeBranch
|
? activeBranch
|
||||||
: (localBranchNames[0] ?? "");
|
: (graphBranchNames[0] ?? "");
|
||||||
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
|
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
|
||||||
|
|
||||||
if (!defaultBranch) {
|
if (!defaultBranch) {
|
||||||
@@ -399,9 +501,9 @@
|
|||||||
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
|
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={handleBranchDialogKeydown} />
|
<svelte:window onclick={closeCommitContextMenu} onkeydown={handleWindowKeydown} on:contextmenu|capture={closeCommitContextMenu} />
|
||||||
|
|
||||||
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history">
|
<section bind:this={panelElement} class="panel history-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">History</span>
|
<span class="eyebrow">History</span>
|
||||||
@@ -437,15 +539,25 @@
|
|||||||
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
|
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
|
||||||
{@const item = entry.commit}
|
{@const item = entry.commit}
|
||||||
{@const row = graphRows[rowIndex]}
|
{@const row = graphRows[rowIndex]}
|
||||||
{@const hoverBranchRefs = visibleBranchLabels(row?.branchLabels ?? [])}
|
{@const hoverBranchRefs = commitHoverBranchLabels(item, row)}
|
||||||
{@const otherRefs = visibleRefs(item)}
|
{@const otherRefs = visibleRefs(item)}
|
||||||
<article class="commit-row graph-row" class:merge-row={item.parents.length > 1} class:root-row={item.parents.length === 0} class:tip-row={item.refs.length > 0}>
|
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
|
||||||
|
<article
|
||||||
|
class="commit-row graph-row"
|
||||||
|
class:graph-ahead-row={rowSyncClass === "ahead"}
|
||||||
|
class:graph-behind-row={rowSyncClass === "behind"}
|
||||||
|
class:merge-row={item.parents.length > 1}
|
||||||
|
class:root-row={item.parents.length === 0}
|
||||||
|
class:tip-row={item.refs.length > 0}
|
||||||
|
>
|
||||||
<div class="graph-gutter" style={`width:${graphWidth}px`} aria-hidden="true">
|
<div class="graph-gutter" style={`width:${graphWidth}px`} aria-hidden="true">
|
||||||
{#if row}
|
{#if row}
|
||||||
<svg class="graph-svg" viewBox={`0 0 ${graphWidth} 100`} preserveAspectRatio="none">
|
<svg class="graph-svg" viewBox={`0 0 ${graphWidth} 100`} preserveAspectRatio="none">
|
||||||
{#each row.top as seg}
|
{#each row.top as seg}
|
||||||
<path
|
<path
|
||||||
class:hidden-branch={!segmentIsVisible(seg)}
|
class:hidden-branch={!segmentIsVisible(seg)}
|
||||||
|
class:graph-segment-ahead={syncClassForBranches(seg.branches) === "ahead"}
|
||||||
|
class:graph-segment-behind={syncClassForBranches(seg.branches) === "behind"}
|
||||||
d={graphPath(seg, 0, 50)}
|
d={graphPath(seg, 0, 50)}
|
||||||
stroke={seg.color}
|
stroke={seg.color}
|
||||||
stroke-width="2.2"
|
stroke-width="2.2"
|
||||||
@@ -455,6 +567,8 @@
|
|||||||
{#each row.bottom as seg}
|
{#each row.bottom as seg}
|
||||||
<path
|
<path
|
||||||
class:hidden-branch={!segmentIsVisible(seg)}
|
class:hidden-branch={!segmentIsVisible(seg)}
|
||||||
|
class:graph-segment-ahead={syncClassForBranches(seg.branches) === "ahead"}
|
||||||
|
class:graph-segment-behind={syncClassForBranches(seg.branches) === "behind"}
|
||||||
d={graphPath(seg, 50, 100)}
|
d={graphPath(seg, 50, 100)}
|
||||||
stroke={seg.color}
|
stroke={seg.color}
|
||||||
stroke-width="2.2"
|
stroke-width="2.2"
|
||||||
@@ -467,13 +581,20 @@
|
|||||||
class:merge={item.parents.length > 1}
|
class:merge={item.parents.length > 1}
|
||||||
class:tip={item.refs.length > 0}
|
class:tip={item.refs.length > 0}
|
||||||
class:hidden-branch={!rowGraphIsVisible(row)}
|
class:hidden-branch={!rowGraphIsVisible(row)}
|
||||||
title={hoverBranchRefs.length > 0 ? `Contained in: ${hoverBranchRefs.join(", ")}` : item.short_hash}
|
class:ahead={rowSyncClass === "ahead"}
|
||||||
|
class:behind={rowSyncClass === "behind"}
|
||||||
|
title={commitHoverTitle(item, row)}
|
||||||
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
|
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
|
||||||
></span>
|
></span>
|
||||||
{#if hoverBranchRefs.length > 0}
|
{#if hoverBranchRefs.length > 0}
|
||||||
<div class="graph-hover-branches" style={`left:${graphColX(row.dotCol) + 13}px`}>
|
<div class="graph-hover-branches" style={`left:${graphColX(row.dotCol) + 13}px`}>
|
||||||
{#each hoverBranchRefs as branch}
|
{#each hoverBranchRefs as branch}
|
||||||
<span title={branch}>
|
<span
|
||||||
|
class:remote={branch === activeUpstream}
|
||||||
|
class:ahead={activeBranch === branch && rowSyncClass === "ahead"}
|
||||||
|
class:behind={activeUpstream === branch && rowSyncClass === "behind"}
|
||||||
|
title={branch}
|
||||||
|
>
|
||||||
<GitBranch size={10} aria-hidden="true" />
|
<GitBranch size={10} aria-hidden="true" />
|
||||||
{branch}
|
{branch}
|
||||||
</span>
|
</span>
|
||||||
@@ -551,13 +672,17 @@
|
|||||||
<div class="commit-actions">
|
<div class="commit-actions">
|
||||||
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
|
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
|
||||||
<div class="commit-action-buttons">
|
<div class="commit-action-buttons">
|
||||||
<button class="btn-sm" type="button" onclick={() => onCreateBranchFromCommit(item)} disabled={isBusy} title="Create a new branch from this commit">
|
<button
|
||||||
<GitBranch size={15} aria-hidden="true" />
|
class="commit-menu-button"
|
||||||
Branch
|
type="button"
|
||||||
</button>
|
onclick={(event) => openCommitActionMenu(event, item)}
|
||||||
<button class="btn-sm" type="button" onclick={() => onRestoreCommit(item)} disabled={isBusy} title="Bring this commit's files into your working tree as unstaged changes (no history is changed)">
|
disabled={isBusy}
|
||||||
<RotateCcw size={15} aria-hidden="true" />
|
title="Commit actions"
|
||||||
Restore
|
aria-label={`Actions for ${item.short_hash}`}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={contextCommit?.hash === item.hash}
|
||||||
|
>
|
||||||
|
<EllipsisVertical size={15} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -566,10 +691,39 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if contextCommit}
|
||||||
|
<div
|
||||||
|
class="history-context-menu"
|
||||||
|
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
||||||
|
role="menu"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label={`Actions for ${contextCommit.short_hash}`}
|
||||||
|
>
|
||||||
|
<button type="button" role="menuitem" onclick={createBranchFromContextCommit} disabled={isBusy}>
|
||||||
|
<GitBranch size={14} aria-hidden="true" />
|
||||||
|
Branch
|
||||||
|
</button>
|
||||||
|
<button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}>
|
||||||
|
<RotateCcw size={14} aria-hidden="true" />
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onclick={cherryPickContextCommit}
|
||||||
|
disabled={isBusy}
|
||||||
|
title="Apply this commit's changes on top of the current branch"
|
||||||
|
>
|
||||||
|
<Cherry size={14} aria-hidden="true" />
|
||||||
|
Cherry-pick
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if branchDialogOpen}
|
{#if branchDialogOpen}
|
||||||
<div class="branch-filter-backdrop" role="presentation" onclick={handleBranchDialogBackdropClick}>
|
<div class="branch-filter-backdrop" role="presentation">
|
||||||
<div
|
<div
|
||||||
class="branch-filter-dialog"
|
class="branch-filter-dialog"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
<div
|
<div
|
||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
>
|
>
|
||||||
<div class="dialog new-branch-dialog" role="dialog" aria-modal="true" aria-label="Create branch from commit" tabindex="-1">
|
<div class="dialog new-branch-dialog" role="dialog" aria-modal="true" aria-label="Create branch from commit" tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
|
|||||||
@@ -33,7 +33,6 @@
|
|||||||
<div
|
<div
|
||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
>
|
>
|
||||||
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
|
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
|
|||||||
@@ -43,12 +43,14 @@
|
|||||||
<style>
|
<style>
|
||||||
.repo-loading {
|
.repo-loading {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
top: var(--app-titlebar-height, 42px);
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
z-index: 400;
|
z-index: 400;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background: rgba(6, 6, 14, 0.72);
|
background: #050712;
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
animation: overlay-in 180ms ease;
|
animation: overlay-in 180ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -254,7 +254,6 @@
|
|||||||
<div
|
<div
|
||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="dialog"
|
class="dialog"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
CommitAiProvider,
|
CommitAiProvider,
|
||||||
CommitAiStatus,
|
CommitAiStatus,
|
||||||
ConflictFile,
|
ConflictFile,
|
||||||
|
GitBlameResult,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
GitCommit,
|
GitCommit,
|
||||||
GitCommitComparison,
|
GitCommitComparison,
|
||||||
@@ -12,6 +13,7 @@ import type {
|
|||||||
GitSearchHit,
|
GitSearchHit,
|
||||||
GitStash,
|
GitStash,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
|
GitTag,
|
||||||
LocalModelOption,
|
LocalModelOption,
|
||||||
PatchApplyAction,
|
PatchApplyAction,
|
||||||
RepositoryBundle,
|
RepositoryBundle,
|
||||||
@@ -34,6 +36,24 @@ export function openRepositoryBundle(path: string, commitLimit = 100): Promise<R
|
|||||||
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
|
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cloneRepository(
|
||||||
|
remoteUrl: string,
|
||||||
|
parentPath: string,
|
||||||
|
directoryName?: string,
|
||||||
|
username?: string,
|
||||||
|
password?: string,
|
||||||
|
commitLimit = 100,
|
||||||
|
): Promise<RepositoryBundle> {
|
||||||
|
return invoke<RepositoryBundle>("clone_repository", {
|
||||||
|
remoteUrl,
|
||||||
|
parentPath,
|
||||||
|
directoryName: directoryName?.trim() ? directoryName.trim() : null,
|
||||||
|
username: username ?? null,
|
||||||
|
password: password ?? null,
|
||||||
|
commitLimit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getStatus(path: string): Promise<GitStatus> {
|
export function getStatus(path: string): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("get_status", { path });
|
return invoke<GitStatus>("get_status", { path });
|
||||||
}
|
}
|
||||||
@@ -76,6 +96,49 @@ export function deleteBranch(path: string, branch: string, force = false): Promi
|
|||||||
return invoke<GitStatus>("delete_branch", { path, branch, force });
|
return invoke<GitStatus>("delete_branch", { path, branch, force });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listTags(path: string): Promise<GitTag[]> {
|
||||||
|
return invoke<GitTag[]>("list_tags", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTag(
|
||||||
|
path: string,
|
||||||
|
name: string,
|
||||||
|
target?: string,
|
||||||
|
message?: string,
|
||||||
|
): Promise<GitTag[]> {
|
||||||
|
return invoke<GitTag[]>("create_tag", {
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
target: target?.trim() ? target.trim() : null,
|
||||||
|
message: message?.trim() ? message.trim() : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTag(path: string, name: string): Promise<GitTag[]> {
|
||||||
|
return invoke<GitTag[]>("delete_tag", { path, name });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pushTag(
|
||||||
|
path: string,
|
||||||
|
name: string,
|
||||||
|
username?: string,
|
||||||
|
password?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return invoke<void>("push_tag", { path, name, username: username ?? null, password: password ?? null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cherryPickCommit(path: string, commit: string): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("cherry_pick_commit", { path, commit });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cherryPickContinue(path: string): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("cherry_pick_continue", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cherryPickAbort(path: string): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("cherry_pick_abort", { path });
|
||||||
|
}
|
||||||
|
|
||||||
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
|
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("stage_files", { path, files });
|
return invoke<GitStatus>("stage_files", { path, files });
|
||||||
}
|
}
|
||||||
@@ -109,6 +172,18 @@ export function commit(path: string, message: string): Promise<GitStatus> {
|
|||||||
return invoke<GitStatus>("commit", { path, message });
|
return invoke<GitStatus>("commit", { path, message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function amendCommit(path: string, message?: string): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("amend_commit", { path, message: message?.trim() ? message.trim() : null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lastCommitMessage(path: string): Promise<string | null> {
|
||||||
|
return invoke<string | null>("last_commit_message", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function undoLastCommit(path: string): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("undo_last_commit", { path });
|
||||||
|
}
|
||||||
|
|
||||||
export function stashPush(
|
export function stashPush(
|
||||||
path: string,
|
path: string,
|
||||||
message?: string,
|
message?: string,
|
||||||
@@ -248,6 +323,10 @@ export function cancelFileHistory(requestId: string): Promise<void> {
|
|||||||
return invoke<void>("cancel_file_history", { requestId });
|
return invoke<void>("cancel_file_history", { requestId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getFileBlame(path: string, file: string): Promise<GitBlameResult> {
|
||||||
|
return invoke<GitBlameResult>("get_file_blame", { path, file });
|
||||||
|
}
|
||||||
|
|
||||||
export function compareCommits(
|
export function compareCommits(
|
||||||
path: string,
|
path: string,
|
||||||
from: string,
|
from: string,
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ export interface AiSettings {
|
|||||||
customModel: string;
|
customModel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
noticeSeen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitStatus {
|
export interface GitStatus {
|
||||||
repo_path: string;
|
repo_path: string;
|
||||||
current_branch: string | null;
|
current_branch: string | null;
|
||||||
@@ -42,6 +47,7 @@ export interface GitStatus {
|
|||||||
files: GitFileStatus[];
|
files: GitFileStatus[];
|
||||||
clean: boolean;
|
clean: boolean;
|
||||||
rebase_in_progress: boolean;
|
rebase_in_progress: boolean;
|
||||||
|
cherry_pick_in_progress: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitFileStatus {
|
export interface GitFileStatus {
|
||||||
@@ -59,6 +65,15 @@ export interface GitBranch {
|
|||||||
remote: boolean;
|
remote: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GitTag {
|
||||||
|
name: string;
|
||||||
|
hash: string;
|
||||||
|
short_hash: string;
|
||||||
|
message: string | null;
|
||||||
|
date: string;
|
||||||
|
annotated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitStash {
|
export interface GitStash {
|
||||||
selector: string;
|
selector: string;
|
||||||
index: number;
|
index: number;
|
||||||
@@ -95,6 +110,7 @@ export interface GitRepositoryFile {
|
|||||||
export interface RepositoryBundle {
|
export interface RepositoryBundle {
|
||||||
status: GitStatus;
|
status: GitStatus;
|
||||||
branches: GitBranch[];
|
branches: GitBranch[];
|
||||||
|
tags: GitTag[];
|
||||||
stashes: GitStash[];
|
stashes: GitStash[];
|
||||||
commits: GitCommit[];
|
commits: GitCommit[];
|
||||||
files: GitRepositoryFile[];
|
files: GitRepositoryFile[];
|
||||||
@@ -164,6 +180,23 @@ export interface ConflictFile {
|
|||||||
theirs_size: number | null;
|
theirs_size: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GitBlameLine {
|
||||||
|
line_number: number;
|
||||||
|
content: string;
|
||||||
|
commit_hash: string;
|
||||||
|
short_hash: string;
|
||||||
|
author_name: string;
|
||||||
|
author_email: string;
|
||||||
|
author_time: number;
|
||||||
|
summary: string;
|
||||||
|
is_uncommitted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitBlameResult {
|
||||||
|
path: string;
|
||||||
|
lines: GitBlameLine[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface StoredCredential {
|
export interface StoredCredential {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user