Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71a97b20bb | ||
|
|
97b5db3031 | ||
|
|
f607c6b5cf | ||
|
|
13660c801e | ||
|
|
c46a875a91 | ||
|
|
25d9d28764 | ||
|
|
da53ba3c0e | ||
|
|
20bc53ebbb | ||
|
|
c6553218a2 | ||
|
|
26f09a3350 | ||
|
|
a55a3ca300 | ||
|
|
865ea410ea | ||
|
|
96d8ec739f | ||
|
|
2b43fb18ac | ||
|
|
5c0c47b5c5 | ||
|
|
ebb5a5fa48 | ||
|
|
2ec493e91f | ||
|
|
7f2b04a506 | ||
|
|
3ceedca0ad | ||
|
|
fcd884ee0b | ||
|
|
4a4a13dcf8 | ||
|
|
106cc98afb | ||
|
|
add98f962e | ||
|
|
90df347e4a | ||
|
|
4c58b14691 | ||
|
|
8bec7dfc9a | ||
|
|
7950edb145 | ||
|
|
e3af6653cd | ||
|
|
b752c0804b | ||
|
|
8f98e79df9 | ||
|
|
f0a1d89152 | ||
|
|
c3762ae7a3 | ||
|
|
4db6f30461 |
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"enabledPlugins": {
|
|
||||||
"frontend-design@claude-plugins-official": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(cargo build *)",
|
|
||||||
"Bash(npm run *)",
|
|
||||||
"Bash(kill %1)",
|
|
||||||
"Bash(perl -0pi -e 's/\\\\{line \\\\|\\\\| \" \"\\\\}/{displayLine\\(line\\) || \" \"}/g' src/App.svelte)",
|
|
||||||
"Bash(kill 26306)",
|
|
||||||
"Bash(convert --version)",
|
|
||||||
"Bash(magick /mnt/data/Development/GitLite/src-tauri/icons/icon.ico /mnt/data/Development/GitLite/src-tauri/icons/icon.png)",
|
|
||||||
"Bash(magick /mnt/data/Development/GitLite/src-tauri/icons/icon.ico -type TrueColorAlpha -alpha on PNG32:/mnt/data/Development/GitLite/src-tauri/icons/icon.png)",
|
|
||||||
"Bash(magick identify *)",
|
|
||||||
"Bash(magick -size 512x512 xc:\"rgba\\(70,130,180,255\\)\" PNG32:/mnt/data/Development/GitLite/src-tauri/icons/icon.png)",
|
|
||||||
"Bash(pkill -f \"vite --host\")",
|
|
||||||
"Bash(pkill -f \"tauri dev\")",
|
|
||||||
"Bash(pkill -f \"tauri_git_lite\")",
|
|
||||||
"Bash(xargs kill -9)",
|
|
||||||
"Bash(grep -qE \"\\(Running \\\\`target|error\\\\[|panicked|terminated\\)\" /tmp/claude-1000/-mnt-data-Development-GitLite/71a8b49f-ac61-4ad9-aaba-dc0219044038/tasks/b4ujhgmdk.output)",
|
|
||||||
"Bash(grep -qE \"\\(Running \\\\`target|error\\\\[|panicked|terminated\\)\" /tmp/claude-1000/-mnt-data-Development-GitLite/71a8b49f-ac61-4ad9-aaba-dc0219044038/tasks/bt54rikhh.output)",
|
|
||||||
"Bash(grep -qE \"\\(Running \\\\`target|error\\\\[|panicked|terminated\\)\" /tmp/claude-1000/-mnt-data-Development-GitLite/71a8b49f-ac61-4ad9-aaba-dc0219044038/tasks/b7nyub68s.output)",
|
|
||||||
"Bash(npm install *)",
|
|
||||||
"Bash(cargo check *)",
|
|
||||||
"Bash(npx vite *)",
|
|
||||||
"Bash(cargo tree *)",
|
|
||||||
"Bash(jobs)",
|
|
||||||
"Bash(npx svelte-check *)",
|
|
||||||
"Bash(git log *)",
|
|
||||||
"Bash(xxd)",
|
|
||||||
"Bash(python3 -)",
|
|
||||||
"Bash(echo \"exit: $?\")",
|
|
||||||
"Bash(grep -n \"input,\\\\|select,\\\\|input {\\\\|select {\\\\|.repo-form input\\\\|input:focus\\\\|::placeholder\" src/app.css)",
|
|
||||||
"Bash(sudo -n true)",
|
|
||||||
"Bash(rustc --version)",
|
|
||||||
"Read(//mnt/c/Users/cbr/Desktop/src-tauri/src/**)",
|
|
||||||
"Bash(sudo apt install -y libdbus-1-dev pkg-config)",
|
|
||||||
"Bash(dpkg -l)",
|
|
||||||
"Bash(apt list *)",
|
|
||||||
"Bash(cargo search *)",
|
|
||||||
"Bash(curl -s \"https://crates.io/api/v1/crates/mistralrs\")",
|
|
||||||
"Bash(cargo info *)",
|
|
||||||
"WebFetch(domain:raw.githubusercontent.com)",
|
|
||||||
"Bash(gh api *)",
|
|
||||||
"WebFetch(domain:github.com)",
|
|
||||||
"WebFetch(domain:ericlbuehler.github.io)",
|
|
||||||
"WebFetch(domain:docs.rs)",
|
|
||||||
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-1.5B-Instruct-GGUF\")",
|
|
||||||
"Bash(python3 -c ' *)",
|
|
||||||
"Bash(curl -sI \"https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf\")",
|
|
||||||
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-1.5B-Instruct-GGUF?blobs=true\")",
|
|
||||||
"Bash(grep -n 'from \"\\\\./lib/git\"\\\\|from \"\\\\./lib/types\"\\\\|^ commit,$' src/App.svelte)",
|
|
||||||
"Bash(kill 24343 24363 24375 24376)",
|
|
||||||
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-0.5B-Instruct-GGUF?blobs=true\")",
|
|
||||||
"Bash(curl -s \"https://huggingface.co/api/models/bartowski/Llama-3.2-3B-Instruct-GGUF?blobs=true\")",
|
|
||||||
"Bash(curl -s \"https://huggingface.co/api/models/bartowski/Llama-3.2-3B-Instruct-GGUF\")",
|
|
||||||
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-3B-Instruct-GGUF\")",
|
|
||||||
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-3B-Instruct-GGUF?blobs=true\")",
|
|
||||||
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-3B-Instruct\")",
|
|
||||||
"Bash(: *)",
|
|
||||||
"Bash(exit 0 *)",
|
|
||||||
"Bash(rustc -O sanitize_test.rs -o sanitize_test)",
|
|
||||||
"Bash(./sanitize_test)",
|
|
||||||
"Bash(echo \"exit:$?\")",
|
|
||||||
"Bash(grep -rlP \"[äöüßÄÖÜ]\" src src-tauri/src src-tauri/crates --include=\"*.rs\" --include=\"*.svelte\" --include=\"*.ts\")",
|
|
||||||
"Bash(echo \"---exit $?---\")",
|
|
||||||
"Bash(apt-cache policy *)",
|
|
||||||
"Bash(timeout 5 curl -sI http://archive.ubuntu.com)",
|
|
||||||
"Bash(sudo -n apt-get install -y libdbus-1-dev pkg-config)",
|
|
||||||
"Bash(grep -n '\"Unerwarteter Git-Log-Eintrag: {}\",' src-tauri/src/git.rs)",
|
|
||||||
"Bash(grep -E \"git_lite$|tauri_git_lite$\")",
|
|
||||||
"Bash(rustfmt --edition 2024 --check src-tauri/src/git.rs)",
|
|
||||||
"Bash(echo \"EXIT:$?\")",
|
|
||||||
"Bash(ls target/)",
|
|
||||||
"Bash(rustup target *)",
|
|
||||||
"Bash(echo \"exit code: $?\")",
|
|
||||||
"Read(//home/cbr/.cargo/registry/src/**)",
|
|
||||||
"Bash(find / -maxdepth 6 -iname \"mistralrs-*\" -type d)",
|
|
||||||
"Bash(grep -A2 '^name = \"tauri\"$' \"/mnt/c/Users/cbr/Desktop/Neuer Ordner \\(6\\)/src-tauri/Cargo.lock\")",
|
|
||||||
"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/badge.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/**)",
|
|
||||||
"Bash(identify src-tauri/icons/GitCat.ico)",
|
|
||||||
"Bash(python3 -c \"import PIL; print\\(PIL.__version__\\)\")",
|
|
||||||
"Bash(python3 *)",
|
|
||||||
"Bash(xxd -l 16 src-tauri/icons/GitCat.ico)",
|
|
||||||
"Bash(pkg-config --exists openssl)",
|
|
||||||
"Bash(sudo apt-get install -y libssl-dev pkg-config)",
|
|
||||||
"Bash(dpkg -L libssl3t64)",
|
|
||||||
"Bash(grep *)",
|
|
||||||
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.11.5/src/**)",
|
|
||||||
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-runtime-wry-2.11.4/src/**)",
|
|
||||||
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.11.5/src/window/**)",
|
|
||||||
"WebSearch",
|
|
||||||
"Bash(node -p \"require\\('./package.json'\\).version\")",
|
|
||||||
"Bash(bash -n PKGBUILD)",
|
|
||||||
"Bash(makepkg --printsrcinfo)",
|
|
||||||
"Bash(npx tauri *)",
|
|
||||||
"Bash(echo \"---exit code:$?---\")",
|
|
||||||
"Bash(git -C /mnt/data/Development/GitLite status --short)",
|
|
||||||
"Bash(makepkg -f --noconfirm)",
|
|
||||||
"Bash(tar -tf gitty-2026.7.19-1-x86_64.pkg.tar.zst)",
|
|
||||||
"Bash(cp /mnt/data/Development/GitLite/PKGBUILD .)",
|
|
||||||
"Bash(sed -i 's/^pkgrel=1/pkgrel=3/' PKGBUILD)",
|
|
||||||
"Bash(node -e \"const fs=require\\('fs'\\); const version='2026.8.1'; const pkgbuild=fs.readFileSync\\('PKGBUILD','utf8'\\).replace\\(/^pkgver=.*\\\\$/m, 'pkgver='+version\\).replace\\(/^pkgrel=.*\\\\$/m, 'pkgrel=1'\\); fs.writeFileSync\\('PKGBUILD', pkgbuild\\);\")",
|
|
||||||
"Bash(rm PKGBUILD)",
|
|
||||||
"Bash(git -C /mnt/data/Development/GitLite stash)",
|
|
||||||
"Bash(git -C /mnt/data/Development/GitLite stash pop)",
|
|
||||||
"Bash(command -v docker)",
|
|
||||||
"Read(//home/christoph/.docker/**)",
|
|
||||||
"Bash(docker info *)",
|
|
||||||
"Bash(sudo -n test -f /root/.docker/config.json)",
|
|
||||||
"Bash(systemctl list-units *)",
|
|
||||||
"Bash(find / -maxdepth 4 -iname \"act_runner*\")",
|
|
||||||
"Bash(find / -maxdepth 6 -iname \"config.yaml\" -path \"*runner*\")",
|
|
||||||
"Read(//home/**)",
|
|
||||||
"Bash(getent passwd *)",
|
|
||||||
"Bash(docker context *)",
|
|
||||||
"Bash(git -C /mnt/data/Development/GitLite log --oneline -1 -- PKGBUILD)",
|
|
||||||
"Bash(git -C /mnt/data/Development/GitLite show --stat HEAD -- PKGBUILD)",
|
|
||||||
"Bash(git -C /mnt/data/Development/GitLite log --oneline -10)",
|
|
||||||
"Bash(git -C /mnt/data/Development/GitLite status)",
|
|
||||||
"Bash(git *)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -28,7 +28,7 @@ Fast, simple, and designed for developers who want a clean Git experience withou
|
|||||||
|
|
||||||
## 📸 Preview
|
## 📸 Preview
|
||||||
|
|
||||||
> Screenshots coming soon.
|
> Screenshots comes later.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -119,20 +119,6 @@ with the stored credentials.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
Start the complete desktop application in development mode with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run tauri:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
The development launcher preserves normal proxy settings. If a sandboxed
|
|
||||||
development terminal injects the non-routing `127.0.0.1:9` proxy or its
|
|
||||||
blocking SSH placeholder, those values are removed only for the Tauri child
|
|
||||||
process so Git remotes and Git LFS remain testable in the debug application.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Git LFS
|
## Git LFS
|
||||||
|
|
||||||
@@ -145,12 +131,3 @@ detects LFS usage and automatically downloads the required LFS objects with the
|
|||||||
same remote and credentials, so no second pull is needed. Fresh clones also get
|
same remote and credentials, so no second pull is needed. Fresh clones also get
|
||||||
repository-local LFS filters and the pre-push hook before they are opened.
|
repository-local LFS filters and the pre-push hook before they are opened.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## SigNoz telemetry
|
|
||||||
|
|
||||||
After the user opts in, Gitty sends privacy-filtered product events and technical errors as OTLP/HTTP JSON logs. Every Git/Tauri command is also captured as a trace span with its command name, duration and success state, but without command arguments or results. CPU utilization and resident memory usage of the Gitty process are sampled every 30 seconds and exported as OpenTelemetry gauges. The default endpoints are `https://telemetry.cbsk-tech.de/v1/logs`, `https://telemetry.cbsk-tech.de/v1/traces`, and `https://telemetry.cbsk-tech.de/v1/metrics`.
|
|
||||||
|
|
||||||
The SigNoz reverse proxy must forward all three paths to the Collector's OTLP/HTTP receiver (normally port `4318`). For development or a separate collector hostname, set `GITTY_OTLP_LOGS_ENDPOINT`, `GITTY_OTLP_TRACES_ENDPOINT`, and `GITTY_OTLP_METRICS_ENDPOINT` to the full signal URLs before starting Gitty.
|
|
||||||
|
|
||||||
Telemetry is non-blocking, bounded, disabled until consent is stored, and never includes repository paths, remote URLs, branch names, commit messages, diffs, file names, credentials, source code, URLs, or email addresses.
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.8.8",
|
"version": "2026.9.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.8.8",
|
"version": "2026.9.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucide/svelte": "^1.21.0",
|
"@lucide/svelte": "^1.21.0",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.8.8",
|
"version": "2026.9.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+38
-3229
File diff suppressed because it is too large
Load Diff
@@ -20,10 +20,11 @@ tauri-plugin-dialog = "=2.7.0"
|
|||||||
tauri-plugin-aptabase = "1.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"
|
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "time"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
sysinfo = { version = "=0.38.3", default-features = false, features = ["system"] }
|
sysinfo = { version = "=0.38.3", default-features = false, features = ["system"] }
|
||||||
|
shlex = "2"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mistralrs = "0.8"
|
|
||||||
tokio = { version = "1", features = ["sync"] }
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
|||||||
@@ -5,318 +5,8 @@ pub use cloud::{
|
|||||||
review_openai, split_anthropic, split_custom, split_openai,
|
review_openai, split_anthropic, split_custom, split_openai,
|
||||||
};
|
};
|
||||||
|
|
||||||
use std::{
|
/// Strip a wrapping code fence and wrapping quotes so the result can go straight into
|
||||||
collections::hash_map::DefaultHasher,
|
/// the commit-message box.
|
||||||
hash::{Hash, Hasher},
|
|
||||||
sync::Arc,
|
|
||||||
};
|
|
||||||
|
|
||||||
use mistralrs::{GgufModelBuilder, Model, RequestBuilder, TextMessageRole};
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
|
||||||
/// One selectable local (on-device) model. Larger models produce better commit messages
|
|
||||||
/// but take longer to download (first run only, then cached) and run slower on CPU.
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
|
||||||
pub struct LocalModelOption {
|
|
||||||
pub id: &'static str,
|
|
||||||
pub label: &'static str,
|
|
||||||
pub approx_size_mb: u32,
|
|
||||||
repo: &'static str,
|
|
||||||
file: &'static str,
|
|
||||||
tokenizer_repo: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const DEFAULT_LOCAL_MODEL_ID: &str = "qwen2.5-0.5b";
|
|
||||||
|
|
||||||
pub const LOCAL_MODELS: &[LocalModelOption] = &[
|
|
||||||
LocalModelOption {
|
|
||||||
id: "qwen2.5-0.5b",
|
|
||||||
label: "Qwen2.5 0.5B Instruct — fast, lower quality",
|
|
||||||
approx_size_mb: 490,
|
|
||||||
repo: "Qwen/Qwen2.5-0.5B-Instruct-GGUF",
|
|
||||||
file: "qwen2.5-0.5b-instruct-q4_k_m.gguf",
|
|
||||||
tokenizer_repo: "Qwen/Qwen2.5-0.5B-Instruct",
|
|
||||||
},
|
|
||||||
LocalModelOption {
|
|
||||||
id: "qwen2.5-1.5b",
|
|
||||||
label: "Qwen2.5 1.5B Instruct — recommended",
|
|
||||||
approx_size_mb: 1050,
|
|
||||||
repo: "Qwen/Qwen2.5-1.5B-Instruct-GGUF",
|
|
||||||
file: "qwen2.5-1.5b-instruct-q4_k_m.gguf",
|
|
||||||
tokenizer_repo: "Qwen/Qwen2.5-1.5B-Instruct",
|
|
||||||
},
|
|
||||||
LocalModelOption {
|
|
||||||
id: "qwen2.5-3b",
|
|
||||||
label: "Qwen2.5 3B Instruct — best quality, slower",
|
|
||||||
approx_size_mb: 2100,
|
|
||||||
repo: "Qwen/Qwen2.5-3B-Instruct-GGUF",
|
|
||||||
file: "qwen2.5-3b-instruct-q4_k_m.gguf",
|
|
||||||
tokenizer_repo: "Qwen/Qwen2.5-3B-Instruct",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
fn find_local_model(model_id: &str) -> Option<&'static LocalModelOption> {
|
|
||||||
LOCAL_MODELS.iter().find(|option| option.id == model_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum LocalGenerationProfile {
|
|
||||||
Fast,
|
|
||||||
Balanced,
|
|
||||||
Detailed,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for LocalGenerationProfile {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Fast
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LocalGenerationProfile {
|
|
||||||
pub fn from_id(value: Option<&str>) -> Self {
|
|
||||||
match value
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase()
|
|
||||||
.as_str()
|
|
||||||
{
|
|
||||||
"balanced" => Self::Balanced,
|
|
||||||
"detailed" => Self::Detailed,
|
|
||||||
_ => Self::Fast,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn diff_unified_context(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Fast => "--unified=1",
|
|
||||||
Self::Balanced => "--unified=2",
|
|
||||||
Self::Detailed => "--unified=3",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn max_diff_chars(self) -> usize {
|
|
||||||
match self {
|
|
||||||
Self::Fast => 8_000,
|
|
||||||
Self::Balanced => 12_000,
|
|
||||||
Self::Detailed => 24_000,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn max_output_tokens(self) -> usize {
|
|
||||||
match self {
|
|
||||||
Self::Fast => 160,
|
|
||||||
Self::Balanced => 360,
|
|
||||||
Self::Detailed => 750,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum CommitAiPhase {
|
|
||||||
/// Nothing has been requested yet.
|
|
||||||
Idle,
|
|
||||||
/// Downloading (first run only, then cached by hf-hub) and/or loading into memory.
|
|
||||||
Loading,
|
|
||||||
Ready,
|
|
||||||
Error,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
|
||||||
pub struct CommitAiStatus {
|
|
||||||
pub phase: CommitAiPhase,
|
|
||||||
pub model_id: Option<String>,
|
|
||||||
pub error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Inner {
|
|
||||||
phase: CommitAiPhase,
|
|
||||||
model_id: Option<String>,
|
|
||||||
error: Option<String>,
|
|
||||||
model: Option<Arc<Model>>,
|
|
||||||
cache: Option<GenerationCache>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
struct GenerationCacheKey {
|
|
||||||
model_id: String,
|
|
||||||
profile: LocalGenerationProfile,
|
|
||||||
input_hash: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct GenerationCache {
|
|
||||||
key: GenerationCacheKey,
|
|
||||||
message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Manages the local (on-device) model only. Cloud providers are stateless HTTP calls
|
|
||||||
/// (see [`cloud`]) and don't need this — there's nothing to download or keep loaded.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct CommitAiEngine {
|
|
||||||
inner: Arc<RwLock<Inner>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for CommitAiEngine {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
inner: Arc::new(RwLock::new(Inner {
|
|
||||||
phase: CommitAiPhase::Idle,
|
|
||||||
model_id: None,
|
|
||||||
error: None,
|
|
||||||
model: None,
|
|
||||||
cache: None,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CommitAiEngine {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn status(&self) -> CommitAiStatus {
|
|
||||||
let guard = self.inner.read().await;
|
|
||||||
CommitAiStatus {
|
|
||||||
phase: guard.phase,
|
|
||||||
model_id: guard.model_id.clone(),
|
|
||||||
error: guard.error.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Downloads (first run only; hf-hub caches the files afterwards) and loads the given
|
|
||||||
/// local model. Safe to call repeatedly — a call for the model that's already
|
|
||||||
/// ready/loading is a no-op; a call for a *different* model switches to it (the
|
|
||||||
/// previous one is dropped once no generation is still using it).
|
|
||||||
pub async fn ensure_loaded(&self, model_id: &str) {
|
|
||||||
{
|
|
||||||
let guard = self.inner.read().await;
|
|
||||||
let same_model = guard.model_id.as_deref() == Some(model_id);
|
|
||||||
if same_model && matches!(guard.phase, CommitAiPhase::Ready | CommitAiPhase::Loading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(option) = find_local_model(model_id) else {
|
|
||||||
let mut guard = self.inner.write().await;
|
|
||||||
guard.phase = CommitAiPhase::Error;
|
|
||||||
guard.model_id = Some(model_id.to_string());
|
|
||||||
guard.error = Some(format!("Unknown local model: {model_id}"));
|
|
||||||
guard.cache = None;
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut guard = self.inner.write().await;
|
|
||||||
guard.phase = CommitAiPhase::Loading;
|
|
||||||
guard.model_id = Some(model_id.to_string());
|
|
||||||
guard.error = None;
|
|
||||||
guard.model = None;
|
|
||||||
guard.cache = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = GgufModelBuilder::new(option.repo, vec![option.file])
|
|
||||||
.with_tok_model_id(option.tokenizer_repo)
|
|
||||||
.with_logging()
|
|
||||||
.build()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let mut guard = self.inner.write().await;
|
|
||||||
// If the user switched to yet another model while this one was loading, drop this
|
|
||||||
// (now stale) result instead of overwriting the newer request's state.
|
|
||||||
if guard.model_id.as_deref() != Some(model_id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
match result {
|
|
||||||
Ok(model) => {
|
|
||||||
guard.model = Some(Arc::new(model));
|
|
||||||
guard.phase = CommitAiPhase::Ready;
|
|
||||||
guard.error = None;
|
|
||||||
guard.cache = None;
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
guard.phase = CommitAiPhase::Error;
|
|
||||||
guard.error = Some(err.to_string());
|
|
||||||
guard.cache = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn generate_commit_message(
|
|
||||||
&self,
|
|
||||||
diff: &str,
|
|
||||||
notes: Option<&str>,
|
|
||||||
profile: LocalGenerationProfile,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let (model, cache_key) = {
|
|
||||||
let guard = self.inner.read().await;
|
|
||||||
match (guard.phase, &guard.model) {
|
|
||||||
(CommitAiPhase::Ready, Some(model)) => {
|
|
||||||
let cache_key = GenerationCacheKey {
|
|
||||||
model_id: guard.model_id.clone().unwrap_or_default(),
|
|
||||||
profile,
|
|
||||||
input_hash: generation_input_hash(diff, notes),
|
|
||||||
};
|
|
||||||
if let Some(cache) = &guard.cache {
|
|
||||||
if cache.key == cache_key {
|
|
||||||
return Ok(cache.message.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(model.clone(), cache_key)
|
|
||||||
}
|
|
||||||
_ => return Err("The local AI model is not ready yet.".to_string()),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let (system, user) = build_local_messages(diff, notes, profile)?;
|
|
||||||
let request = RequestBuilder::new()
|
|
||||||
.set_sampler_max_len(profile.max_output_tokens())
|
|
||||||
.add_message(TextMessageRole::System, system)
|
|
||||||
.add_message(TextMessageRole::User, user);
|
|
||||||
|
|
||||||
let response = model
|
|
||||||
.send_chat_request(request)
|
|
||||||
.await
|
|
||||||
.map_err(|err| err.to_string())?;
|
|
||||||
|
|
||||||
let content = response
|
|
||||||
.choices
|
|
||||||
.first()
|
|
||||||
.and_then(|choice| choice.message.content.clone())
|
|
||||||
.ok_or_else(|| "The model did not return a response.".to_string())?;
|
|
||||||
|
|
||||||
let message = sanitize_message(&content);
|
|
||||||
if message.is_empty() {
|
|
||||||
return Err("The model did not return a response.".to_string());
|
|
||||||
}
|
|
||||||
if looks_like_diff_echo(&message) {
|
|
||||||
return Err(
|
|
||||||
"The local model returned the diff instead of a commit message. Try a larger local model (1.5B or 3B) or a cloud provider.".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let mut guard = self.inner.write().await;
|
|
||||||
guard.cache = Some(GenerationCache {
|
|
||||||
key: cache_key,
|
|
||||||
message: message.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generation_input_hash(diff: &str, notes: Option<&str>) -> u64 {
|
|
||||||
let mut hasher = DefaultHasher::new();
|
|
||||||
diff.hash(&mut hasher);
|
|
||||||
notes.unwrap_or("").hash(&mut hasher);
|
|
||||||
hasher.finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Models occasionally ignore the "no code fences" instruction (small local models
|
|
||||||
/// especially) — strip a wrapping ``` fence and wrapping quotes so the result can go
|
|
||||||
/// straight into the commit-message box.
|
|
||||||
pub(crate) fn sanitize_message(raw: &str) -> String {
|
pub(crate) fn sanitize_message(raw: &str) -> String {
|
||||||
let mut text = raw.trim().to_string();
|
let mut text = raw.trim().to_string();
|
||||||
if text.starts_with("```") {
|
if text.starts_with("```") {
|
||||||
@@ -333,9 +23,9 @@ pub(crate) fn sanitize_message(raw: &str) -> String {
|
|||||||
trimmed.to_string()
|
trimmed.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Weak models (small local ones especially) sometimes just echo the prompt's diff
|
/// Some models echo the prompt's diff sections instead of writing a commit message.
|
||||||
/// sections back instead of writing a commit message. Catch that so the UI can show a
|
/// Catch that so the UI can show a clear error instead of dumping raw diff text into
|
||||||
/// clear error instead of dumping raw diff text into the commit-message box.
|
/// the commit-message box.
|
||||||
pub(crate) fn looks_like_diff_echo(message: &str) -> bool {
|
pub(crate) fn looks_like_diff_echo(message: &str) -> bool {
|
||||||
let lower = message.to_ascii_lowercase();
|
let lower = message.to_ascii_lowercase();
|
||||||
lower.contains("diff --git")
|
lower.contains("diff --git")
|
||||||
@@ -358,55 +48,12 @@ fn truncate_at_char_boundary(input: &str, max_chars: usize) -> String {
|
|||||||
format!("{}\n\n[... diff truncated ...]", &input[..cut])
|
format!("{}\n\n[... diff truncated ...]", &input[..cut])
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn build_local_messages(
|
|
||||||
diff: &str,
|
|
||||||
notes: Option<&str>,
|
|
||||||
profile: LocalGenerationProfile,
|
|
||||||
) -> Result<(String, String), String> {
|
|
||||||
if diff.trim().is_empty() {
|
|
||||||
return Err("No staged changes available for a commit message.".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let diff = truncate_at_char_boundary(diff, profile.max_diff_chars());
|
|
||||||
// Appended to every profile below: small local models occasionally just echo the input
|
|
||||||
// (the "Staged files:" / "Diff stat:" / "Detailed diff:" sections built in git.rs's
|
|
||||||
// `staged_diff_local`) instead of writing a new commit message. Naming those exact
|
|
||||||
// section headers here makes the failure mode explicit enough for weak models to avoid.
|
|
||||||
const ANTI_ECHO: &str = " Never repeat, quote, or paraphrase the diff or its headers — \
|
|
||||||
do not include 'diff --git', '@@', 'Staged files:', 'Diff stat:', or 'Detailed diff:' \
|
|
||||||
anywhere in your answer.";
|
|
||||||
let system = match profile {
|
|
||||||
LocalGenerationProfile::Fast => {
|
|
||||||
format!(
|
|
||||||
"You generate Git commit messages. Respond only with one Conventional Commits subject line: <type>(<scope>): <subject>. Max 72 characters. No body, bullets, preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
LocalGenerationProfile::Balanced => {
|
|
||||||
format!(
|
|
||||||
"You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then at most two short bullet points. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
LocalGenerationProfile::Detailed => {
|
|
||||||
format!(
|
|
||||||
"You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then a concise body and up to four short bullet points grouped by affected area/file. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut user = String::new();
|
|
||||||
if let Some(n) = notes.filter(|n| !n.trim().is_empty()) {
|
|
||||||
user.push_str(&format!("Developer notes:\n{n}\n\n"));
|
|
||||||
}
|
|
||||||
user.push_str(&format!("Staged changes:\n{diff}"));
|
|
||||||
Ok((system, user))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String, String), String> {
|
pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String, String), String> {
|
||||||
if diff.trim().is_empty() {
|
if diff.trim().is_empty() {
|
||||||
return Err("No staged changes available for a commit message.".to_string());
|
return Err("No staged changes available for a commit message.".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rough token estimate — small models often have an 8-32k context window.
|
// Rough token estimate to keep requests within common context windows.
|
||||||
const MAX_CHARS: usize = 24_000;
|
const MAX_CHARS: usize = 24_000;
|
||||||
let diff = truncate_at_char_boundary(diff, MAX_CHARS);
|
let diff = truncate_at_char_boundary(diff, MAX_CHARS);
|
||||||
|
|
||||||
|
|||||||
+954
-125
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,12 @@ use crate::git::load_stored_credential;
|
|||||||
use reqwest::blocking::{Client, Response};
|
use reqwest::blocking::{Client, Response};
|
||||||
use reqwest::header::{ACCEPT, USER_AGENT};
|
use reqwest::header::{ACCEPT, USER_AGENT};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
const PAGE_SIZE: usize = 100;
|
const PAGE_SIZE: usize = 100;
|
||||||
|
const REVIEW_REQUEST_TIMEOUT: Duration = Duration::from_secs(35);
|
||||||
|
const AZURE_PROJECT_WORKERS: usize = 8;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -20,6 +23,46 @@ pub struct IntegrationRepository {
|
|||||||
pub private: bool,
|
pub private: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct IntegrationReviewRequest {
|
||||||
|
pub id: String,
|
||||||
|
pub number: u64,
|
||||||
|
pub provider: String,
|
||||||
|
pub repository_id: String,
|
||||||
|
pub repository_name: String,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub author: String,
|
||||||
|
pub state: String,
|
||||||
|
pub source_branch: String,
|
||||||
|
pub target_branch: String,
|
||||||
|
pub web_url: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub collaborators: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub additions: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub deletions: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub changed_files: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub merge_status: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub comments: Vec<IntegrationReviewComment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct IntegrationReviewComment {
|
||||||
|
pub id: String,
|
||||||
|
pub author: String,
|
||||||
|
pub body: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct GitLabProject {
|
struct GitLabProject {
|
||||||
id: u64,
|
id: u64,
|
||||||
@@ -118,7 +161,8 @@ fn integration_key(provider: &str, account_id: Option<&str>) -> Result<String, S
|
|||||||
|
|
||||||
fn client() -> Result<Client, String> {
|
fn client() -> Result<Client, String> {
|
||||||
Client::builder()
|
Client::builder()
|
||||||
.timeout(Duration::from_secs(25))
|
.connect_timeout(Duration::from_secs(7))
|
||||||
|
.timeout(Duration::from_secs(15))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|err| format!("Could not initialize the integration client: {err}"))
|
.map_err(|err| format!("Could not initialize the integration client: {err}"))
|
||||||
}
|
}
|
||||||
@@ -350,6 +394,852 @@ fn azure_repositories(
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn value_string(value: &serde_json::Value, path: &[&str]) -> String {
|
||||||
|
path.iter()
|
||||||
|
.try_fold(value, |current, key| current.get(*key))
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_u64(value: &serde_json::Value, key: &str) -> u64 {
|
||||||
|
value
|
||||||
|
.get(key)
|
||||||
|
.and_then(serde_json::Value::as_u64)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn github_repository_name(repository_url: &str) -> String {
|
||||||
|
let parts: Vec<_> = repository_url.trim_end_matches('/').split('/').collect();
|
||||||
|
if parts.len() < 2 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
format!("{}/{}", parts[parts.len() - 2], parts[parts.len() - 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_github_review(value: &serde_json::Value) -> Option<IntegrationReviewRequest> {
|
||||||
|
let pull = value.get("pull_request")?;
|
||||||
|
let number = value_u64(value, "number");
|
||||||
|
let repository_url = value_string(value, &["repository_url"]);
|
||||||
|
let state = if pull.get("merged_at").is_some_and(|item| !item.is_null()) {
|
||||||
|
"merged"
|
||||||
|
} else if value_string(value, &["state"]) == "open" {
|
||||||
|
if value.get("draft").and_then(serde_json::Value::as_bool) == Some(true) {
|
||||||
|
"draft"
|
||||||
|
} else {
|
||||||
|
"open"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"closed"
|
||||||
|
};
|
||||||
|
Some(IntegrationReviewRequest {
|
||||||
|
id: format!("github:{repository_url}:{number}"),
|
||||||
|
number,
|
||||||
|
provider: "github".to_string(),
|
||||||
|
repository_id: repository_url.clone(),
|
||||||
|
repository_name: github_repository_name(&repository_url),
|
||||||
|
title: value_string(value, &["title"]),
|
||||||
|
description: value_string(value, &["body"]),
|
||||||
|
author: value_string(value, &["user", "login"]),
|
||||||
|
state: state.to_string(),
|
||||||
|
source_branch: String::new(),
|
||||||
|
target_branch: String::new(),
|
||||||
|
web_url: value_string(value, &["html_url"]),
|
||||||
|
created_at: value_string(value, &["created_at"]),
|
||||||
|
updated_at: value_string(value, &["updated_at"]),
|
||||||
|
collaborators: Vec::new(), additions: None, deletions: None, changed_files: None, merge_status: String::new(), comments: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_gitlab_review(value: &serde_json::Value, provider: &str) -> IntegrationReviewRequest {
|
||||||
|
let number = value_u64(value, "iid");
|
||||||
|
let project_id = value.get("project_id").map_or_else(String::new, |id| {
|
||||||
|
id.as_u64().map_or_else(
|
||||||
|
|| id.as_str().unwrap_or_default().to_string(),
|
||||||
|
|id| id.to_string(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let reference = value_string(value, &["references", "full"]);
|
||||||
|
let repository_name = reference
|
||||||
|
.rsplit_once('!')
|
||||||
|
.map_or(reference.clone(), |(name, _)| name.to_string());
|
||||||
|
let raw_state = value_string(value, &["state"]);
|
||||||
|
let draft = value.get("draft").and_then(serde_json::Value::as_bool) == Some(true)
|
||||||
|
|| value
|
||||||
|
.get("work_in_progress")
|
||||||
|
.and_then(serde_json::Value::as_bool)
|
||||||
|
== Some(true);
|
||||||
|
let state = match raw_state.as_str() {
|
||||||
|
"opened" if draft => "draft",
|
||||||
|
"opened" => "open",
|
||||||
|
"merged" => "merged",
|
||||||
|
_ => "closed",
|
||||||
|
};
|
||||||
|
IntegrationReviewRequest {
|
||||||
|
id: format!("{provider}:{project_id}:{number}"),
|
||||||
|
number,
|
||||||
|
provider: provider.to_string(),
|
||||||
|
repository_id: project_id,
|
||||||
|
repository_name,
|
||||||
|
title: value_string(value, &["title"]),
|
||||||
|
description: value_string(value, &["description"]),
|
||||||
|
author: value_string(value, &["author", "name"]),
|
||||||
|
state: state.to_string(),
|
||||||
|
source_branch: value_string(value, &["source_branch"]),
|
||||||
|
target_branch: value_string(value, &["target_branch"]),
|
||||||
|
web_url: value_string(value, &["web_url"]),
|
||||||
|
created_at: value_string(value, &["created_at"]),
|
||||||
|
updated_at: value_string(value, &["updated_at"]),
|
||||||
|
collaborators: value.get("reviewers").and_then(serde_json::Value::as_array).into_iter().flatten().map(|item| value_string(item, &["name"])).filter(|name| !name.is_empty()).collect(),
|
||||||
|
additions: None, deletions: None,
|
||||||
|
changed_files: value_string(value, &["changes_count"]).parse().ok(),
|
||||||
|
merge_status: match value_string(value, &["detailed_merge_status"]).as_str() { "mergeable" | "can_be_merged" => "mergeable", "conflict" | "conflicts" => "conflicts", "checking" | "unchecked" | "preparing" => "checking", "" => "", _ => "blocked" }.to_string(), comments: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_gitea_review(value: &serde_json::Value) -> Option<IntegrationReviewRequest> {
|
||||||
|
value.get("pull_request")?;
|
||||||
|
let number = value_u64(value, "number");
|
||||||
|
let repository_id = value
|
||||||
|
.get("repository")
|
||||||
|
.and_then(|repository| repository.get("id"))
|
||||||
|
.and_then(serde_json::Value::as_u64)
|
||||||
|
.map_or_else(String::new, |id| id.to_string());
|
||||||
|
let is_open = value_string(value, &["state"]) == "open";
|
||||||
|
let merged = value
|
||||||
|
.get("pull_request")
|
||||||
|
.and_then(|pull| pull.get("merged_at"))
|
||||||
|
.is_some_and(|item| !item.is_null());
|
||||||
|
Some(IntegrationReviewRequest {
|
||||||
|
id: format!("gitea:{repository_id}:{number}"),
|
||||||
|
number,
|
||||||
|
provider: "gitea".to_string(),
|
||||||
|
repository_id,
|
||||||
|
repository_name: value_string(value, &["repository", "full_name"]),
|
||||||
|
title: value_string(value, &["title"]),
|
||||||
|
description: value_string(value, &["body"]),
|
||||||
|
author: value_string(value, &["user", "login"]),
|
||||||
|
state: if merged {
|
||||||
|
"merged"
|
||||||
|
} else if is_open {
|
||||||
|
"open"
|
||||||
|
} else {
|
||||||
|
"closed"
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
source_branch: {
|
||||||
|
let branch = value_string(value, &["pull_request", "head", "ref"]);
|
||||||
|
if branch.is_empty() { value_string(value, &["pull_request", "head", "label"]) } else { branch }
|
||||||
|
},
|
||||||
|
target_branch: {
|
||||||
|
let branch = value_string(value, &["pull_request", "base", "ref"]);
|
||||||
|
if branch.is_empty() { value_string(value, &["pull_request", "base", "label"]) } else { branch }
|
||||||
|
},
|
||||||
|
web_url: value_string(value, &["html_url"]),
|
||||||
|
created_at: value_string(value, &["created_at"]),
|
||||||
|
updated_at: value_string(value, &["updated_at"]),
|
||||||
|
collaborators: Vec::new(), additions: None, deletions: None, changed_files: None,
|
||||||
|
merge_status: value.get("pull_request").and_then(|pull| pull.get("mergeable")).and_then(serde_json::Value::as_bool).map(|mergeable| if mergeable { "mergeable" } else { "conflicts" }).unwrap_or_default().to_string(), comments: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_azure_review(
|
||||||
|
value: &serde_json::Value,
|
||||||
|
repository: &IntegrationRepository,
|
||||||
|
) -> IntegrationReviewRequest {
|
||||||
|
let number = value_u64(value, "pullRequestId");
|
||||||
|
let raw_state = value_string(value, &["status"]);
|
||||||
|
let draft = value.get("isDraft").and_then(serde_json::Value::as_bool) == Some(true);
|
||||||
|
let state = match raw_state.as_str() {
|
||||||
|
"active" if draft => "draft",
|
||||||
|
"active" => "open",
|
||||||
|
"completed" => "merged",
|
||||||
|
_ => "closed",
|
||||||
|
};
|
||||||
|
let trim_branch = |branch: String| {
|
||||||
|
branch
|
||||||
|
.strip_prefix("refs/heads/")
|
||||||
|
.unwrap_or(&branch)
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
IntegrationReviewRequest {
|
||||||
|
id: format!("azure-devops:{}:{number}", repository.id),
|
||||||
|
number,
|
||||||
|
provider: "azure-devops".to_string(),
|
||||||
|
repository_id: repository.id.clone(),
|
||||||
|
repository_name: repository.full_name.clone(),
|
||||||
|
title: value_string(value, &["title"]),
|
||||||
|
description: value_string(value, &["description"]),
|
||||||
|
author: value_string(value, &["createdBy", "displayName"]),
|
||||||
|
state: state.to_string(),
|
||||||
|
source_branch: trim_branch(value_string(value, &["sourceRefName"])),
|
||||||
|
target_branch: trim_branch(value_string(value, &["targetRefName"])),
|
||||||
|
web_url: format!(
|
||||||
|
"{}/pullrequest/{number}",
|
||||||
|
repository.web_url.trim_end_matches('/')
|
||||||
|
),
|
||||||
|
created_at: value_string(value, &["creationDate"]),
|
||||||
|
updated_at: value_string(value, &["closedDate"]),
|
||||||
|
collaborators: value.get("reviewers").and_then(serde_json::Value::as_array).into_iter().flatten().map(|item| value_string(item, &["displayName"])).filter(|name| !name.is_empty()).collect(),
|
||||||
|
additions: None, deletions: None, changed_files: None,
|
||||||
|
merge_status: match value_string(value, &["mergeStatus"]).as_str() { "succeeded" => "mergeable", "conflicts" => "conflicts", "queued" | "notSet" => "checking", "" => "", _ => "blocked" }.to_string(), comments: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn github_reviews(
|
||||||
|
client: &Client,
|
||||||
|
base_url: &str,
|
||||||
|
token: &str,
|
||||||
|
state: &str,
|
||||||
|
) -> Result<Vec<IntegrationReviewRequest>, String> {
|
||||||
|
let query = match state {
|
||||||
|
"open" => "is:pr is:open",
|
||||||
|
"merged" => "is:pr is:merged",
|
||||||
|
"closed" => "is:pr is:closed is:unmerged",
|
||||||
|
_ => return Err("Unsupported review state.".to_string()),
|
||||||
|
};
|
||||||
|
let response = client
|
||||||
|
.get(format!("{}/search/issues", github_api_base_url(base_url)?))
|
||||||
|
.header(USER_AGENT, "Gitty")
|
||||||
|
.header(ACCEPT, "application/vnd.github+json")
|
||||||
|
.header("Authorization", format!("Bearer {token}"))
|
||||||
|
.header("X-GitHub-Api-Version", "2026-03-10")
|
||||||
|
.query(&[
|
||||||
|
("q", query),
|
||||||
|
("sort", "updated"),
|
||||||
|
("order", "desc"),
|
||||||
|
("per_page", "100"),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.map_err(|err| format!("Could not reach GitHub: {err}"))?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(response_error(response, "GitHub"));
|
||||||
|
}
|
||||||
|
let payload = response
|
||||||
|
.json::<serde_json::Value>()
|
||||||
|
.map_err(|err| format!("GitHub returned an unreadable pull request list: {err}"))?;
|
||||||
|
Ok(payload
|
||||||
|
.get("items")
|
||||||
|
.and_then(serde_json::Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(parse_github_review)
|
||||||
|
.map(|mut review| {
|
||||||
|
if state != "open" {
|
||||||
|
review.state = state.to_string();
|
||||||
|
}
|
||||||
|
review
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gitlab_reviews(
|
||||||
|
client: &Client,
|
||||||
|
base_url: &str,
|
||||||
|
token: &str,
|
||||||
|
provider: &str,
|
||||||
|
state: &str,
|
||||||
|
) -> Result<Vec<IntegrationReviewRequest>, String> {
|
||||||
|
let api_state = match state {
|
||||||
|
"open" => "opened",
|
||||||
|
"merged" => "merged",
|
||||||
|
"closed" => "closed",
|
||||||
|
_ => return Err("Unsupported review state.".to_string()),
|
||||||
|
};
|
||||||
|
let response = client
|
||||||
|
.get(format!("{base_url}/api/v4/merge_requests"))
|
||||||
|
.header(USER_AGENT, "Gitty")
|
||||||
|
.header(ACCEPT, "application/json")
|
||||||
|
.header("PRIVATE-TOKEN", token)
|
||||||
|
.query(&[
|
||||||
|
("scope", "all"),
|
||||||
|
("state", api_state),
|
||||||
|
("order_by", "updated_at"),
|
||||||
|
("sort", "desc"),
|
||||||
|
("per_page", "100"),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.map_err(|err| format!("Could not reach GitLab: {err}"))?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(response_error(response, "GitLab"));
|
||||||
|
}
|
||||||
|
Ok(response
|
||||||
|
.json::<Vec<serde_json::Value>>()
|
||||||
|
.map_err(|err| format!("GitLab returned an unreadable merge request list: {err}"))?
|
||||||
|
.iter()
|
||||||
|
.map(|value| parse_gitlab_review(value, provider))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gitea_reviews(
|
||||||
|
client: &Client,
|
||||||
|
base_url: &str,
|
||||||
|
token: &str,
|
||||||
|
state: &str,
|
||||||
|
) -> Result<Vec<IntegrationReviewRequest>, String> {
|
||||||
|
let api_state = if state == "open" { "open" } else { "closed" };
|
||||||
|
let response = client
|
||||||
|
.get(format!("{base_url}/api/v1/repos/issues/search"))
|
||||||
|
.header(USER_AGENT, "Gitty")
|
||||||
|
.header(ACCEPT, "application/json")
|
||||||
|
.header("Authorization", format!("token {token}"))
|
||||||
|
.query(&[("type", "pulls"), ("state", api_state), ("limit", "100")])
|
||||||
|
.send()
|
||||||
|
.map_err(|err| format!("Could not reach Gitea: {err}"))?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(response_error(response, "Gitea"));
|
||||||
|
}
|
||||||
|
Ok(response
|
||||||
|
.json::<Vec<serde_json::Value>>()
|
||||||
|
.map_err(|err| format!("Gitea returned an unreadable pull request list: {err}"))?
|
||||||
|
.iter()
|
||||||
|
.filter_map(parse_gitea_review)
|
||||||
|
.filter(|review| state == "open" || review.state == state)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn azure_project_reviews(
|
||||||
|
client: &Client,
|
||||||
|
base_url: &str,
|
||||||
|
username: &str,
|
||||||
|
token: &str,
|
||||||
|
api_state: &str,
|
||||||
|
project: &str,
|
||||||
|
repositories: &[IntegrationRepository],
|
||||||
|
) -> Result<Vec<IntegrationReviewRequest>, String> {
|
||||||
|
let mut endpoint =
|
||||||
|
reqwest::Url::parse(base_url).map_err(|err| format!("Invalid Azure DevOps URL: {err}"))?;
|
||||||
|
endpoint
|
||||||
|
.path_segments_mut()
|
||||||
|
.map_err(|_| "Invalid Azure DevOps URL.".to_string())?
|
||||||
|
.push(project)
|
||||||
|
.push("_apis")
|
||||||
|
.push("git")
|
||||||
|
.push("pullrequests");
|
||||||
|
let response = client
|
||||||
|
.get(endpoint)
|
||||||
|
.timeout(Duration::from_secs(15))
|
||||||
|
.header(USER_AGENT, "Gitty")
|
||||||
|
.header(ACCEPT, "application/json")
|
||||||
|
.basic_auth(username, Some(token))
|
||||||
|
.query(&[
|
||||||
|
("searchCriteria.status", api_state),
|
||||||
|
("$top", "100"),
|
||||||
|
("api-version", "7.1"),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.map_err(|err| format!("Could not reach Azure DevOps project {project}: {err}"))?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(response_error(response, "Azure DevOps"));
|
||||||
|
}
|
||||||
|
let payload = response
|
||||||
|
.json::<serde_json::Value>()
|
||||||
|
.map_err(|err| format!("Azure DevOps returned an unreadable pull request list: {err}"))?;
|
||||||
|
Ok(payload
|
||||||
|
.get("value")
|
||||||
|
.and_then(serde_json::Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|value| {
|
||||||
|
let repository_id = value_string(value, &["repository", "id"]);
|
||||||
|
repositories
|
||||||
|
.iter()
|
||||||
|
.find(|repository| repository.id == repository_id)
|
||||||
|
.map(|repository| parse_azure_review(value, repository))
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn azure_reviews(
|
||||||
|
client: &Client,
|
||||||
|
base_url: &str,
|
||||||
|
username: &str,
|
||||||
|
token: &str,
|
||||||
|
state: &str,
|
||||||
|
) -> Result<Vec<IntegrationReviewRequest>, String> {
|
||||||
|
let api_state = match state {
|
||||||
|
"open" => "active",
|
||||||
|
"merged" => "completed",
|
||||||
|
"closed" => "abandoned",
|
||||||
|
_ => return Err("Unsupported review state.".to_string()),
|
||||||
|
};
|
||||||
|
let repositories = azure_repositories(client, base_url, username, token)?;
|
||||||
|
let projects: Vec<_> = repositories
|
||||||
|
.iter()
|
||||||
|
.filter_map(|repository| {
|
||||||
|
repository
|
||||||
|
.full_name
|
||||||
|
.split_once('/')
|
||||||
|
.map(|(project, _)| project)
|
||||||
|
})
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
if projects.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let chunk_size = projects.len().div_ceil(AZURE_PROJECT_WORKERS);
|
||||||
|
let results = std::thread::scope(|scope| {
|
||||||
|
let handles = projects
|
||||||
|
.chunks(chunk_size)
|
||||||
|
.map(|project_chunk| {
|
||||||
|
let repositories = &repositories;
|
||||||
|
scope.spawn(move || {
|
||||||
|
project_chunk
|
||||||
|
.iter()
|
||||||
|
.map(|project| {
|
||||||
|
azure_project_reviews(
|
||||||
|
client,
|
||||||
|
base_url,
|
||||||
|
username,
|
||||||
|
token,
|
||||||
|
api_state,
|
||||||
|
project,
|
||||||
|
repositories,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut results = Vec::with_capacity(projects.len());
|
||||||
|
for handle in handles {
|
||||||
|
match handle.join() {
|
||||||
|
Ok(worker_results) => results.extend(worker_results),
|
||||||
|
Err(_) => results.push(Err("Azure DevOps project worker failed.".to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results
|
||||||
|
});
|
||||||
|
let mut reviews = Vec::new();
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
let mut successful_projects = 0usize;
|
||||||
|
for result in results {
|
||||||
|
match result {
|
||||||
|
Ok(project_reviews) => {
|
||||||
|
successful_projects += 1;
|
||||||
|
reviews.extend(project_reviews);
|
||||||
|
}
|
||||||
|
Err(error) => errors.push(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if successful_projects == 0 && !errors.is_empty() {
|
||||||
|
let omitted = errors.len().saturating_sub(3);
|
||||||
|
let mut detail = errors.into_iter().take(3).collect::<Vec<_>>().join("; ");
|
||||||
|
if omitted > 0 {
|
||||||
|
detail.push_str(&format!("; and {omitted} more project request(s) failed."));
|
||||||
|
}
|
||||||
|
return Err(detail);
|
||||||
|
}
|
||||||
|
reviews.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
|
||||||
|
Ok(reviews)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_action_response(response: Response, provider: &str, action: &str) -> Result<(), String> {
|
||||||
|
if response.status().is_success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("Could not {action} review request: {}", response_error(response, provider)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn github_review_action(
|
||||||
|
client: &Client,
|
||||||
|
base_url: &str,
|
||||||
|
token: &str,
|
||||||
|
repository_name: &str,
|
||||||
|
number: u64,
|
||||||
|
action: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if repository_name.split('/').count() != 2 {
|
||||||
|
return Err("GitHub returned an invalid repository name.".to_string());
|
||||||
|
}
|
||||||
|
let endpoint = format!("{}/repos/{repository_name}/pulls/{number}", github_api_base_url(base_url)?);
|
||||||
|
let request = match action {
|
||||||
|
"merge" => client.put(format!("{endpoint}/merge")).json(&serde_json::json!({})),
|
||||||
|
"approve" => client.post(format!("{endpoint}/reviews")).json(&serde_json::json!({ "event": "APPROVE" })),
|
||||||
|
"close" => client.patch(&endpoint).json(&serde_json::json!({ "state": "closed" })),
|
||||||
|
"reopen" => client.patch(&endpoint).json(&serde_json::json!({ "state": "open" })),
|
||||||
|
_ => return Err("Unsupported review action.".to_string()),
|
||||||
|
};
|
||||||
|
let response = request
|
||||||
|
.header(USER_AGENT, "Gitty")
|
||||||
|
.header(ACCEPT, "application/vnd.github+json")
|
||||||
|
.header("Authorization", format!("Bearer {token}"))
|
||||||
|
.header("X-GitHub-Api-Version", "2026-03-10")
|
||||||
|
.send()
|
||||||
|
.map_err(|err| format!("Could not reach GitHub: {err}"))?;
|
||||||
|
ensure_action_response(response, "GitHub", action)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gitlab_review_action(
|
||||||
|
client: &Client,
|
||||||
|
base_url: &str,
|
||||||
|
token: &str,
|
||||||
|
repository_id: &str,
|
||||||
|
number: u64,
|
||||||
|
action: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if repository_id.trim().is_empty() {
|
||||||
|
return Err("GitLab returned an invalid project identifier.".to_string());
|
||||||
|
}
|
||||||
|
let endpoint = format!("{base_url}/api/v4/projects/{repository_id}/merge_requests/{number}");
|
||||||
|
let request = match action {
|
||||||
|
"merge" => client.put(format!("{endpoint}/merge")),
|
||||||
|
"approve" => client.post(format!("{endpoint}/approve")),
|
||||||
|
"close" => client.put(&endpoint).query(&[("state_event", "close")]),
|
||||||
|
"reopen" => client.put(&endpoint).query(&[("state_event", "reopen")]),
|
||||||
|
_ => return Err("Unsupported review action.".to_string()),
|
||||||
|
};
|
||||||
|
let response = request
|
||||||
|
.header(USER_AGENT, "Gitty")
|
||||||
|
.header(ACCEPT, "application/json")
|
||||||
|
.header("PRIVATE-TOKEN", token)
|
||||||
|
.send()
|
||||||
|
.map_err(|err| format!("Could not reach GitLab: {err}"))?;
|
||||||
|
ensure_action_response(response, "GitLab", action)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gitea_review_action(
|
||||||
|
client: &Client,
|
||||||
|
base_url: &str,
|
||||||
|
token: &str,
|
||||||
|
repository_name: &str,
|
||||||
|
number: u64,
|
||||||
|
action: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if repository_name.split('/').count() != 2 {
|
||||||
|
return Err("Gitea returned an invalid repository name.".to_string());
|
||||||
|
}
|
||||||
|
let endpoint = format!("{base_url}/api/v1/repos/{repository_name}/pulls/{number}");
|
||||||
|
let request = match action {
|
||||||
|
"merge" => client.post(format!("{endpoint}/merge")).json(&serde_json::json!({ "Do": "merge" })),
|
||||||
|
"approve" => client.post(format!("{endpoint}/reviews")).json(&serde_json::json!({ "event": "APPROVED", "body": "" })),
|
||||||
|
"close" => client.patch(&endpoint).json(&serde_json::json!({ "state": "closed" })),
|
||||||
|
"reopen" => client.patch(&endpoint).json(&serde_json::json!({ "state": "open" })),
|
||||||
|
_ => return Err("Unsupported review action.".to_string()),
|
||||||
|
};
|
||||||
|
let response = request
|
||||||
|
.header(USER_AGENT, "Gitty")
|
||||||
|
.header(ACCEPT, "application/json")
|
||||||
|
.header("Authorization", format!("token {token}"))
|
||||||
|
.send()
|
||||||
|
.map_err(|err| format!("Could not reach Gitea: {err}"))?;
|
||||||
|
ensure_action_response(response, "Gitea", action)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn azure_review_endpoint(
|
||||||
|
base_url: &str,
|
||||||
|
repository_name: &str,
|
||||||
|
repository_id: &str,
|
||||||
|
number: u64,
|
||||||
|
) -> Result<reqwest::Url, String> {
|
||||||
|
let project = repository_name
|
||||||
|
.split_once('/')
|
||||||
|
.map(|(project, _)| project)
|
||||||
|
.filter(|project| !project.is_empty())
|
||||||
|
.ok_or_else(|| "Azure DevOps returned an invalid repository name.".to_string())?;
|
||||||
|
let mut endpoint = reqwest::Url::parse(base_url)
|
||||||
|
.map_err(|err| format!("Invalid Azure DevOps URL: {err}"))?;
|
||||||
|
endpoint
|
||||||
|
.path_segments_mut()
|
||||||
|
.map_err(|_| "Invalid Azure DevOps URL.".to_string())?
|
||||||
|
.push(project)
|
||||||
|
.push("_apis")
|
||||||
|
.push("git")
|
||||||
|
.push("repositories")
|
||||||
|
.push(repository_id)
|
||||||
|
.push("pullrequests")
|
||||||
|
.push(&number.to_string());
|
||||||
|
endpoint.query_pairs_mut().append_pair("api-version", "7.1");
|
||||||
|
Ok(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn azure_review_action(
|
||||||
|
client: &Client,
|
||||||
|
base_url: &str,
|
||||||
|
username: &str,
|
||||||
|
token: &str,
|
||||||
|
repository_name: &str,
|
||||||
|
repository_id: &str,
|
||||||
|
number: u64,
|
||||||
|
action: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let endpoint = azure_review_endpoint(base_url, repository_name, repository_id, number)?;
|
||||||
|
let auth_user = if username.trim().is_empty() { "gitty" } else { username };
|
||||||
|
if action == "approve" {
|
||||||
|
let mut identity_endpoint = reqwest::Url::parse(base_url)
|
||||||
|
.map_err(|err| format!("Invalid Azure DevOps URL: {err}"))?;
|
||||||
|
identity_endpoint.path_segments_mut().map_err(|_| "Invalid Azure DevOps URL.".to_string())?.push("_apis").push("connectionData");
|
||||||
|
identity_endpoint.query_pairs_mut().append_pair("connectOptions", "1").append_pair("lastChangeId", "-1").append_pair("lastChangeId64", "-1");
|
||||||
|
let identity = client.get(identity_endpoint).header(USER_AGENT, "Gitty").basic_auth(auth_user, Some(token)).send()
|
||||||
|
.map_err(|err| format!("Could not reach Azure DevOps: {err}"))?;
|
||||||
|
if !identity.status().is_success() { return Err(response_error(identity, "Azure DevOps")); }
|
||||||
|
let payload = identity.json::<serde_json::Value>().map_err(|err| format!("Azure DevOps returned an unreadable identity: {err}"))?;
|
||||||
|
let reviewer_id = value_string(&payload, &["authenticatedUser", "id"]);
|
||||||
|
if reviewer_id.is_empty() { return Err("Azure DevOps did not return the authenticated user.".to_string()); }
|
||||||
|
let reviewer_endpoint = format!("{}/reviewers/{reviewer_id}?api-version=7.1", endpoint.as_str().split('?').next().unwrap_or_default());
|
||||||
|
let response = client.put(reviewer_endpoint).header(USER_AGENT, "Gitty").basic_auth(auth_user, Some(token)).json(&serde_json::json!({ "vote": 10 })).send()
|
||||||
|
.map_err(|err| format!("Could not reach Azure DevOps: {err}"))?;
|
||||||
|
return ensure_action_response(response, "Azure DevOps", action);
|
||||||
|
}
|
||||||
|
let body = match action {
|
||||||
|
"close" => serde_json::json!({ "status": "abandoned" }),
|
||||||
|
"reopen" => serde_json::json!({ "status": "active" }),
|
||||||
|
"merge" => {
|
||||||
|
let current = client.get(endpoint.clone()).header(USER_AGENT, "Gitty").basic_auth(auth_user, Some(token)).send()
|
||||||
|
.map_err(|err| format!("Could not reach Azure DevOps: {err}"))?;
|
||||||
|
if !current.status().is_success() { return Err(response_error(current, "Azure DevOps")); }
|
||||||
|
let payload = current.json::<serde_json::Value>().map_err(|err| format!("Azure DevOps returned an unreadable pull request: {err}"))?;
|
||||||
|
let commit_id = value_string(&payload, &["lastMergeSourceCommit", "commitId"]);
|
||||||
|
if commit_id.is_empty() { return Err("Azure DevOps did not return the current source commit.".to_string()); }
|
||||||
|
serde_json::json!({ "status": "completed", "lastMergeSourceCommit": { "commitId": commit_id } })
|
||||||
|
}
|
||||||
|
_ => return Err("Unsupported review action.".to_string()),
|
||||||
|
};
|
||||||
|
let response = client.patch(endpoint).header(USER_AGENT, "Gitty").basic_auth(auth_user, Some(token)).json(&body).send()
|
||||||
|
.map_err(|err| format!("Could not reach Azure DevOps: {err}"))?;
|
||||||
|
ensure_action_response(response, "Azure DevOps", action)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn review_names(value: &serde_json::Value, key: &str, name_key: &str) -> Vec<String> {
|
||||||
|
value.get(key).and_then(serde_json::Value::as_array).into_iter().flatten()
|
||||||
|
.map(|item| value_string(item, &[name_key]))
|
||||||
|
.filter(|name| !name.is_empty()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_id(value: &serde_json::Value) -> String {
|
||||||
|
value.get("id").map_or_else(String::new, |id| id.as_str().map(str::to_string).or_else(|| id.as_u64().map(|id| id.to_string())).unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn review_comments(client: &Client, base_url: &str, username: &str, token: &str, review: &IntegrationReviewRequest) -> Result<Vec<IntegrationReviewComment>, String> {
|
||||||
|
let (payload, provider) = match review.provider.as_str() {
|
||||||
|
"github" => {
|
||||||
|
let url = format!("{}/repos/{}/issues/{}/comments", github_api_base_url(base_url)?, review.repository_name, review.number);
|
||||||
|
let response = client.get(url).header(USER_AGENT,"Gitty").header(ACCEPT,"application/vnd.github+json").header("Authorization",format!("Bearer {token}")).header("X-GitHub-Api-Version","2026-03-10").query(&[("per_page","100")]).send().map_err(|err|format!("Could not load GitHub comments: {err}"))?;
|
||||||
|
if !response.status().is_success(){return Err(response_error(response,"GitHub"));} (response.json::<serde_json::Value>().map_err(|err|format!("GitHub returned unreadable comments: {err}"))?,"github")
|
||||||
|
}
|
||||||
|
"gitlab" | "gitlab-self-hosted" => {
|
||||||
|
let url=format!("{base_url}/api/v4/projects/{}/merge_requests/{}/notes",review.repository_id,review.number);
|
||||||
|
let response=client.get(url).header(USER_AGENT,"Gitty").header("PRIVATE-TOKEN",token).query(&[("per_page","100"),("sort","asc")]).send().map_err(|err|format!("Could not load GitLab comments: {err}"))?;
|
||||||
|
if !response.status().is_success(){return Err(response_error(response,"GitLab"));} (response.json::<serde_json::Value>().map_err(|err|format!("GitLab returned unreadable comments: {err}"))?,"gitlab")
|
||||||
|
}
|
||||||
|
"gitea" => {
|
||||||
|
let url=format!("{base_url}/api/v1/repos/{}/issues/{}/comments",review.repository_name,review.number);
|
||||||
|
let response=client.get(url).header(USER_AGENT,"Gitty").header("Authorization",format!("token {token}")).query(&[("limit","100")]).send().map_err(|err|format!("Could not load Gitea comments: {err}"))?;
|
||||||
|
if !response.status().is_success(){return Err(response_error(response,"Gitea"));} (response.json::<serde_json::Value>().map_err(|err|format!("Gitea returned unreadable comments: {err}"))?,"gitea")
|
||||||
|
}
|
||||||
|
"azure-devops" => {
|
||||||
|
let mut url=azure_review_endpoint(base_url,&review.repository_name,&review.repository_id,review.number)?; url.set_query(None); url.path_segments_mut().map_err(|_|"Invalid Azure DevOps URL.".to_string())?.push("threads"); url.query_pairs_mut().append_pair("api-version","7.1");
|
||||||
|
let auth=if username.trim().is_empty(){"gitty"}else{username}; let response=client.get(url).header(USER_AGENT,"Gitty").basic_auth(auth,Some(token)).send().map_err(|err|format!("Could not load Azure DevOps comments: {err}"))?;
|
||||||
|
if !response.status().is_success(){return Err(response_error(response,"Azure DevOps"));} (response.json::<serde_json::Value>().map_err(|err|format!("Azure DevOps returned unreadable comments: {err}"))?,"azure-devops")
|
||||||
|
}
|
||||||
|
_=>return Err("Unsupported integration provider.".to_string()),
|
||||||
|
};
|
||||||
|
let mut comments=Vec::new();
|
||||||
|
if provider=="azure-devops" {
|
||||||
|
for thread in payload.get("value").and_then(serde_json::Value::as_array).into_iter().flatten() { for item in thread.get("comments").and_then(serde_json::Value::as_array).into_iter().flatten() { if item.get("isDeleted").and_then(serde_json::Value::as_bool)==Some(true){continue;} comments.push(IntegrationReviewComment{id:format!("{}:{}",json_id(thread),json_id(item)),author:value_string(item,&["author","displayName"]),body:value_string(item,&["content"]),created_at:value_string(item,&["publishedDate"])}); } }
|
||||||
|
} else if let Some(items)=payload.as_array() { for item in items { if provider=="gitlab" && item.get("system").and_then(serde_json::Value::as_bool)==Some(true){continue;} comments.push(IntegrationReviewComment{id:json_id(item),author:if provider=="gitlab"{value_string(item,&["author","name"])}else{value_string(item,&["user","login"])},body:value_string(item,&["body"]),created_at:value_string(item,&["created_at"])}); } }
|
||||||
|
Ok(comments)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post_review_comment(client:&Client,base_url:&str,username:&str,token:&str,review:&IntegrationReviewRequest,body:&str)->Result<(),String>{
|
||||||
|
let response=match review.provider.as_str(){
|
||||||
|
"github"=>client.post(format!("{}/repos/{}/issues/{}/comments",github_api_base_url(base_url)?,review.repository_name,review.number)).header(USER_AGENT,"Gitty").header(ACCEPT,"application/vnd.github+json").header("Authorization",format!("Bearer {token}")).header("X-GitHub-Api-Version","2026-03-10").json(&serde_json::json!({"body":body})).send().map_err(|err|format!("Could not reach GitHub: {err}"))?,
|
||||||
|
"gitlab"|"gitlab-self-hosted"=>client.post(format!("{base_url}/api/v4/projects/{}/merge_requests/{}/notes",review.repository_id,review.number)).header(USER_AGENT,"Gitty").header("PRIVATE-TOKEN",token).json(&serde_json::json!({"body":body})).send().map_err(|err|format!("Could not reach GitLab: {err}"))?,
|
||||||
|
"gitea"=>client.post(format!("{base_url}/api/v1/repos/{}/issues/{}/comments",review.repository_name,review.number)).header(USER_AGENT,"Gitty").header("Authorization",format!("token {token}")).json(&serde_json::json!({"body":body})).send().map_err(|err|format!("Could not reach Gitea: {err}"))?,
|
||||||
|
"azure-devops"=>{let mut url=azure_review_endpoint(base_url,&review.repository_name,&review.repository_id,review.number)?;url.set_query(None);url.path_segments_mut().map_err(|_|"Invalid Azure DevOps URL.".to_string())?.push("threads");url.query_pairs_mut().append_pair("api-version","7.1");let auth=if username.trim().is_empty(){"gitty"}else{username};client.post(url).header(USER_AGENT,"Gitty").basic_auth(auth,Some(token)).json(&serde_json::json!({"comments":[{"parentCommentId":0,"content":body,"commentType":1}],"status":1})).send().map_err(|err|format!("Could not reach Azure DevOps: {err}"))?},
|
||||||
|
_=>return Err("Unsupported integration provider.".to_string())};
|
||||||
|
ensure_action_response(response, provider_label_for_error(&review.provider), "add comment")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_label_for_error(provider:&str)->&str{match provider{"github"=>"GitHub","gitlab"|"gitlab-self-hosted"=>"GitLab","gitea"=>"Gitea","azure-devops"=>"Azure DevOps",_=>"Integration"}}
|
||||||
|
|
||||||
|
fn load_review_details(
|
||||||
|
client: &Client,
|
||||||
|
base_url: &str,
|
||||||
|
username: &str,
|
||||||
|
token: &str,
|
||||||
|
mut review: IntegrationReviewRequest,
|
||||||
|
) -> Result<IntegrationReviewRequest, String> {
|
||||||
|
let payload = match review.provider.as_str() {
|
||||||
|
"github" => {
|
||||||
|
let endpoint = format!("{}/repos/{}/pulls/{}", github_api_base_url(base_url)?, review.repository_name, review.number);
|
||||||
|
let response = client.get(endpoint).header(USER_AGENT, "Gitty").header(ACCEPT, "application/vnd.github+json").header("Authorization", format!("Bearer {token}")).header("X-GitHub-Api-Version", "2026-03-10").send().map_err(|err| format!("Could not reach GitHub: {err}"))?;
|
||||||
|
if !response.status().is_success() { return Err(response_error(response, "GitHub")); }
|
||||||
|
response.json::<serde_json::Value>().map_err(|err| format!("GitHub returned unreadable pull request details: {err}"))?
|
||||||
|
}
|
||||||
|
"gitlab" | "gitlab-self-hosted" => {
|
||||||
|
let endpoint = format!("{base_url}/api/v4/projects/{}/merge_requests/{}", review.repository_id, review.number);
|
||||||
|
let response = client.get(&endpoint).header(USER_AGENT, "Gitty").header("PRIVATE-TOKEN", token).send().map_err(|err| format!("Could not reach GitLab: {err}"))?;
|
||||||
|
if !response.status().is_success() { return Err(response_error(response, "GitLab")); }
|
||||||
|
let mut payload = response.json::<serde_json::Value>().map_err(|err| format!("GitLab returned unreadable merge request details: {err}"))?;
|
||||||
|
let diffs = client.get(format!("{endpoint}/diffs")).header(USER_AGENT, "Gitty").header("PRIVATE-TOKEN", token).query(&[("per_page", "100")]).send().map_err(|err| format!("Could not load GitLab diffs: {err}"))?;
|
||||||
|
if diffs.status().is_success() {
|
||||||
|
if let Ok(items) = diffs.json::<Vec<serde_json::Value>>() { payload["gitty_diffs"] = serde_json::Value::Array(items); }
|
||||||
|
}
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
"gitea" => {
|
||||||
|
let endpoint = format!("{base_url}/api/v1/repos/{}/pulls/{}", review.repository_name, review.number);
|
||||||
|
let response = client.get(endpoint).header(USER_AGENT, "Gitty").header("Authorization", format!("token {token}")).send().map_err(|err| format!("Could not reach Gitea: {err}"))?;
|
||||||
|
if !response.status().is_success() { return Err(response_error(response, "Gitea")); }
|
||||||
|
response.json::<serde_json::Value>().map_err(|err| format!("Gitea returned unreadable pull request details: {err}"))?
|
||||||
|
}
|
||||||
|
"azure-devops" => {
|
||||||
|
let endpoint = azure_review_endpoint(base_url, &review.repository_name, &review.repository_id, review.number)?;
|
||||||
|
let auth_user = if username.trim().is_empty() { "gitty" } else { username };
|
||||||
|
let response = client.get(endpoint.clone()).header(USER_AGENT, "Gitty").basic_auth(auth_user, Some(token)).send().map_err(|err| format!("Could not reach Azure DevOps: {err}"))?;
|
||||||
|
if !response.status().is_success() { return Err(response_error(response, "Azure DevOps")); }
|
||||||
|
let mut payload = response.json::<serde_json::Value>().map_err(|err| format!("Azure DevOps returned unreadable pull request details: {err}"))?;
|
||||||
|
let mut iterations_url = endpoint; iterations_url.set_query(None); iterations_url.path_segments_mut().map_err(|_| "Invalid Azure DevOps URL.".to_string())?.push("iterations"); iterations_url.query_pairs_mut().append_pair("api-version", "7.1");
|
||||||
|
if let Ok(iterations) = client.get(iterations_url.clone()).header(USER_AGENT, "Gitty").basic_auth(auth_user, Some(token)).send() {
|
||||||
|
if iterations.status().is_success() {
|
||||||
|
if let Ok(value) = iterations.json::<serde_json::Value>() {
|
||||||
|
if let Some(id) = value.get("value").and_then(serde_json::Value::as_array).and_then(|items| items.last()).and_then(|item| item.get("id")).and_then(serde_json::Value::as_u64) {
|
||||||
|
iterations_url.set_query(None); iterations_url.path_segments_mut().map_err(|_| "Invalid Azure DevOps URL.".to_string())?.push(&id.to_string()).push("changes"); iterations_url.query_pairs_mut().append_pair("$top", "2000").append_pair("api-version", "7.1");
|
||||||
|
if let Ok(changes) = client.get(iterations_url).header(USER_AGENT, "Gitty").basic_auth(auth_user, Some(token)).send() { if changes.status().is_success() { if let Ok(value) = changes.json::<serde_json::Value>() { payload["gitty_changed_files"] = serde_json::json!(value.get("changeEntries").and_then(serde_json::Value::as_array).map_or(0, Vec::len)); } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
_ => return Err("Unsupported integration provider.".to_string()),
|
||||||
|
};
|
||||||
|
match review.provider.as_str() {
|
||||||
|
"github" => { review.additions = payload.get("additions").and_then(serde_json::Value::as_u64); review.deletions = payload.get("deletions").and_then(serde_json::Value::as_u64); review.changed_files = payload.get("changed_files").and_then(serde_json::Value::as_u64); review.collaborators = review_names(&payload, "requested_reviewers", "login"); review.source_branch = value_string(&payload, &["head", "ref"]); review.target_branch = value_string(&payload, &["base", "ref"]); review.merge_status = match payload.get("mergeable").and_then(serde_json::Value::as_bool) { Some(true) => "mergeable", Some(false) => "conflicts", None => "checking" }.to_string(); }
|
||||||
|
"gitlab" | "gitlab-self-hosted" => { review.collaborators = review_names(&payload, "reviewers", "name"); review.changed_files = value_string(&payload, &["changes_count"]).parse().ok(); review.merge_status = match value_string(&payload, &["detailed_merge_status"]).as_str() { "mergeable" | "can_be_merged" => "mergeable", "conflict" | "conflicts" => "conflicts", "checking" | "unchecked" | "preparing" => "checking", _ => "blocked" }.to_string(); if let Some(diffs) = payload.get("gitty_diffs").and_then(serde_json::Value::as_array) { let mut plus=0; let mut minus=0; for line in diffs.iter().filter_map(|item| item.get("diff").and_then(serde_json::Value::as_str)).flat_map(str::lines) { if line.starts_with('+') && !line.starts_with("+++") { plus+=1; } else if line.starts_with('-') && !line.starts_with("---") { minus+=1; } } review.additions=Some(plus); review.deletions=Some(minus); review.changed_files=Some(diffs.len() as u64); } }
|
||||||
|
"gitea" => { review.additions=payload.get("additions").and_then(serde_json::Value::as_u64); review.deletions=payload.get("deletions").and_then(serde_json::Value::as_u64); review.changed_files=payload.get("changed_files").and_then(serde_json::Value::as_u64); review.collaborators=review_names(&payload,"requested_reviewers","login"); let source=value_string(&payload,&["head","ref"]); if !source.is_empty(){review.source_branch=source;} let target=value_string(&payload,&["base","ref"]); if !target.is_empty(){review.target_branch=target;} review.merge_status=payload.get("mergeable").and_then(serde_json::Value::as_bool).map(|ok|if ok{"mergeable"}else{"conflicts"}).unwrap_or("checking").to_string(); }
|
||||||
|
"azure-devops" => { review.collaborators=review_names(&payload,"reviewers","displayName"); review.changed_files=payload.get("gitty_changed_files").and_then(serde_json::Value::as_u64); review.merge_status=match value_string(&payload,&["mergeStatus"]).as_str(){"succeeded"=>"mergeable","conflicts"=>"conflicts","queued"|"notSet"=>"checking",_=>"blocked"}.to_string(); }
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
review.comments = review_comments(client, base_url, username, token, &review).unwrap_or_default();
|
||||||
|
Ok(review)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_integration_review_requests(
|
||||||
|
provider: String,
|
||||||
|
base_url: String,
|
||||||
|
username: String,
|
||||||
|
token: String,
|
||||||
|
state: String,
|
||||||
|
) -> Result<Vec<IntegrationReviewRequest>, String> {
|
||||||
|
tokio::time::timeout(
|
||||||
|
REVIEW_REQUEST_TIMEOUT,
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
if token.trim().is_empty() {
|
||||||
|
return Err("No token is stored for this integration.".to_string());
|
||||||
|
}
|
||||||
|
let base_url = normalized_base_url(&base_url)?;
|
||||||
|
let client = client()?;
|
||||||
|
match provider.as_str() {
|
||||||
|
"github" => github_reviews(&client, &base_url, &token, &state),
|
||||||
|
"gitlab" | "gitlab-self-hosted" => {
|
||||||
|
gitlab_reviews(&client, &base_url, &token, &provider, &state)
|
||||||
|
}
|
||||||
|
"gitea" => gitea_reviews(&client, &base_url, &token, &state),
|
||||||
|
"azure-devops" => azure_reviews(
|
||||||
|
&client,
|
||||||
|
&base_url,
|
||||||
|
if username.trim().is_empty() {
|
||||||
|
"gitty"
|
||||||
|
} else {
|
||||||
|
&username
|
||||||
|
},
|
||||||
|
&token,
|
||||||
|
&state,
|
||||||
|
),
|
||||||
|
_ => Err("Unsupported integration provider.".to_string()),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "The integration API did not respond within 35 seconds.".to_string())?
|
||||||
|
.map_err(|err| format!("Could not load review requests: {err}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn run_integration_review_action(
|
||||||
|
provider: String,
|
||||||
|
base_url: String,
|
||||||
|
username: String,
|
||||||
|
token: String,
|
||||||
|
repository_id: String,
|
||||||
|
repository_name: String,
|
||||||
|
number: u64,
|
||||||
|
action: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
tokio::time::timeout(
|
||||||
|
REVIEW_REQUEST_TIMEOUT,
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
if token.trim().is_empty() { return Err("No token is stored for this integration.".to_string()); }
|
||||||
|
if !matches!(action.as_str(), "merge" | "approve" | "close" | "reopen") { return Err("Unsupported review action.".to_string()); }
|
||||||
|
let base_url = normalized_base_url(&base_url)?;
|
||||||
|
let client = client()?;
|
||||||
|
match provider.as_str() {
|
||||||
|
"github" => github_review_action(&client, &base_url, &token, &repository_name, number, &action),
|
||||||
|
"gitlab" | "gitlab-self-hosted" => gitlab_review_action(&client, &base_url, &token, &repository_id, number, &action),
|
||||||
|
"gitea" => gitea_review_action(&client, &base_url, &token, &repository_name, number, &action),
|
||||||
|
"azure-devops" => azure_review_action(&client, &base_url, &username, &token, &repository_name, &repository_id, number, &action),
|
||||||
|
_ => Err("Unsupported integration provider.".to_string()),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "The integration API did not respond within 35 seconds.".to_string())?
|
||||||
|
.map_err(|err| format!("Could not update review request: {err}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_integration_review_details(provider: String, base_url: String, username: String, token: String, request: IntegrationReviewRequest) -> Result<IntegrationReviewRequest, String> {
|
||||||
|
tokio::time::timeout(REVIEW_REQUEST_TIMEOUT, tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
if token.trim().is_empty() { return Err("No token is stored for this integration.".to_string()); }
|
||||||
|
let base_url = normalized_base_url(&base_url)?;
|
||||||
|
let client = client()?;
|
||||||
|
if provider != request.provider { return Err("Review provider does not match the selected integration.".to_string()); }
|
||||||
|
load_review_details(&client, &base_url, &username, &token, request)
|
||||||
|
})).await.map_err(|_| "The integration API did not respond within 35 seconds.".to_string())?
|
||||||
|
.map_err(|err| format!("Could not load review details: {err}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn add_integration_review_comment(provider:String,base_url:String,username:String,token:String,request:IntegrationReviewRequest,body:String)->Result<(),String>{
|
||||||
|
tokio::time::timeout(REVIEW_REQUEST_TIMEOUT,tauri::async_runtime::spawn_blocking(move||{
|
||||||
|
let body=body.trim(); if body.is_empty(){return Err("Comment cannot be empty.".to_string());} if body.chars().count()>100_000{return Err("Comment is too long.".to_string());}
|
||||||
|
if provider!=request.provider{return Err("Review provider does not match the selected integration.".to_string());}
|
||||||
|
let base_url=normalized_base_url(&base_url)?; let client=client()?; post_review_comment(&client,&base_url,&username,&token,&request,body)
|
||||||
|
})).await.map_err(|_|"The integration API did not respond within 35 seconds.".to_string())?.map_err(|err|format!("Could not add comment: {err}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_in_browser(url: String) -> Result<(), String> {
|
||||||
|
let normalized = normalized_base_url(&url)?;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let mut command = {
|
||||||
|
let mut command = std::process::Command::new("rundll32");
|
||||||
|
command.args(["url.dll,FileProtocolHandler", &normalized]);
|
||||||
|
command
|
||||||
|
};
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let mut command = {
|
||||||
|
let mut command = std::process::Command::new("open");
|
||||||
|
command.arg(&normalized);
|
||||||
|
command
|
||||||
|
};
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
let mut command = {
|
||||||
|
let mut command = std::process::Command::new("xdg-open");
|
||||||
|
command.arg(&normalized);
|
||||||
|
command
|
||||||
|
};
|
||||||
|
command
|
||||||
|
.spawn()
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|err| format!("Could not open the browser: {err}"))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_integration_repositories(
|
pub async fn list_integration_repositories(
|
||||||
provider: String,
|
provider: String,
|
||||||
@@ -454,4 +1344,47 @@ mod tests {
|
|||||||
"https://dev.azure.com/org/Platform/_git/web"
|
"https://dev.azure.com/org/Platform/_git/web"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_review_payloads_map_to_common_shape() {
|
||||||
|
let github: serde_json::Value = serde_json::from_str(r#"{
|
||||||
|
"number":42,"title":"Improve dashboard","body":"Details","state":"open",
|
||||||
|
"repository_url":"https://api.github.com/repos/team/app","html_url":"https://github.com/team/app/pull/42",
|
||||||
|
"user":{"login":"alex"},"pull_request":{"merged_at":null},"created_at":"2026-09-01","updated_at":"2026-09-02"
|
||||||
|
}"#).unwrap();
|
||||||
|
let review = parse_github_review(&github).unwrap();
|
||||||
|
assert_eq!(review.repository_name, "team/app");
|
||||||
|
assert_eq!(review.state, "open");
|
||||||
|
|
||||||
|
let gitlab: serde_json::Value = serde_json::from_str(r#"{
|
||||||
|
"iid":7,"project_id":9,"title":"Add bisect","state":"opened","draft":true,
|
||||||
|
"references":{"full":"team/app!7"},"author":{"name":"Sam"},
|
||||||
|
"source_branch":"feature","target_branch":"main","web_url":"https://gitlab.test/team/app/-/merge_requests/7"
|
||||||
|
}"#).unwrap();
|
||||||
|
let review = parse_gitlab_review(&gitlab, "gitlab-self-hosted");
|
||||||
|
assert_eq!(review.repository_name, "team/app");
|
||||||
|
assert_eq!(review.state, "draft");
|
||||||
|
|
||||||
|
let gitea: serde_json::Value = serde_json::from_str(r#"{
|
||||||
|
"number":3,"title":"Fix menu","state":"closed","repository":{"id":2,"full_name":"team/ui"},
|
||||||
|
"user":{"login":"dev"},"pull_request":{"merged_at":"2026-09-03"},"html_url":"https://gitea.test/team/ui/pulls/3"
|
||||||
|
}"#).unwrap();
|
||||||
|
let review = parse_gitea_review(&gitea).unwrap();
|
||||||
|
assert_eq!(review.state, "merged");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn azure_review_action_url_keeps_organization_and_encodes_project() {
|
||||||
|
let endpoint = azure_review_endpoint(
|
||||||
|
"https://dev.azure.com/example-org",
|
||||||
|
"Machine Kits/api",
|
||||||
|
"repo-id",
|
||||||
|
42,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
endpoint.as_str(),
|
||||||
|
"https://dev.azure.com/example-org/Machine%20Kits/_apis/git/repositories/repo-id/pullrequests/42?api-version=7.1"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-16
@@ -14,26 +14,29 @@ use git::{
|
|||||||
SearchCancellationState, add_remote, add_to_gitignore, add_worktree, amend_commit,
|
SearchCancellationState, add_remote, add_to_gitignore, add_worktree, amend_commit,
|
||||||
apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
|
apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
|
||||||
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
|
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
|
||||||
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status,
|
commit_ai_review, commit_ai_split, compare_commits, compare_file_to_head,
|
||||||
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag,
|
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
|
||||||
cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch,
|
delete_branch, delete_commit_note, delete_remote_branch, delete_remote_branches, delete_tag,
|
||||||
delete_remote_branches, delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes,
|
diff_file_against_working_tree, fetch, fetch_commit_notes, get_bisect_state, get_commit_note,
|
||||||
get_commit_note, get_file_blame, get_file_patch, get_remote_url, get_status, git_lfs_install,
|
get_file_blame, get_file_patch, get_remote_url, get_status, git_lfs_install, git_lfs_prune,
|
||||||
git_lfs_prune, git_lfs_pull, git_lfs_status, git_lfs_track, git_lfs_untrack, init_repository,
|
git_lfs_pull, git_lfs_status, git_lfs_track, git_lfs_untrack, init_repository,
|
||||||
last_commit_message, list_branches, list_commits, list_file_history,
|
last_commit_message, list_branches, list_commits, list_file_history,
|
||||||
list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
|
list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
|
||||||
list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, merge_branch,
|
list_stashes, list_tags, list_worktrees, lock_worktree, mark_bisect, merge_abort, merge_branch,
|
||||||
merge_continue, move_worktree, open_repo_in_explorer, open_repository, open_repository_bundle,
|
merge_continue, move_worktree, open_repo_in_explorer, open_repository, open_repository_bundle,
|
||||||
open_repository_file, prune_worktrees, pull, push, push_commit_notes, push_tag, read_conflict,
|
open_repository_file, prune_worktrees, pull, push, push_commit_notes, push_tag, read_conflict,
|
||||||
rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, rename_branch,
|
rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, rename_branch,
|
||||||
rename_remote_branch, repair_worktree, resolve_conflict, resolve_conflict_side,
|
rename_remote_branch, repair_worktree, reset_bisect, resolve_conflict, resolve_conflict_side,
|
||||||
restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
|
restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
|
||||||
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
|
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
|
||||||
set_branch_upstream, set_commit_note, stage_files, start_interactive_rebase, stash_apply,
|
set_branch_upstream, set_commit_note, stage_files, start_bisect, start_interactive_rebase,
|
||||||
stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files,
|
stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree,
|
||||||
untrack_paths, update_remote,
|
unstage_files, untrack_paths, update_remote,
|
||||||
|
};
|
||||||
|
use integrations::{
|
||||||
|
add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser,
|
||||||
|
run_integration_review_action,
|
||||||
};
|
};
|
||||||
use integrations::list_integration_repositories;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::{Emitter, Manager};
|
use tauri::{Emitter, Manager};
|
||||||
@@ -317,7 +320,6 @@ async fn main() {
|
|||||||
.manage(StartupRepository(Mutex::new(startup_repository)))
|
.manage(StartupRepository(Mutex::new(startup_repository)))
|
||||||
.manage(StartupClone(Mutex::new(startup_clone)))
|
.manage(StartupClone(Mutex::new(startup_clone)))
|
||||||
.manage(SearchCancellationState::default())
|
.manage(SearchCancellationState::default())
|
||||||
.manage(commit_ai::CommitAiEngine::new())
|
|
||||||
.plugin(tauri_plugin_dialog::init());
|
.plugin(tauri_plugin_dialog::init());
|
||||||
|
|
||||||
// Linux installs are expected to come from the system package manager (see the
|
// Linux installs are expected to come from the system package manager (see the
|
||||||
@@ -388,9 +390,6 @@ async fn main() {
|
|||||||
amend_commit,
|
amend_commit,
|
||||||
undo_last_commit,
|
undo_last_commit,
|
||||||
last_commit_message,
|
last_commit_message,
|
||||||
commit_ai_status,
|
|
||||||
commit_ai_load,
|
|
||||||
commit_ai_local_models,
|
|
||||||
commit_ai_generate,
|
commit_ai_generate,
|
||||||
commit_ai_review,
|
commit_ai_review,
|
||||||
commit_ai_split,
|
commit_ai_split,
|
||||||
@@ -416,6 +415,10 @@ async fn main() {
|
|||||||
start_interactive_rebase,
|
start_interactive_rebase,
|
||||||
list_reflog,
|
list_reflog,
|
||||||
restore_reflog_entry,
|
restore_reflog_entry,
|
||||||
|
get_bisect_state,
|
||||||
|
start_bisect,
|
||||||
|
mark_bisect,
|
||||||
|
reset_bisect,
|
||||||
list_repository_files,
|
list_repository_files,
|
||||||
open_repository_bundle,
|
open_repository_bundle,
|
||||||
list_file_history,
|
list_file_history,
|
||||||
@@ -435,6 +438,11 @@ async fn main() {
|
|||||||
cred_save,
|
cred_save,
|
||||||
cred_delete,
|
cred_delete,
|
||||||
list_integration_repositories,
|
list_integration_repositories,
|
||||||
|
list_integration_review_requests,
|
||||||
|
get_integration_review_details,
|
||||||
|
add_integration_review_comment,
|
||||||
|
run_integration_review_action,
|
||||||
|
open_in_browser,
|
||||||
set_sync_badge,
|
set_sync_badge,
|
||||||
close_splashscreen,
|
close_splashscreen,
|
||||||
set_telemetry_enabled,
|
set_telemetry_enabled,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Gitty",
|
"productName": "Gitty",
|
||||||
"version": "2026.8.8",
|
"version": "2026.9.1",
|
||||||
"identifier": "com.gitty",
|
"identifier": "com.gitty",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run prepare:lfs && npm run dev",
|
"beforeDevCommand": "npm run prepare:lfs && npm run dev",
|
||||||
|
|||||||
+507
-288
File diff suppressed because it is too large
Load Diff
+215
-127
@@ -1486,73 +1486,61 @@
|
|||||||
right: 18px;
|
right: 18px;
|
||||||
bottom: 18px;
|
bottom: 18px;
|
||||||
z-index: 70;
|
z-index: 70;
|
||||||
display: grid;
|
width: min(410px, calc(100vw - 28px));
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
width: min(430px, calc(100vw - 32px));
|
|
||||||
padding: 14px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(100, 108, 255, 0.42);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 14px;
|
border-top: 2px solid var(--color-accent);
|
||||||
color: var(--color-ink);
|
color: var(--color-ink);
|
||||||
background:
|
background: var(--app-dialog-bg);
|
||||||
linear-gradient(135deg, rgba(100,108,255,0.22), rgba(189,52,254,0.12) 42%, rgba(65,209,255,0.08)),
|
box-shadow: 0 18px 48px rgba(0,0,0,0.42);
|
||||||
rgba(12, 13, 24, 0.96);
|
animation: update-toast-in 180ms cubic-bezier(.2,.8,.2,1) both;
|
||||||
box-shadow: 0 24px 72px rgba(0,0,0,0.48), 0 0 0 1px rgba(255,255,255,0.04) inset;
|
|
||||||
backdrop-filter: blur(18px);
|
|
||||||
}
|
}
|
||||||
.update-toast.error {
|
.update-toast.error {
|
||||||
border-color: rgba(232,96,96,0.45);
|
border-top-color: var(--code-delete-strong);
|
||||||
background:
|
|
||||||
linear-gradient(135deg, rgba(232,96,96,0.16), rgba(100,108,255,0.11)),
|
|
||||||
rgba(12, 13, 24, 0.96);
|
|
||||||
}
|
}
|
||||||
.update-toast.installed {
|
.update-toast.installed {
|
||||||
border-color: rgba(78,202,118,0.38);
|
border-top-color: var(--code-add-strong);
|
||||||
background:
|
|
||||||
linear-gradient(135deg, rgba(78,202,118,0.15), rgba(65,209,255,0.1), rgba(100,108,255,0.12)),
|
|
||||||
rgba(12, 13, 24, 0.96);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-toast-glow {
|
.update-toast-header {
|
||||||
position: absolute;
|
display: grid;
|
||||||
inset: auto 18px -46px auto;
|
grid-template-columns: 34px minmax(0, 1fr) auto;
|
||||||
width: 170px;
|
align-items: center;
|
||||||
height: 95px;
|
gap: 10px;
|
||||||
border-radius: 999px;
|
min-height: 58px;
|
||||||
background: rgba(65,209,255,0.18);
|
padding: 10px 11px;
|
||||||
filter: blur(34px);
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
pointer-events: none;
|
background: var(--app-dialog-chrome);
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-toast-icon {
|
.update-toast-icon {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 42px;
|
width: 34px;
|
||||||
height: 42px;
|
height: 34px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
border: 1px solid rgba(255,255,255,0.14);
|
border: 1px solid color-mix(in srgb, var(--color-accent) 32%, var(--color-border));
|
||||||
border-radius: 12px;
|
color: var(--color-accent);
|
||||||
color: #ffffff;
|
background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-raised));
|
||||||
background: linear-gradient(135deg, rgba(65,209,255,0.28), rgba(100,108,255,0.54), rgba(189,52,254,0.42));
|
|
||||||
box-shadow: 0 14px 32px rgba(100,108,255,0.22), inset 0 1px 0 rgba(255,255,255,0.16);
|
|
||||||
}
|
}
|
||||||
.update-toast-icon.busy { color: #bfefff; }
|
.update-toast.error .update-toast-icon {
|
||||||
|
border-color: color-mix(in srgb, var(--code-delete-strong) 34%, var(--color-border));
|
||||||
.update-toast-content {
|
color: var(--code-delete-strong);
|
||||||
position: relative;
|
background: var(--code-delete-bg);
|
||||||
z-index: 1;
|
|
||||||
display: grid;
|
|
||||||
gap: 9px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
.update-toast.installed .update-toast-icon {
|
||||||
|
border-color: color-mix(in srgb, var(--code-add-strong) 34%, var(--color-border));
|
||||||
|
color: var(--code-add-strong);
|
||||||
|
background: var(--code-add-bg);
|
||||||
|
}
|
||||||
|
.update-toast-icon.busy { color: var(--color-accent); }
|
||||||
|
|
||||||
.update-toast-top {
|
.update-toast-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
gap: 10px;
|
||||||
align-items: start;
|
|
||||||
gap: 8px;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--app-dialog-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-toast-copy { min-width: 0; }
|
.update-toast-copy { min-width: 0; }
|
||||||
@@ -1561,55 +1549,54 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
letter-spacing: .035em;
|
||||||
|
text-transform: uppercase;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.update-toast h2 {
|
.update-toast h2 {
|
||||||
margin: 2px 0 0;
|
margin: 3px 0 0;
|
||||||
color: #ffffff;
|
color: var(--color-ink);
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
.update-toast p {
|
.update-toast p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-ink-muted);
|
color: var(--color-ink-muted);
|
||||||
font-size: 12.5px;
|
font-size: 12px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-toast-close {
|
.update-toast-close {
|
||||||
width: 28px;
|
width: 26px;
|
||||||
min-width: 28px;
|
min-width: 26px;
|
||||||
min-height: 28px;
|
min-height: 26px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-color: rgba(255,255,255,0.1);
|
border-color: var(--color-border-subtle);
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--color-ink-dim);
|
color: var(--color-ink-dim);
|
||||||
background: rgba(255,255,255,0.04);
|
background: transparent;
|
||||||
}
|
}
|
||||||
.update-toast-close:hover:not(:disabled) {
|
.update-toast-close:hover:not(:disabled) {
|
||||||
border-color: rgba(255,255,255,0.2);
|
border-color: var(--color-border);
|
||||||
color: #ffffff;
|
color: var(--color-ink);
|
||||||
background: rgba(255,255,255,0.1);
|
background: var(--color-surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-progress {
|
.update-progress {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 6px;
|
height: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 999px;
|
background: var(--color-surface-hover);
|
||||||
background: rgba(255,255,255,0.09);
|
|
||||||
}
|
}
|
||||||
.update-progress span {
|
.update-progress span {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0 auto 0 0;
|
inset: 0 auto 0 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-radius: inherit;
|
background: var(--color-accent);
|
||||||
background: linear-gradient(90deg, #4db6d6, #6f8cff, #238eb4);
|
|
||||||
transition: width 160ms ease;
|
transition: width 160ms ease;
|
||||||
}
|
}
|
||||||
.update-progress.indeterminate span {
|
.update-progress.indeterminate span {
|
||||||
@@ -1627,29 +1614,48 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 7px;
|
||||||
|
min-height: 49px;
|
||||||
|
padding: 8px 11px;
|
||||||
|
border-top: 1px solid var(--color-border-subtle);
|
||||||
|
background: var(--app-dialog-chrome);
|
||||||
}
|
}
|
||||||
.update-toast-primary,
|
.update-toast-primary,
|
||||||
.update-toast-secondary {
|
.update-toast-secondary {
|
||||||
min-height: 32px;
|
min-height: 31px;
|
||||||
border-radius: 8px;
|
font-size: 11.5px;
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
.update-toast-primary {
|
.update-toast-primary {
|
||||||
border-color: rgba(111,140,255,0.72);
|
border-color: var(--color-primary);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background: linear-gradient(135deg, #5f7df2, #238eb4);
|
background: var(--color-primary);
|
||||||
}
|
}
|
||||||
.update-toast-primary:hover:not(:disabled) {
|
.update-toast-primary:hover:not(:disabled) {
|
||||||
border-color: rgba(77,182,214,0.74);
|
border-color: var(--color-primary-dark);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background: linear-gradient(135deg, #6f8cff, #2da0c7);
|
background: var(--color-primary-dark);
|
||||||
}
|
}
|
||||||
.update-toast-secondary {
|
.update-toast-secondary {
|
||||||
border-color: rgba(255,255,255,0.1);
|
border-color: var(--color-border);
|
||||||
color: var(--color-ink-muted);
|
color: var(--color-ink-muted);
|
||||||
background: rgba(255,255,255,0.05);
|
background: var(--app-button-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes update-toast-in {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.update-toast {
|
||||||
|
right: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
width: calc(100vw - 16px);
|
||||||
|
}
|
||||||
|
.update-toast-actions > button {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Workspace layout --- */
|
/* --- Workspace layout --- */
|
||||||
@@ -3872,6 +3878,20 @@
|
|||||||
max-height: calc(100vh - 32px);
|
max-height: calc(100vh - 32px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
.init-repository-dialog {
|
||||||
|
display: block;
|
||||||
|
width: min(540px, calc(100vw - 32px));
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.merge-branch-dialog {
|
||||||
|
display: block;
|
||||||
|
width: min(590px, calc(100vw - 32px));
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
.rename-branch-dialog {
|
.rename-branch-dialog {
|
||||||
display: block;
|
display: block;
|
||||||
width: min(520px, calc(100vw - 32px));
|
width: min(520px, calc(100vw - 32px));
|
||||||
@@ -4160,50 +4180,75 @@
|
|||||||
color: #f5f7ff;
|
color: #f5f7ff;
|
||||||
background: linear-gradient(180deg, rgba(100,108,255,0.22), rgba(65,209,255,0.1));
|
background: linear-gradient(180deg, rgba(100,108,255,0.22), rgba(65,209,255,0.1));
|
||||||
}
|
}
|
||||||
.ai-provider-option-local {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
row-gap: 2px;
|
|
||||||
}
|
|
||||||
.ai-provider-badge {
|
|
||||||
flex-basis: 100%;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 9.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--color-ink-faint);
|
|
||||||
}
|
|
||||||
.ai-local-profile-options {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.ai-local-profile-option {
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 34px;
|
|
||||||
padding: 0 8px;
|
|
||||||
border-color: var(--color-border-subtle);
|
|
||||||
background: rgba(255,255,255,0.03);
|
|
||||||
color: var(--color-ink-dim);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.ai-local-profile-option:hover:not(:disabled) {
|
|
||||||
border-color: var(--color-border);
|
|
||||||
color: var(--color-ink);
|
|
||||||
background: var(--color-surface-hover);
|
|
||||||
}
|
|
||||||
.ai-local-profile-option.active {
|
|
||||||
border-color: rgba(65,209,255,0.48);
|
|
||||||
color: #f5f7ff;
|
|
||||||
background: linear-gradient(180deg, rgba(65,209,255,0.16), rgba(100,108,255,0.12));
|
|
||||||
}
|
|
||||||
.new-branch-form {
|
.new-branch-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
.init-repository-header { padding: 16px 18px; }
|
||||||
|
.init-repository-heading { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.init-repository-heading h2 { margin: 2px 0 0; color: var(--color-ink); font-size: 15px; line-height: 1.25; }
|
||||||
|
.init-repository-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 34%, var(--color-border));
|
||||||
|
border-radius: 9px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-surface-raised));
|
||||||
|
}
|
||||||
|
.init-repository-form { display: flex; flex-direction: column; gap: 16px; padding: 18px; }
|
||||||
|
.init-repository-description { margin: 0; color: var(--color-ink-dim); font-size: 11.5px; line-height: 1.5; }
|
||||||
|
.init-repository-field > span { font-size: 9.5px; }
|
||||||
|
.init-repository-field > div { position: relative; align-items: center; }
|
||||||
|
.init-repository-field > div > svg { position: absolute; left: 11px; z-index: 1; color: var(--color-ink-faint); pointer-events: none; }
|
||||||
|
.init-repository-field > div > input { height: 36px; padding-left: 36px; font-family: var(--font-mono); font-size: 12px; }
|
||||||
|
.init-repository-field small { color: var(--color-ink-faint); font-size: 9.5px; line-height: 1.4; }
|
||||||
|
.init-repository-path-field { padding: 12px; border: 1px solid var(--color-border-subtle); border-radius: 9px; background: var(--color-surface-raised); }
|
||||||
|
.init-repository-path-control { display: grid !important; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
|
||||||
|
.init-repository-path-control > input { grid-column: 1; }
|
||||||
|
.init-repository-browse { grid-column: 2; display: inline-flex; align-items: center; gap: 6px; height: 36px; font-size: 11.5px; white-space: nowrap; }
|
||||||
|
.init-repository-error { color: var(--color-danger, #ff6b78) !important; }
|
||||||
|
.init-repository-actions { padding-top: 2px; }
|
||||||
|
.init-repository-actions > button { font-size: 11.5px; }
|
||||||
|
.merge-branch-header { padding: 15px 17px; }
|
||||||
|
.merge-branch-heading { display: flex; align-items: center; gap: 11px; }
|
||||||
|
.merge-branch-heading h2 { margin: 2px 0 0; color: var(--color-ink); font-size: 15px; line-height: 1.25; }
|
||||||
|
.merge-branch-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 34%, var(--color-border));
|
||||||
|
border-radius: 9px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-surface-raised));
|
||||||
|
}
|
||||||
|
.merge-branch-form { display: flex; flex-direction: column; gap: 15px; padding: 17px; }
|
||||||
|
.merge-branch-route { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 12px; padding: 11px 12px; border: 1px solid var(--color-border-subtle); border-radius: 9px; background: var(--color-surface-raised); }
|
||||||
|
.merge-branch-route > div { display: grid; gap: 3px; min-width: 0; }
|
||||||
|
.merge-branch-route > div:last-child { text-align: right; }
|
||||||
|
.merge-branch-route span { color: var(--color-ink-faint); font-size: 9px; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; }
|
||||||
|
.merge-branch-route strong { overflow: hidden; color: var(--color-ink); font-family: var(--font-mono); font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.merge-branch-route > svg { color: var(--color-accent); }
|
||||||
|
.merge-strategy-fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||||
|
.merge-strategy-fieldset legend { margin-bottom: 7px; color: var(--color-ink-faint); font-size: 9.5px; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; }
|
||||||
|
.merge-strategy-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||||
|
.merge-strategy-option { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 9px; min-width: 0; padding: 11px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-dim); cursor: pointer; }
|
||||||
|
.merge-strategy-option:hover { border-color: var(--color-border); background: var(--color-surface-hover); }
|
||||||
|
.merge-strategy-option.active { border-color: color-mix(in srgb, var(--color-accent) 52%, var(--color-border)); background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface-raised)); box-shadow: inset 2px 0 0 var(--color-accent); }
|
||||||
|
.merge-strategy-option > input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||||
|
.merge-strategy-check { display: grid; place-items: center; width: 17px; height: 17px; margin-top: 1px; border: 1px solid var(--color-border-input); border-radius: 50%; color: #fff; background: var(--color-surface-raised); }
|
||||||
|
.merge-strategy-option.active .merge-strategy-check { border-color: var(--color-accent); background: var(--color-accent); }
|
||||||
|
.merge-strategy-copy { display: grid; gap: 3px; min-width: 0; }
|
||||||
|
.merge-strategy-copy strong { color: var(--color-ink); font-size: 11.5px; }
|
||||||
|
.merge-strategy-copy small { color: var(--color-ink-faint); font-size: 9.5px; line-height: 1.4; }
|
||||||
|
.merge-branch-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 1px; }
|
||||||
|
.merge-branch-actions > button { font-size: 11.5px; }
|
||||||
.rename-branch-form {
|
.rename-branch-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -8875,7 +8920,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
|||||||
.repo-tab {
|
.repo-tab {
|
||||||
min-height: 35px;
|
min-height: 35px;
|
||||||
height: 35px;
|
height: 35px;
|
||||||
padding: 0 31px 0 15px;
|
padding: 0 34px 0 15px;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
@@ -8901,19 +8946,21 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
|||||||
|
|
||||||
.repo-tab-close {
|
.repo-tab-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 6px;
|
||||||
right: 6px;
|
right: 5px;
|
||||||
width: 19px;
|
width: 23px;
|
||||||
min-width: 19px;
|
min-width: 23px;
|
||||||
max-width: 19px;
|
max-width: 23px;
|
||||||
height: 19px;
|
height: 23px;
|
||||||
min-height: 19px;
|
min-height: 23px;
|
||||||
max-height: 19px;
|
max-height: 23px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-radius: 2px;
|
border-radius: 3px;
|
||||||
color: var(--color-ink-faint);
|
color: var(--color-ink-faint);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
opacity: 0.62;
|
opacity: 0.62;
|
||||||
|
transition: opacity 120ms ease, color 120ms ease, background 120ms ease, box-shadow 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo-tab-wrap.active .repo-tab-close { opacity: 0.72; }
|
.repo-tab-wrap.active .repo-tab-close { opacity: 0.72; }
|
||||||
@@ -8921,8 +8968,16 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
|||||||
.repo-tab-wrap:focus-within .repo-tab-close { opacity: 1; }
|
.repo-tab-wrap:focus-within .repo-tab-close { opacity: 1; }
|
||||||
.repo-tab-close:hover:not(:disabled),
|
.repo-tab-close:hover:not(:disabled),
|
||||||
.repo-tab-close:focus-visible:not(:disabled) {
|
.repo-tab-close:focus-visible:not(:disabled) {
|
||||||
color: #e1848b;
|
color: #ffffff;
|
||||||
background: transparent;
|
background: #d93641;
|
||||||
|
box-shadow: inset 0 0 0 1px #f0646d;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .repo-tab-close:hover:not(:disabled),
|
||||||
|
:root[data-theme="light"] .repo-tab-close:focus-visible:not(:disabled) {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #c92f3a;
|
||||||
|
box-shadow: inset 0 0 0 1px #a9212b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo-tab-add {
|
.repo-tab-add {
|
||||||
@@ -8971,3 +9026,36 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
|||||||
:root:not([data-theme="light"]) .repo-tab.management.active > svg {
|
:root:not([data-theme="light"]) .repo-tab.management.active > svg {
|
||||||
color: #d7dae0;
|
color: #d7dae0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keep every dialog close action neutral until it is intentionally targeted. */
|
||||||
|
:root .dialog-close:hover:not(:disabled),
|
||||||
|
:root .dialog-close:focus-visible:not(:disabled),
|
||||||
|
:root .dialog-icon-button:hover:not(:disabled),
|
||||||
|
:root .dialog-icon-button:focus-visible:not(:disabled),
|
||||||
|
:root .cred-close:hover:not(:disabled),
|
||||||
|
:root .cred-close:focus-visible:not(:disabled) {
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: #f0646d;
|
||||||
|
background: #d93641;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .dialog-close:hover:not(:disabled),
|
||||||
|
:root[data-theme="light"] .dialog-close:focus-visible:not(:disabled),
|
||||||
|
:root[data-theme="light"] .dialog-icon-button:hover:not(:disabled),
|
||||||
|
:root[data-theme="light"] .dialog-icon-button:focus-visible:not(:disabled),
|
||||||
|
:root[data-theme="light"] .cred-close:hover:not(:disabled),
|
||||||
|
:root[data-theme="light"] .cred-close:focus-visible:not(:disabled) {
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: #a9212b;
|
||||||
|
background: #c92f3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep the repository overview as a compact icon tab. */
|
||||||
|
.repo-tab.management {
|
||||||
|
flex: 0 0 38px;
|
||||||
|
width: 38px;
|
||||||
|
min-width: 38px;
|
||||||
|
padding: 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|||||||
+10
-2
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Folder, GitBranch, Plus, X } from "@lucide/svelte";
|
import { Folder, GitBranch, GitPullRequest, Plus, X } from "@lucide/svelte";
|
||||||
|
|
||||||
interface RepositoryTabItem {
|
interface RepositoryTabItem {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -7,11 +7,12 @@
|
|||||||
branch: string | null;
|
branch: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export let activeView: "management" | "repository" = "management";
|
export let activeView: "management" | "review-center" | "repository" = "management";
|
||||||
export let repoTabs: RepositoryTabItem[] = [];
|
export let repoTabs: RepositoryTabItem[] = [];
|
||||||
export let isBusy: boolean = false;
|
export let isBusy: boolean = false;
|
||||||
export let language: "en" | "de" = "en";
|
export let language: "en" | "de" = "en";
|
||||||
export let onOpenManagement: () => void = () => {};
|
export let onOpenManagement: () => void = () => {};
|
||||||
|
export let onOpenReviewCenter: () => void = () => {};
|
||||||
export let isActive: (path: string) => boolean = () => false;
|
export let isActive: (path: string) => boolean = () => false;
|
||||||
export let onSelect: (path: string) => void | Promise<void> = () => {};
|
export let onSelect: (path: string) => void | Promise<void> = () => {};
|
||||||
export let onClose: (path: string, event: MouseEvent) => void | Promise<void> = () => {};
|
export let onClose: (path: string, event: MouseEvent) => void | Promise<void> = () => {};
|
||||||
@@ -27,11 +28,18 @@
|
|||||||
onclick={onOpenManagement}
|
onclick={onOpenManagement}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
title={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
|
title={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
|
||||||
|
aria-label={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
|
||||||
>
|
>
|
||||||
<Folder size={15} aria-hidden="true" />
|
<Folder size={15} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="repo-tabs-scroll">
|
<div class="repo-tabs-scroll">
|
||||||
|
<div class="repo-tab-wrap review-center-tab" class:active={activeView === "review-center"} role="presentation">
|
||||||
|
<button class="repo-tab" type="button" onclick={onOpenReviewCenter} disabled={isBusy} title="Review Center" aria-label="Review Center">
|
||||||
|
<GitPullRequest size={13} aria-hidden="true" />
|
||||||
|
<span>PR</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{#each repoTabs as repo (repo.path)}
|
{#each repoTabs as repo (repo.path)}
|
||||||
<div
|
<div
|
||||||
class="repo-tab-wrap"
|
class="repo-tab-wrap"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
|
Bug,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Code2,
|
Code2,
|
||||||
CloudDownload,
|
CloudDownload,
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
export let onCompare: () => void = () => {};
|
export let onCompare: () => void = () => {};
|
||||||
export let onInteractiveRebase: () => void = () => {};
|
export let onInteractiveRebase: () => void = () => {};
|
||||||
export let onReflog: () => void = () => {};
|
export let onReflog: () => void = () => {};
|
||||||
|
export let onBisect: () => void = () => {};
|
||||||
export let onOpenInExplorer: () => void = () => {};
|
export let onOpenInExplorer: () => void = () => {};
|
||||||
export let onOpenInEditor: () => void = () => {};
|
export let onOpenInEditor: () => void = () => {};
|
||||||
export let onOpenTerminal: () => void = () => {};
|
export let onOpenTerminal: () => void = () => {};
|
||||||
@@ -208,6 +210,13 @@
|
|||||||
<small>{isGerman ? "Commits ordnen und zusammenfassen" : "Reorder and combine commits"}</small>
|
<small>{isGerman ? "Commits ordnen und zusammenfassen" : "Reorder and combine commits"}</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" role="menuitem" onclick={() => runHistoryAction(onBisect)}>
|
||||||
|
<Bug size={15} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<strong>Git Bisect</strong>
|
||||||
|
<small>{isGerman ? "Fehlerhaften Commit schrittweise finden" : "Find a bad commit step by step"}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,8 +15,7 @@
|
|||||||
function providerLabel(value: CommitAiProvider): string {
|
function providerLabel(value: CommitAiProvider): string {
|
||||||
if (value === "openai") return "OpenAI";
|
if (value === "openai") return "OpenAI";
|
||||||
if (value === "anthropic") return "Anthropic";
|
if (value === "anthropic") return "Anthropic";
|
||||||
if (value === "custom") return "Custom endpoint";
|
return "Custom endpoint";
|
||||||
return "Local AI";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function locationLabel(finding: AiReviewFinding): string {
|
function locationLabel(finding: AiReviewFinding): string {
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, 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 { Bot, Check, Eye, EyeOff, Globe, Key, LoaderCircle, X } 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, CommitAiProvider } from "../types";
|
||||||
import SelectMenu from "./SelectMenu.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: AiSettings;
|
settings: AiSettings;
|
||||||
localModels: LocalModelOption[];
|
|
||||||
onSave: (settings: AiSettings) => void;
|
onSave: (settings: AiSettings) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { settings, localModels = [], onSave, onClose }: Props = $props();
|
let { settings, onSave, onClose }: Props = $props();
|
||||||
|
|
||||||
type CloudProvider = Exclude<CommitAiProvider, "local">;
|
type CloudProvider = CommitAiProvider;
|
||||||
|
|
||||||
const CRED_KEYS: Record<CloudProvider, string> = {
|
const CRED_KEYS: Record<CloudProvider, string> = {
|
||||||
openai: "ai:openai",
|
openai: "ai:openai",
|
||||||
@@ -22,9 +20,7 @@
|
|||||||
custom: "ai:custom",
|
custom: "ai:custom",
|
||||||
};
|
};
|
||||||
|
|
||||||
let provider = $state<CommitAiProvider>("local");
|
let provider = $state<CommitAiProvider>("openai");
|
||||||
let localModelId = $state("");
|
|
||||||
let localProfile = $state<CommitAiLocalProfile>("fast");
|
|
||||||
let openaiModel = $state("");
|
let openaiModel = $state("");
|
||||||
let anthropicModel = $state("");
|
let anthropicModel = $state("");
|
||||||
let customBaseUrl = $state("");
|
let customBaseUrl = $state("");
|
||||||
@@ -41,8 +37,6 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
provider = settings.provider;
|
provider = settings.provider;
|
||||||
localModelId = settings.localModelId;
|
|
||||||
localProfile = settings.localProfile ?? "fast";
|
|
||||||
openaiModel = settings.openaiModel;
|
openaiModel = settings.openaiModel;
|
||||||
anthropicModel = settings.anthropicModel;
|
anthropicModel = settings.anthropicModel;
|
||||||
customBaseUrl = settings.customBaseUrl;
|
customBaseUrl = settings.customBaseUrl;
|
||||||
@@ -103,8 +97,6 @@
|
|||||||
]);
|
]);
|
||||||
onSave({
|
onSave({
|
||||||
provider,
|
provider,
|
||||||
localModelId,
|
|
||||||
localProfile,
|
|
||||||
openaiModel: openaiModel.trim() || "gpt-4o-mini",
|
openaiModel: openaiModel.trim() || "gpt-4o-mini",
|
||||||
anthropicModel: anthropicModel.trim() || "claude-3-5-haiku-latest",
|
anthropicModel: anthropicModel.trim() || "claude-3-5-haiku-latest",
|
||||||
customBaseUrl: customBaseUrl.trim(),
|
customBaseUrl: customBaseUrl.trim(),
|
||||||
@@ -117,26 +109,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSize(mb: number): string {
|
|
||||||
return mb >= 1000 ? `${(mb / 1000).toFixed(1)} GB` : `${mb} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function recommendedModelForProfile(profile: CommitAiLocalProfile): string {
|
|
||||||
if (profile === "balanced") return "qwen2.5-1.5b";
|
|
||||||
if (profile === "detailed") return "qwen2.5-3b";
|
|
||||||
return "qwen2.5-0.5b";
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectLocalProfile(profile: CommitAiLocalProfile) {
|
|
||||||
const previousRecommended = recommendedModelForProfile(localProfile);
|
|
||||||
localProfile = profile;
|
|
||||||
const nextRecommended = recommendedModelForProfile(profile);
|
|
||||||
if (!localModelId || localModelId === previousRecommended) {
|
|
||||||
localModelId = nextRecommended;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let selectedLocalModel = $derived(localModels.find((option) => option.id === localModelId));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -156,17 +128,6 @@
|
|||||||
|
|
||||||
<form class="ai-settings-form" onsubmit={(e) => { e.preventDefault(); void handleSave(); }}>
|
<form class="ai-settings-form" onsubmit={(e) => { e.preventDefault(); void handleSave(); }}>
|
||||||
<div class="ai-provider-options" role="radiogroup" aria-label="AI provider">
|
<div class="ai-provider-options" role="radiogroup" aria-label="AI provider">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="ai-provider-option ai-provider-option-local"
|
|
||||||
class:active={provider === "local"}
|
|
||||||
disabled
|
|
||||||
title="Local AI is still in development and not yet available"
|
|
||||||
>
|
|
||||||
<Cpu size={16} aria-hidden="true" />
|
|
||||||
Local AI
|
|
||||||
<span class="ai-provider-badge">In development</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="ai-provider-option" class:active={provider === "openai"} onclick={() => { provider = "openai"; }}>
|
<button type="button" class="ai-provider-option" class:active={provider === "openai"} onclick={() => { provider = "openai"; }}>
|
||||||
<Bot size={16} aria-hidden="true" />
|
<Bot size={16} aria-hidden="true" />
|
||||||
OpenAI
|
OpenAI
|
||||||
@@ -181,38 +142,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if provider === "local"}
|
{#if provider === "openai"}
|
||||||
<div class="cred-field">
|
|
||||||
<span class="cred-field-label">Local speed</span>
|
|
||||||
<div class="ai-local-profile-options" role="radiogroup" aria-label="Local AI speed">
|
|
||||||
<button type="button" class="ai-local-profile-option" class:active={localProfile === "fast"} onclick={() => selectLocalProfile("fast")}>
|
|
||||||
<Zap size={15} aria-hidden="true" />
|
|
||||||
Fast
|
|
||||||
</button>
|
|
||||||
<button type="button" class="ai-local-profile-option" class:active={localProfile === "balanced"} onclick={() => selectLocalProfile("balanced")}>
|
|
||||||
<Gauge size={15} aria-hidden="true" />
|
|
||||||
Balanced
|
|
||||||
</button>
|
|
||||||
<button type="button" class="ai-local-profile-option" class:active={localProfile === "detailed"} onclick={() => selectLocalProfile("detailed")}>
|
|
||||||
<Sparkles size={15} aria-hidden="true" />
|
|
||||||
Detailed
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label class="cred-field">
|
|
||||||
<span class="cred-field-label">Model</span>
|
|
||||||
<SelectMenu value={localModelId} options={localModels.map((option) => ({ value: option.id, label: `${option.label} - ${formatSize(option.approx_size_mb)}` }))} onChange={(value) => { localModelId = value; }} />
|
|
||||||
</label>
|
|
||||||
<div class="cred-token-hint">
|
|
||||||
<AlertCircle size={13} aria-hidden="true" />
|
|
||||||
<span>
|
|
||||||
Switching downloads the model{selectedLocalModel ? ` (${formatSize(selectedLocalModel.approx_size_mb)})` : ""}
|
|
||||||
in the background — depending on your internet connection this can take several minutes.
|
|
||||||
After that it stays cached locally and loads instantly on the next start.
|
|
||||||
The speed setting only changes Local AI; API providers keep their existing prompt.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{:else if provider === "openai"}
|
|
||||||
<label class="cred-field">
|
<label class="cred-field">
|
||||||
<span class="cred-field-label">Model</span>
|
<span class="cred-field-label">Model</span>
|
||||||
<input type="text" bind:value={openaiModel} placeholder="gpt-4o-mini" autocomplete="off" spellcheck="false" />
|
<input type="text" bind:value={openaiModel} placeholder="gpt-4o-mini" autocomplete="off" spellcheck="false" />
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertTriangle, Bug, Check, GitCommitHorizontal, LoaderCircle, Play, RotateCcw, SkipForward, X } from "@lucide/svelte";
|
||||||
|
import type { BisectState } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
language: "en" | "de";
|
||||||
|
bisectState: BisectState | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isBusy: boolean;
|
||||||
|
operation: string;
|
||||||
|
error: string;
|
||||||
|
onStart: (good: string, bad: string) => void;
|
||||||
|
onMark: (verdict: "good" | "bad" | "skip") => void;
|
||||||
|
onReset: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { language = "en", bisectState = null, isLoading = false, isBusy = false, operation = "", error = "", onStart = () => {}, onMark = () => {}, onReset = () => {}, onClose = () => {} }: Props = $props();
|
||||||
|
let good = $state("");
|
||||||
|
let bad = $state("HEAD");
|
||||||
|
const de = $derived(language === "de");
|
||||||
|
const canStart = $derived(Boolean(good.trim() && bad.trim()) && !isLoading && !isBusy);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
||||||
|
<div class="dialog bisect-dialog" role="dialog" aria-modal="true" aria-label={de ? "Geführtes Git Bisect" : "Guided Git bisect"} tabindex="-1">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div><span class="eyebrow">{de ? "Fehlerursache eingrenzen" : "Find the regression"}</span><h2 class="dialog-title">Git Bisect</h2></div>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={de ? "Schließen" : "Close"}><X size={18} aria-hidden="true" /></button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="bisect-body">
|
||||||
|
{#if error}<div class="bisect-notice error"><AlertTriangle size={16} /><span>{error}</span></div>{/if}
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="bisect-loading"><LoaderCircle class="spin" size={18} />{de ? "Bisect-Status wird geladen …" : "Loading bisect status …"}</div>
|
||||||
|
{:else if !bisectState?.active}
|
||||||
|
<section class="bisect-intro">
|
||||||
|
<div class="bisect-intro-icon"><Bug size={24} /></div>
|
||||||
|
<div><h3>{de ? "Welcher Commit hat den Fehler eingeführt?" : "Which commit introduced the bug?"}</h3><p>{de ? "Gitty checkt schrittweise frühere Commits aus. Markiere jede getestete Version als Good, Bad oder Skip." : "Gitty checks out earlier commits step by step. Mark each tested version as Good, Bad, or Skip."}</p></div>
|
||||||
|
</section>
|
||||||
|
<div class="bisect-range">
|
||||||
|
<div class="range-heading">
|
||||||
|
<span>{de ? "Suchbereich" : "Search range"}</span>
|
||||||
|
<small>{de ? "Von funktionierend bis fehlerhaft" : "From working to broken"}</small>
|
||||||
|
</div>
|
||||||
|
<div class="bisect-fields">
|
||||||
|
<label class="commit-field good-field">
|
||||||
|
<span class="field-title"><Check size={14} />{de ? "Funktionierender Commit" : "Known good commit"}<em>Good</em></span>
|
||||||
|
<input bind:value={good} placeholder={de ? "z. B. v1.4.0 oder Commit-Hash" : "e.g. v1.4.0 or commit hash"} disabled={isBusy} spellcheck="false" autocomplete="off" />
|
||||||
|
<small>{de ? "Hier trat der Fehler noch nicht auf." : "The bug did not occur here."}</small>
|
||||||
|
</label>
|
||||||
|
<label class="commit-field bad-field">
|
||||||
|
<span class="field-title"><Bug size={14} />{de ? "Fehlerhafter Commit" : "Known bad commit"}<em>Bad</em></span>
|
||||||
|
<input bind:value={bad} placeholder="HEAD" disabled={isBusy} spellcheck="false" autocomplete="off" />
|
||||||
|
<small>{de ? "Meistens HEAD, der aktuelle Commit." : "Usually HEAD, the current commit."}</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bisect-notice"><AlertTriangle size={15} /><span>{de ? "Der Arbeitsbaum muss sauber sein. Während des Bisects wechselt Gitty vorübergehend zwischen Commits." : "The working tree must be clean. Gitty temporarily switches between commits during the bisect."}</span></div>
|
||||||
|
{:else if bisectState.finished && bisectState.culprit}
|
||||||
|
<section class="bisect-result">
|
||||||
|
<div class="result-icon"><Bug size={22} /></div>
|
||||||
|
<div><span>{de ? "Erster fehlerhafter Commit gefunden" : "First bad commit found"}</span><h3>{bisectState.culprit.summary}</h3><div class="commit-meta"><code>{bisectState.culprit.short_hash}</code><span>{bisectState.culprit.author_name}</span><span>{new Date(bisectState.culprit.date).toLocaleString()}</span></div></div>
|
||||||
|
</section>
|
||||||
|
<p class="result-copy">{de ? "Beende den Bisect, um zum ursprünglichen Branch und Arbeitsstand zurückzukehren." : "Finish the bisect to return to the original branch and working state."}</p>
|
||||||
|
{:else if bisectState.current}
|
||||||
|
<div class="bisect-progress">
|
||||||
|
<span>{de ? "Aktueller Test-Commit" : "Current test commit"}</span>
|
||||||
|
{#if bisectState.remaining_steps !== null}<strong>{de ? `Noch ungefähr ${bisectState.remaining_steps} Schritte` : `About ${bisectState.remaining_steps} steps remaining`}</strong>{/if}
|
||||||
|
</div>
|
||||||
|
<section class="bisect-current">
|
||||||
|
<GitCommitHorizontal size={22} />
|
||||||
|
<div><h3>{bisectState.current.summary}</h3><div class="commit-meta"><code>{bisectState.current.short_hash}</code><span>{bisectState.current.author_name}</span><span>{new Date(bisectState.current.date).toLocaleString()}</span></div></div>
|
||||||
|
</section>
|
||||||
|
<div class="bisect-question"><strong>{de ? "Tritt der Fehler bei diesem Commit auf?" : "Does the bug occur at this commit?"}</strong><span>{de ? "Teste die Anwendung oder führe deine Prüfschritte aus, bevor du entscheidest." : "Test the application or run your checks before choosing a verdict."}</span></div>
|
||||||
|
<div class="verdict-grid">
|
||||||
|
<button class="verdict good" type="button" onclick={() => onMark("good")} disabled={isBusy}><Check size={17} /><span><strong>Good</strong><small>{de ? "Fehler tritt nicht auf" : "Bug is absent"}</small></span></button>
|
||||||
|
<button class="verdict bad" type="button" onclick={() => onMark("bad")} disabled={isBusy}><Bug size={17} /><span><strong>Bad</strong><small>{de ? "Fehler tritt auf" : "Bug occurs"}</small></span></button>
|
||||||
|
<button class="verdict skip" type="button" onclick={() => onMark("skip")} disabled={isBusy}><SkipForward size={17} /><span><strong>Skip</strong><small>{de ? "Nicht testbar" : "Cannot test"}</small></span></button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="dialog-footer bisect-footer">
|
||||||
|
{#if bisectState?.active}
|
||||||
|
<button class="btn-secondary reset" type="button" onclick={onReset} disabled={isBusy}><RotateCcw size={15} />{bisectState.finished ? (de ? "Bisect beenden" : "Finish bisect") : (de ? "Bisect abbrechen" : "Abort bisect")}</button>
|
||||||
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{de ? "Später fortsetzen" : "Continue later"}</button>
|
||||||
|
{:else}
|
||||||
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{de ? "Abbrechen" : "Cancel"}</button>
|
||||||
|
<button class="btn-primary" type="button" onclick={() => onStart(good.trim(), bad.trim())} disabled={!canStart}>{#if operation === "Starting Git bisect"}<LoaderCircle class="spin" size={16} />{:else}<Play size={16} />{/if}{de ? "Bisect starten" : "Start bisect"}</button>
|
||||||
|
{/if}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bisect-dialog{grid-template-rows:auto minmax(0,auto) auto;width:min(640px,calc(100vw - 28px));height:auto;max-height:calc(100vh - 40px);overflow:hidden}
|
||||||
|
.bisect-body{display:grid;align-content:start;gap:14px;padding:16px 18px;overflow:auto}
|
||||||
|
.bisect-loading{display:flex;min-height:150px;align-items:center;justify-content:center;gap:8px;color:var(--color-ink-muted)}
|
||||||
|
.bisect-intro,.bisect-current,.bisect-result{display:flex;align-items:flex-start;gap:12px}
|
||||||
|
.bisect-intro-icon,.result-icon{display:grid;width:38px;height:38px;flex:0 0 38px;place-items:center;border:1px solid color-mix(in srgb,var(--color-primary) 32%,var(--color-border));border-radius:8px;color:var(--color-primary);background:color-mix(in srgb,var(--color-primary) 9%,var(--color-surface))}
|
||||||
|
.bisect-intro h3,.bisect-current h3,.bisect-result h3{margin:0;font-size:13px}
|
||||||
|
.bisect-intro p,.result-copy{max-width:520px;margin:4px 0 0;color:var(--color-ink-muted);font-size:12px;line-height:1.45}
|
||||||
|
.bisect-range{display:grid;gap:9px;padding:12px;border:1px solid var(--color-border-subtle);border-radius:9px;background:color-mix(in srgb,var(--color-surface) 72%,transparent)}
|
||||||
|
.range-heading{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
|
||||||
|
.range-heading>span{color:var(--color-ink);font-size:10px;font-weight:800;letter-spacing:.07em;text-transform:uppercase}
|
||||||
|
.range-heading>small{color:var(--color-ink-faint);font-size:10px}
|
||||||
|
.bisect-fields{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||||
|
.commit-field{display:grid;gap:6px;padding:10px;border:1px solid var(--color-border-subtle);border-radius:7px;background:var(--color-surface-raised)}
|
||||||
|
.field-title{display:flex;align-items:center;gap:6px;color:var(--color-ink);font-size:11px;font-weight:700}
|
||||||
|
.field-title em{margin-left:auto;padding:1px 5px;border-radius:4px;font-size:9px;font-style:normal;font-weight:800;text-transform:uppercase}
|
||||||
|
.good-field .field-title :global(svg),.good-field .field-title em{color:#69c986}
|
||||||
|
.good-field .field-title em{background:color-mix(in srgb,#69c986 10%,transparent)}
|
||||||
|
.bad-field .field-title :global(svg),.bad-field .field-title em{color:#ef737b}
|
||||||
|
.bad-field .field-title em{background:color-mix(in srgb,#ef737b 10%,transparent)}
|
||||||
|
.bisect-fields input{height:32px;font-family:var(--font-mono);font-size:11.5px}
|
||||||
|
.bisect-fields small{color:var(--color-ink-faint);font-size:9.5px;line-height:1.35}
|
||||||
|
.bisect-notice{display:flex;align-items:flex-start;gap:8px;padding:9px 11px;border:1px solid color-mix(in srgb,#e2ad4e 28%,var(--color-border));border-radius:7px;color:var(--color-ink-muted);background:color-mix(in srgb,#e2ad4e 6%,var(--color-surface));font-size:11.5px;line-height:1.4}
|
||||||
|
.bisect-notice :global(svg){flex:0 0 auto;color:#e2ad4e}
|
||||||
|
.bisect-notice.error{border-color:color-mix(in srgb,#e45c65 40%,var(--color-border));color:#ef979d;background:color-mix(in srgb,#c92f3a 8%,var(--color-surface))}
|
||||||
|
.bisect-notice.error :global(svg){color:#ef6972}
|
||||||
|
.bisect-progress{display:flex;align-items:center;justify-content:space-between;color:var(--color-ink-muted);font-size:11px}
|
||||||
|
.bisect-progress strong{color:var(--color-primary)}
|
||||||
|
.bisect-current{padding:14px;border:1px solid var(--color-border);border-radius:8px;background:var(--color-surface)}
|
||||||
|
.bisect-current>:global(svg){flex:0 0 auto;color:var(--color-primary)}
|
||||||
|
.commit-meta{display:flex;flex-wrap:wrap;align-items:center;gap:7px 12px;margin-top:7px;color:var(--color-ink-faint);font-size:10px}
|
||||||
|
.commit-meta code{padding:2px 5px;border-radius:4px;color:var(--color-primary);background:color-mix(in srgb,var(--color-primary) 10%,transparent)}
|
||||||
|
.bisect-question{display:grid;gap:4px}
|
||||||
|
.bisect-question span{color:var(--color-ink-muted);font-size:11px}
|
||||||
|
.verdict-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:9px}
|
||||||
|
.verdict{min-height:58px;justify-content:flex-start;padding:8px 11px;text-align:left}
|
||||||
|
.verdict>span{display:grid;gap:2px}
|
||||||
|
.verdict small{color:var(--color-ink-faint);font-size:9.5px}
|
||||||
|
.verdict.good{color:#69c986}.verdict.bad{color:#ef737b}.verdict.skip{color:#ddb45c}
|
||||||
|
.bisect-result{padding:16px;border:1px solid color-mix(in srgb,#ef737b 36%,var(--color-border));border-radius:8px;background:color-mix(in srgb,#c92f3a 7%,var(--color-surface))}
|
||||||
|
.bisect-result>div:last-child>span{color:#ef838b;font-size:10px;font-weight:800;text-transform:uppercase}
|
||||||
|
.result-copy{margin:0}
|
||||||
|
.bisect-footer{justify-content:flex-end;padding-block:9px}
|
||||||
|
.bisect-footer .reset{margin-right:auto}
|
||||||
|
@media(max-width:620px){.bisect-fields,.verdict-grid{grid-template-columns:1fr}.bisect-body{padding:14px}.range-heading{align-items:flex-start;flex-direction:column;gap:2px}.bisect-progress{align-items:flex-start;flex-direction:column;gap:4px}}
|
||||||
|
</style>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
import { Cloud, Download, FolderOpen, GitBranch, Globe2, LoaderCircle, LockKeyhole, RefreshCw, Search, X } from "@lucide/svelte";
|
import { Cloud, Download, FolderOpen, GitBranch, Globe2, LoaderCircle, LockKeyhole, RefreshCw, Search, X } from "@lucide/svelte";
|
||||||
import { listIntegrationRepositories } from "../git";
|
import { listIntegrationRepositories } from "../git";
|
||||||
import { configuredIntegrationSources } from "../integrations";
|
import { configuredIntegrationSources } from "../integrations";
|
||||||
import type { AppLanguage, GitIntegrationProvider, GitIntegrationRepository, GitIntegrationSettings, GitIntegrationSource } from "../types";
|
import type { AppLanguage, CloneOptions, GitIntegrationProvider, GitIntegrationRepository, GitIntegrationSettings, GitIntegrationSource } from "../types";
|
||||||
|
|
||||||
type CloneSource = "url" | "integrations";
|
type CloneSource = "url" | "integrations";
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
error: string;
|
error: string;
|
||||||
language: AppLanguage;
|
language: AppLanguage;
|
||||||
integrations: GitIntegrationSettings;
|
integrations: GitIntegrationSettings;
|
||||||
onClone: (remoteUrl: string, parentPath: string, directoryName: string, provider?: GitIntegrationProvider, accountId?: string) => void;
|
onClone: (remoteUrl: string, parentPath: string, directoryName: string, options: CloneOptions, provider?: GitIntegrationProvider, accountId?: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +45,15 @@
|
|||||||
let repositoryScrollbarDragScrollTop = 0;
|
let repositoryScrollbarDragScrollTop = 0;
|
||||||
let repositoryScrollbarFrame: number | undefined;
|
let repositoryScrollbarFrame: number | undefined;
|
||||||
let repositoryRequestId = 0;
|
let repositoryRequestId = 0;
|
||||||
|
let shallowClone = $state(false);
|
||||||
|
let branchToClone = $state("");
|
||||||
|
let shallowLimitMode = $state<"depth" | "since">("depth");
|
||||||
|
let shallowDepth = $state(1);
|
||||||
|
let shallowSince = $state("");
|
||||||
|
let customFlags = $state("");
|
||||||
|
let sparseCheckout = $state(false);
|
||||||
|
let bloblessClone = $state(false);
|
||||||
|
let sparsePathInput = $state("");
|
||||||
|
|
||||||
const isGerman = $derived(language === "de");
|
const isGerman = $derived(language === "de");
|
||||||
const configuredSources = $derived(configuredIntegrationSources(integrations));
|
const configuredSources = $derived(configuredIntegrationSources(integrations));
|
||||||
@@ -55,9 +64,24 @@
|
|||||||
if (!query) return activeRepositories;
|
if (!query) return activeRepositories;
|
||||||
return activeRepositories.filter((repository) => `${repository.fullName} ${repository.description}`.toLocaleLowerCase().includes(query));
|
return activeRepositories.filter((repository) => `${repository.fullName} ${repository.description}`.toLocaleLowerCase().includes(query));
|
||||||
});
|
});
|
||||||
|
const azureRepositoryGroups = $derived.by(() => {
|
||||||
|
if (activeSource?.provider !== "azure-devops") return [];
|
||||||
|
const groups = new Map<string, GitIntegrationRepository[]>();
|
||||||
|
for (const repository of filteredRepositories) {
|
||||||
|
const separator = repository.fullName.indexOf("/");
|
||||||
|
const project = separator > 0 ? repository.fullName.slice(0, separator) : (isGerman ? "Weitere Repositories" : "Other repositories");
|
||||||
|
const repositories = groups.get(project) ?? [];
|
||||||
|
repositories.push(repository);
|
||||||
|
groups.set(project, repositories);
|
||||||
|
}
|
||||||
|
return [...groups.entries()].map(([project, repositories]) => ({ project, repositories }));
|
||||||
|
});
|
||||||
const selectedRepository = $derived(activeRepositories.find((repository) => repository.id === selectedRepositoryId));
|
const selectedRepository = $derived(activeRepositories.find((repository) => repository.id === selectedRepositoryId));
|
||||||
const directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl));
|
const directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl));
|
||||||
const canSubmit = $derived(!isBusy && remoteUrl.trim().length > 0 && parentPath.trim().length > 0);
|
const sparsePaths = $derived.by(() => [...new Set(sparsePathInput.split(/\r?\n/).map((path) => path.trim()).filter(Boolean))]);
|
||||||
|
const shallowLimitValid = $derived(!shallowClone || (shallowLimitMode === "depth" ? Number.isInteger(shallowDepth) && shallowDepth > 0 : shallowSince.trim().length > 0));
|
||||||
|
const cloneOptionsValid = $derived(shallowLimitValid);
|
||||||
|
const canSubmit = $derived(!isBusy && remoteUrl.trim().length > 0 && parentPath.trim().length > 0 && cloneOptionsValid);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const nextError = error || browseError;
|
const nextError = error || browseError;
|
||||||
@@ -230,10 +254,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showIntegrations() {
|
function selectIntegrationSource(integrationSource: GitIntegrationSource) {
|
||||||
source = "integrations";
|
source = "integrations";
|
||||||
const nextSource = configuredSources.find((candidate) => candidate.id === selectedSourceId) ?? configuredSources[0];
|
void loadRepositories(integrationSource);
|
||||||
if (nextSource) void loadRepositories(nextSource);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showUrlInput() {
|
function showUrlInput() {
|
||||||
@@ -249,39 +272,114 @@
|
|||||||
|
|
||||||
function submit(event: SubmitEvent) {
|
function submit(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (canSubmit) onClone(remoteUrl.trim(), parentPath.trim(), directoryName.trim(), source === "integrations" ? activeSource?.provider : undefined, source === "integrations" ? activeSource?.accountId : undefined);
|
if (canSubmit) onClone(
|
||||||
|
remoteUrl.trim(),
|
||||||
|
parentPath.trim(),
|
||||||
|
directoryName.trim(),
|
||||||
|
{
|
||||||
|
branch: shallowClone && branchToClone.trim() ? branchToClone.trim() : null,
|
||||||
|
blobless: sparseCheckout && bloblessClone,
|
||||||
|
customFlags: shallowClone ? customFlags.trim() : "",
|
||||||
|
shallowDepth: shallowClone && shallowLimitMode === "depth" ? shallowDepth : null,
|
||||||
|
shallowSince: shallowClone && shallowLimitMode === "since" ? shallowSince : null,
|
||||||
|
sparse: sparseCheckout,
|
||||||
|
sparsePaths: sparseCheckout ? sparsePaths : [],
|
||||||
|
},
|
||||||
|
source === "integrations" ? activeSource?.provider : undefined,
|
||||||
|
source === "integrations" ? activeSource?.accountId : undefined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="dialog-backdrop" role="presentation">
|
<div class="dialog-backdrop" role="presentation">
|
||||||
<div class="dialog clone-repository-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Repository klonen" : "Clone repository"} tabindex="-1">
|
<div class="dialog clone-repository-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Repository klonen" : "Clone repository"} tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="dialog-header clone-dialog-header">
|
||||||
<div><span class="eyebrow">Repository Management</span><h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{isGerman ? "Repository klonen" : "Clone repository"}</h2></div>
|
<div><h2>{isGerman ? "Repository klonen" : "Clone a Repository"}</h2></div>
|
||||||
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Klonen schließen" : "Close clone dialog"}><X size={18} /></button>
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Klonen schließen" : "Close clone dialog"}><X size={18} /></button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form class="clone-dialog-form" onsubmit={submit}>
|
<form class="clone-dialog-form" onsubmit={submit}>
|
||||||
<div class="clone-source-tabs" role="tablist" aria-label={isGerman ? "Repository-Quelle" : "Repository source"}>
|
<div class="clone-dialog-layout">
|
||||||
<button type="button" role="tab" aria-selected={source === "url"} class:active={source === "url"} onclick={showUrlInput}><Globe2 size={15} />URL</button>
|
<aside class="clone-source-nav" aria-label={isGerman ? "Repository-Quellen" : "Repository sources"}>
|
||||||
<button type="button" role="tab" aria-selected={source === "integrations"} class:active={source === "integrations"} onclick={showIntegrations}><Cloud size={15} />{isGerman ? "Integrationen" : "Integrations"}{#if configuredSources.length}<em>{configuredSources.length}</em>{/if}</button>
|
<div class="clone-source-heading">{isGerman ? "Quelle" : "Source"}</div>
|
||||||
</div>
|
<div class="clone-source-list" role="tablist" aria-label={isGerman ? "Repository-Quelle" : "Repository source"}>
|
||||||
|
<button type="button" role="tab" aria-selected={source === "url"} class:active={source === "url"} onclick={showUrlInput}><Globe2 size={15} /><span>{isGerman ? "Mit URL klonen" : "Clone with URL"}</span></button>
|
||||||
|
{#each configuredSources as integrationSource}
|
||||||
|
<button type="button" role="tab" aria-selected={source === "integrations" && selectedSourceId === integrationSource.id} class:active={source === "integrations" && selectedSourceId === integrationSource.id} onclick={() => selectIntegrationSource(integrationSource)}>
|
||||||
|
{#if integrationSource.provider === "azure-devops"}<Cloud size={15} />{:else}<GitBranch size={15} />{/if}
|
||||||
|
<span>{integrationSource.provider === "azure-devops" ? `Azure · ${integrationSource.label}` : integrationSource.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if configuredSources.length === 0}<p>{isGerman ? "Integrationen kannst du in den Einstellungen einrichten." : "Set up integrations in Settings."}</p>{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
{#if source === "url"}
|
<section class="clone-dialog-content">
|
||||||
<label class="clone-dialog-field">
|
<div class="clone-dialog-title">
|
||||||
<span>{isGerman ? "Remote-URL" : "Remote URL"}</span>
|
<span>{source === "integrations" ? (activeSource?.label ?? "Integration") : "URL"}</span>
|
||||||
<!-- svelte-ignore a11y_autofocus -->
|
<h3>{isGerman ? "Repository klonen" : "Clone a Repo"}</h3>
|
||||||
<input value={remoteUrl} oninput={handleRemoteInput} autocomplete="off" spellcheck="false" placeholder="https://gitlab.com/org/project.git" disabled={isBusy} autofocus />
|
</div>
|
||||||
</label>
|
|
||||||
{:else}
|
<div class="clone-target-grid">
|
||||||
<section class="integration-browser" aria-label={isGerman ? "Repositories aus Integrationen" : "Repositories from integrations"}>
|
<label class="clone-dialog-field"><span>{isGerman ? "Klonen nach" : "Where to clone to"}</span><div class="clone-dialog-path-field"><input bind:value={parentPath} autocomplete="off" spellcheck="false" placeholder={isGerman ? "Übergeordneten Ordner auswählen" : "Choose parent folder"} disabled={isBusy} /><button class="btn-secondary" type="button" onclick={chooseParentFolder} disabled={isBusy}><FolderOpen size={14} />{isGerman ? "Durchsuchen" : "Browse"}</button></div></label>
|
||||||
|
<label class="clone-dialog-field"><span>{isGerman ? "Ordnername" : "Folder name"}</span><input bind:value={directoryName} oninput={handleDirectoryInput} autocomplete="off" spellcheck="false" placeholder={directorySuggestion || "Optional"} disabled={isBusy} /></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="clone-options" aria-label={isGerman ? "Clone-Optionen" : "Clone options"}>
|
||||||
|
<div class="clone-option-card" class:expanded={shallowClone}>
|
||||||
|
<label class="clone-option-toggle">
|
||||||
|
<input type="checkbox" bind:checked={shallowClone} disabled={isBusy} />
|
||||||
|
<span><strong>Shallow Clone</strong><small>{isGerman ? "Nur die neuesten Commits laden" : "Download only the latest commits"}</small></span>
|
||||||
|
</label>
|
||||||
|
{#if shallowClone}
|
||||||
|
<div class="clone-option-body">
|
||||||
|
<label class="clone-option-detail"><span>{isGerman ? "Zu klonender Branch" : "Branch to clone"}</span><input bind:value={branchToClone} autocomplete="off" spellcheck="false" placeholder={isGerman ? "Standard-Branch" : "Default branch"} disabled={isBusy} /></label>
|
||||||
|
<fieldset class="clone-history-mode">
|
||||||
|
<legend>{isGerman ? "Historie begrenzen nach" : "Limit history by"}</legend>
|
||||||
|
<label><input type="radio" bind:group={shallowLimitMode} value="depth" disabled={isBusy} />{isGerman ? "Commit-Tiefe" : "Commit depth"}</label>
|
||||||
|
<label><input type="radio" bind:group={shallowLimitMode} value="since" disabled={isBusy} />{isGerman ? "Seit Datum" : "Since date"}</label>
|
||||||
|
</fieldset>
|
||||||
|
{#if shallowLimitMode === "depth"}
|
||||||
|
<label class="clone-option-detail"><span>{isGerman ? "Tiefe" : "Depth"}</span><input type="number" min="1" step="1" bind:value={shallowDepth} disabled={isBusy} aria-invalid={!Number.isInteger(shallowDepth) || shallowDepth < 1} /></label>
|
||||||
|
{:else}
|
||||||
|
<label class="clone-option-detail"><span>{isGerman ? "Seit" : "Since"}</span><input type="date" bind:value={shallowSince} disabled={isBusy} aria-invalid={!shallowSince.trim()} /></label>
|
||||||
|
{/if}
|
||||||
|
<label class="clone-option-detail"><span>{isGerman ? "Zusätzliche Flags" : "Custom flags"}</span><input bind:value={customFlags} autocomplete="off" spellcheck="false" placeholder="--recurse-submodules --single-branch" disabled={isBusy} /></label>
|
||||||
|
<small class="clone-option-help">{isGerman ? "Flags wie in der Git-Kommandozeile; verwaltete oder unsichere Flags werden abgewiesen." : "Enter flags as on the Git command line; managed or unsafe flags are rejected."}</small>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="clone-option-card" class:expanded={sparseCheckout}>
|
||||||
|
<label class="clone-option-toggle">
|
||||||
|
<input type="checkbox" bind:checked={sparseCheckout} disabled={isBusy} />
|
||||||
|
<span><strong>Sparse Checkout</strong><small>{isGerman ? "Nur ausgewählte Pfade auschecken" : "Check out only selected paths"}</small></span>
|
||||||
|
</label>
|
||||||
|
{#if sparseCheckout}
|
||||||
|
<div class="clone-option-body">
|
||||||
|
<label class="clone-blobless-toggle"><input type="checkbox" bind:checked={bloblessClone} disabled={isBusy} /><span><strong>Blobless Clone</strong><small>{isGerman ? "Lädt zunächst Bäume und Commits ohne Dateiinhalte. Blobs werden bei Bedarf nachgeladen." : "Fetch trees and commits without file contents initially. Blobs are downloaded on demand."}</small></span></label>
|
||||||
|
<label class="clone-option-detail clone-sparse-paths">
|
||||||
|
<span>{isGerman ? "Pfade einschließen" : "Paths to include"}</span>
|
||||||
|
<textarea bind:value={sparsePathInput} rows="3" spellcheck="false" placeholder={"src/\ndocs/\nREADME.md"} disabled={isBusy}></textarea>
|
||||||
|
<small class="clone-option-help">{isGerman ? "Ein Pfad pro Zeile. Nur diese Pfade werden im Arbeitsverzeichnis ausgecheckt. Ohne Pfade werden nur Dateien im Repository-Root ausgecheckt." : "Enter one path per line. Only these paths will be checked out in the working directory. With no paths, only files in the repository root are checked out."}</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if source === "url"}
|
||||||
|
<label class="clone-dialog-field clone-url-field">
|
||||||
|
<span>{isGerman ? "Repository-URL" : "Repository URL"}</span>
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input value={remoteUrl} oninput={handleRemoteInput} autocomplete="off" spellcheck="false" placeholder="https://gitlab.com/org/project.git" disabled={isBusy} autofocus />
|
||||||
|
</label>
|
||||||
|
{:else}
|
||||||
|
<section class="integration-browser" aria-label={isGerman ? "Repositories aus Integrationen" : "Repositories from integrations"}>
|
||||||
{#if configuredSources.length === 0}
|
{#if configuredSources.length === 0}
|
||||||
<div class="integration-empty"><Cloud size={26} /><strong>{isGerman ? "Keine aktive Integration" : "No active integration"}</strong><p>{isGerman ? "Richte unter Einstellungen → Integrationen zuerst GitHub, GitLab, Azure DevOps oder Gitea ein." : "Set up GitHub, GitLab, Azure DevOps, or Gitea under Settings → Integrations first."}</p></div>
|
<div class="integration-empty"><Cloud size={26} /><strong>{isGerman ? "Keine aktive Integration" : "No active integration"}</strong><p>{isGerman ? "Richte unter Einstellungen → Integrationen zuerst GitHub, GitLab, Azure DevOps oder Gitea ein." : "Set up GitHub, GitLab, Azure DevOps, or Gitea under Settings → Integrations first."}</p></div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="integration-provider-tabs" role="tablist" aria-label={isGerman ? "Konfigurierte Anbieter" : "Configured providers"}>
|
|
||||||
{#each configuredSources as integrationSource}<button type="button" role="tab" aria-selected={selectedSourceId === integrationSource.id} class:active={selectedSourceId === integrationSource.id} onclick={() => loadRepositories(integrationSource)}>{integrationSource.provider === "azure-devops" ? `Azure · ${integrationSource.label}` : integrationSource.label}</button>{/each}
|
|
||||||
</div>
|
|
||||||
<div class="repository-toolbar">
|
<div class="repository-toolbar">
|
||||||
<label><Search size={14} /><input bind:value={repositorySearch} placeholder={isGerman ? "Repositories filtern…" : "Filter repositories…"} aria-label={isGerman ? "Repositories filtern" : "Filter repositories"} /></label>
|
<label><Search size={14} /><input bind:value={repositorySearch} placeholder={isGerman ? "Repositories durchsuchen…" : "Search repositories…"} aria-label={isGerman ? "Repositories durchsuchen" : "Search repositories"} /></label>
|
||||||
<button type="button" onclick={() => activeSource && loadRepositories(activeSource, true)} disabled={!activeSource || loadingSourceId.length > 0} title={isGerman ? "Neu laden" : "Refresh"} aria-label={isGerman ? "Repository-Liste neu laden" : "Refresh repository list"}><RefreshCw class={loadingSourceId ? "spin" : ""} size={14} /></button>
|
<button type="button" onclick={() => activeSource && loadRepositories(activeSource, true)} disabled={!activeSource || loadingSourceId.length > 0} title={isGerman ? "Neu laden" : "Refresh"} aria-label={isGerman ? "Repository-Liste neu laden" : "Refresh repository list"}><RefreshCw class={loadingSourceId ? "spin" : ""} size={14} /></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="repository-list-shell">
|
<div class="repository-list-shell">
|
||||||
@@ -292,6 +390,19 @@
|
|||||||
<div class="repository-state repository-state-error"><strong>{isGerman ? "Repositories konnten nicht geladen werden" : "Could not load repositories"}</strong><span>{repositoryError}</span></div>
|
<div class="repository-state repository-state-error"><strong>{isGerman ? "Repositories konnten nicht geladen werden" : "Could not load repositories"}</strong><span>{repositoryError}</span></div>
|
||||||
{:else if filteredRepositories.length === 0}
|
{:else if filteredRepositories.length === 0}
|
||||||
<div class="repository-state"><GitBranch size={20} /><span>{repositorySearch ? (isGerman ? "Keine passenden Repositories." : "No matching repositories.") : (isGerman ? "Keine Repositories gefunden." : "No repositories found.")}</span></div>
|
<div class="repository-state"><GitBranch size={20} /><span>{repositorySearch ? (isGerman ? "Keine passenden Repositories." : "No matching repositories.") : (isGerman ? "Keine Repositories gefunden." : "No repositories found.")}</span></div>
|
||||||
|
{:else if activeSource?.provider === "azure-devops"}
|
||||||
|
{#each azureRepositoryGroups as group (group.project)}
|
||||||
|
<section class="repository-project-group" aria-label={group.project}>
|
||||||
|
<div class="repository-project-header"><span>{group.project}</span><em>{group.repositories.length}</em></div>
|
||||||
|
{#each group.repositories as repository (repository.id)}
|
||||||
|
<button type="button" class="repository-option" class:selected={selectedRepositoryId === repository.id} onclick={() => selectRepository(repository)}>
|
||||||
|
<span class="repository-option-icon"><GitBranch size={15} /></span>
|
||||||
|
<span class="repository-option-copy"><strong>{repository.name}</strong><small>{repository.description || repository.cloneUrl}</small></span>
|
||||||
|
<span class="repository-option-meta">{#if repository.private}<LockKeyhole size={12} aria-label={isGerman ? "Privat" : "Private"} />{/if}{formatUpdatedAt(repository.updatedAt)}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
{#each filteredRepositories as repository (repository.id)}
|
{#each filteredRepositories as repository (repository.id)}
|
||||||
<button type="button" class="repository-option" class:selected={selectedRepositoryId === repository.id} onclick={() => selectRepository(repository)}>
|
<button type="button" class="repository-option" class:selected={selectedRepositoryId === repository.id} onclick={() => selectRepository(repository)}>
|
||||||
@@ -331,38 +442,68 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if selectedRepository}<div class="selected-repository"><span>{isGerman ? "Ausgewählt" : "Selected"}</span><strong>{selectedRepository.fullName}</strong><code>{selectedRepository.cloneUrl}</code></div>{/if}
|
{#if selectedRepository}<div class="selected-repository"><span>{isGerman ? "Ausgewählt" : "Selected"}</span><strong>{selectedRepository.fullName}</strong><code>{selectedRepository.cloneUrl}</code></div>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if visibleError}<div class="clone-dialog-error" role="alert">{visibleError}</div>{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="clone-target-grid">
|
|
||||||
<label class="clone-dialog-field"><span>{isGerman ? "Ziel" : "Destination"}</span><div class="clone-dialog-path-field"><input bind:value={parentPath} autocomplete="off" spellcheck="false" placeholder={isGerman ? "Übergeordneten Ordner auswählen" : "Choose parent folder"} disabled={isBusy} /><button class="btn-secondary" type="button" onclick={chooseParentFolder} disabled={isBusy}><FolderOpen size={14} />{isGerman ? "Durchsuchen" : "Browse"}</button></div></label>
|
|
||||||
<label class="clone-dialog-field"><span>{isGerman ? "Ordnername" : "Folder name"}</span><input bind:value={directoryName} oninput={handleDirectoryInput} autocomplete="off" spellcheck="false" placeholder={directorySuggestion || "Optional"} disabled={isBusy} /></label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#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}>{isGerman ? "Abbrechen" : "Cancel"}</button><button class="btn-primary" type="submit" disabled={!canSubmit}>{#if isBusy}<LoaderCircle class="spin" size={16} />{:else}<Download size={16} />{/if}{isGerman ? "Klonen" : "Clone"}</button></div>
|
<div class="clone-dialog-actions"><button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{isGerman ? "Abbrechen" : "Cancel"}</button><button class="btn-primary" type="submit" disabled={!canSubmit}>{#if isBusy}<LoaderCircle class="spin" size={16} />{:else}<Download size={16} />{/if}{isGerman ? "Klonen" : "Clone"}</button></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.clone-repository-dialog { width: min(760px, calc(100vw - 32px)); }
|
.clone-repository-dialog { width: min(900px, calc(100vw - 32px)); height: min(660px, calc(100vh - 32px)); }
|
||||||
.clone-source-tabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; padding: 4px; border: 1px solid var(--color-border-subtle); border-radius: 9px; background: var(--app-settings-row-bg); }
|
.clone-dialog-header { min-height: 52px; padding: 0 16px 0 20px; }
|
||||||
.clone-source-tabs button { min-height: 36px; border-color: transparent; color: var(--color-ink-dim); background: transparent; font-size: 11px; font-weight: 800; }
|
.clone-dialog-header h2 { margin: 0; color: var(--color-ink); font-size: 15px; font-weight: 650; }
|
||||||
.clone-source-tabs button.active { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-raised); box-shadow: 0 2px 8px rgba(0,0,0,.12); }
|
.clone-dialog-form { grid-template-rows: minmax(0, 1fr) auto; gap: 0; height: calc(100% - 53px); padding: 0; }
|
||||||
.clone-source-tabs button.active :global(svg) { color: var(--color-accent); }
|
.clone-dialog-layout { display: grid; grid-template-columns: 205px minmax(0, 1fr); min-height: 0; }
|
||||||
.clone-source-tabs em { display: grid; place-items: center; min-width: 19px; height: 18px; padding: 0 5px; border-radius: 9px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 12%, transparent); font-size: 9px; font-style: normal; }
|
.clone-source-nav { min-width: 0; padding: 12px 0; border-right: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
||||||
.integration-browser { display: grid; gap: 9px; min-height: 270px; padding: 11px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: color-mix(in srgb, var(--app-settings-row-bg) 72%, transparent); }
|
.clone-source-heading { padding: 2px 14px 8px; color: var(--color-ink-faint); font-size: 8.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .08em; }
|
||||||
.integration-provider-tabs { display: flex; gap: 5px; overflow-x: auto; }
|
.clone-source-list { display: grid; gap: 2px; }
|
||||||
.integration-provider-tabs button { flex: 0 0 auto; min-height: 29px; padding: 0 9px; border-color: transparent; color: var(--color-ink-dim); background: transparent; font-size: 10px; font-weight: 750; }
|
.clone-source-list button { display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: 8px; width: 100%; min-height: 38px; padding: 0 14px; border: 0; border-radius: 0; color: var(--color-ink-dim); background: transparent; box-shadow: none; font-size: 10.5px; font-weight: 650; text-align: left; }
|
||||||
.integration-provider-tabs button.active { border-color: color-mix(in srgb, var(--color-accent) 30%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface-raised)); }
|
.clone-source-list button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.clone-source-list button :global(svg) { color: var(--color-ink-faint); }
|
||||||
|
.clone-source-list button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||||
|
.clone-source-list button.active { color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 18%, var(--color-surface-hover)); box-shadow: inset 3px 0 0 var(--color-accent); }
|
||||||
|
.clone-source-list button.active :global(svg) { color: var(--color-accent); }
|
||||||
|
.clone-source-nav > p { margin: 12px 14px 0; color: var(--color-ink-faint); font-size: 9px; line-height: 1.45; }
|
||||||
|
.clone-dialog-content { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr); grid-auto-rows: auto; align-content: stretch; gap: 12px; min-width: 0; min-height: 0; padding: 16px 18px; overflow: hidden; }
|
||||||
|
.clone-dialog-title { display: grid; gap: 3px; }
|
||||||
|
.clone-dialog-title > span { color: var(--color-accent); font-size: 8.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .07em; }
|
||||||
|
.clone-dialog-title h3 { margin: 0; color: var(--color-ink); font-size: 16px; font-weight: 650; }
|
||||||
|
.clone-url-field { align-self: start; margin-top: 2px; }
|
||||||
|
.clone-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: 8px; }
|
||||||
|
.clone-option-card { min-width: 0; border: 1px solid var(--color-border-subtle); border-radius: 6px; background: color-mix(in srgb, var(--color-surface-raised) 70%, transparent); }
|
||||||
|
.clone-option-card.expanded { border-color: color-mix(in srgb, var(--color-accent) 34%, var(--color-border)); }
|
||||||
|
.clone-option-toggle { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 8px; min-height: 42px; padding: 6px 9px; cursor: pointer; }
|
||||||
|
.clone-option-toggle > input { width: 14px; height: 14px; margin: 0; accent-color: var(--color-accent); }
|
||||||
|
.clone-option-toggle > span { display: grid; min-width: 0; gap: 2px; }
|
||||||
|
.clone-option-toggle strong { color: var(--color-ink); font-size: 10.5px; }
|
||||||
|
.clone-option-toggle small { overflow: hidden; color: var(--color-ink-faint); font-size: 8.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.clone-option-body { display: grid; gap: 8px; padding: 1px 9px 9px 31px; }
|
||||||
|
.clone-option-detail { display: grid; grid-template-columns: minmax(82px, auto) minmax(64px, 1fr); align-items: center; gap: 8px; color: var(--color-ink-faint); font-size: 8.5px; font-weight: 750; text-transform: uppercase; letter-spacing: .035em; }
|
||||||
|
.clone-option-detail input { height: 28px; min-width: 0; font-size: 10px; }
|
||||||
|
.clone-history-mode { display: flex; flex-wrap: wrap; align-items: center; gap: 7px 12px; min-width: 0; margin: 0; padding: 0; border: 0; color: var(--color-ink-dim); font-size: 9.5px; }
|
||||||
|
.clone-history-mode legend { float: left; min-width: 100%; margin-bottom: 1px; color: var(--color-ink-faint); font-size: 8.5px; font-weight: 750; text-transform: uppercase; letter-spacing: .035em; }
|
||||||
|
.clone-history-mode label, .clone-blobless-toggle { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; }
|
||||||
|
.clone-history-mode input, .clone-blobless-toggle > input { width: 13px; height: 13px; margin: 0; accent-color: var(--color-accent); }
|
||||||
|
.clone-option-help { color: var(--color-ink-faint); font-size: 8px; line-height: 1.35; }
|
||||||
|
.clone-blobless-toggle { align-items: start; }
|
||||||
|
.clone-blobless-toggle > span { display: grid; gap: 2px; }
|
||||||
|
.clone-blobless-toggle strong { color: var(--color-ink); font-size: 9.5px; }
|
||||||
|
.clone-blobless-toggle small { color: var(--color-ink-faint); font-size: 8px; }
|
||||||
|
.clone-sparse-paths { grid-template-columns: 1fr; gap: 5px; }
|
||||||
|
.clone-sparse-paths textarea { min-height: 54px; max-height: 72px; resize: vertical; font: 9.5px/1.35 var(--font-mono); }
|
||||||
|
.integration-browser { display: grid; grid-template-rows: auto minmax(0, 1fr); grid-auto-rows: auto; gap: 8px; min-height: 0; }
|
||||||
.repository-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) 32px; gap: 6px; }
|
.repository-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) 32px; gap: 6px; }
|
||||||
.repository-toolbar label { position: relative; min-width: 0; }
|
.repository-toolbar label { position: relative; min-width: 0; }
|
||||||
.repository-toolbar label > :global(svg) { position: absolute; z-index: 1; top: 10px; left: 10px; color: var(--color-ink-faint); }
|
.repository-toolbar label > :global(svg) { position: absolute; z-index: 1; top: 10px; left: 10px; color: var(--color-ink-faint); }
|
||||||
.repository-toolbar input { height: 34px; padding-left: 31px; font-size: 11px; }
|
.repository-toolbar input { height: 34px; padding-left: 31px; font-size: 11px; }
|
||||||
.repository-toolbar button { min-height: 32px; padding: 0; }
|
.repository-toolbar button { min-height: 32px; padding: 0; }
|
||||||
.repository-list-shell { position: relative; min-height: 162px; max-height: 250px; overflow: hidden; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
|
.repository-list-shell { position: relative; min-height: 0; height: 100%; overflow: hidden; border: 1px solid var(--color-border-input); border-radius: 7px; background: var(--color-surface-raised); box-shadow: 0 8px 18px rgba(0,0,0,.13); }
|
||||||
.repository-list { min-height: 160px; max-height: 248px; padding-right: 8px; overflow: auto; scrollbar-width: none; border-radius: inherit; background: transparent; }
|
.repository-list { min-height: 0; height: 100%; padding-right: 8px; overflow: auto; scrollbar-width: none; border-radius: inherit; background: transparent; }
|
||||||
.repository-list::-webkit-scrollbar { display: none; width: 0; height: 0; }
|
.repository-list::-webkit-scrollbar { display: none; width: 0; height: 0; }
|
||||||
.repository-scrollbar { position: absolute; z-index: 2; top: 3px; right: 1px; bottom: 3px; width: 7px; border-radius: 4px; opacity: 0; pointer-events: none; touch-action: none; transition: opacity 120ms ease; }
|
.repository-scrollbar { position: absolute; z-index: 2; top: 3px; right: 1px; bottom: 3px; width: 7px; border-radius: 4px; opacity: 0; pointer-events: none; touch-action: none; transition: opacity 120ms ease; }
|
||||||
.repository-scrollbar.visible { opacity: 1; pointer-events: auto; }
|
.repository-scrollbar.visible { opacity: 1; pointer-events: auto; }
|
||||||
@@ -371,28 +512,57 @@
|
|||||||
.repository-scrollbar:focus-visible .repository-scrollbar-thumb,
|
.repository-scrollbar:focus-visible .repository-scrollbar-thumb,
|
||||||
.repository-scrollbar.dragging .repository-scrollbar-thumb { right: 1px; width: 4px; background: var(--app-scrollbar-thumb-hover); }
|
.repository-scrollbar.dragging .repository-scrollbar-thumb { right: 1px; width: 4px; background: var(--app-scrollbar-thumb-hover); }
|
||||||
.repository-scrollbar:focus-visible { outline: 1px solid color-mix(in srgb, var(--color-accent) 70%, transparent); outline-offset: 1px; }
|
.repository-scrollbar:focus-visible { outline: 1px solid color-mix(in srgb, var(--color-accent) 70%, transparent); outline-offset: 1px; }
|
||||||
.repository-option { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 9px; width: 100%; min-height: 52px; padding: 7px 9px; border: 0; border-bottom: 1px solid var(--color-border-subtle); border-radius: 0; color: var(--color-ink-dim); background: transparent; text-align: left; }
|
.repository-project-group + .repository-project-group { border-top: 1px solid var(--color-border-subtle); }
|
||||||
|
.repository-project-header { position: sticky; z-index: 1; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 34px; padding: 7px 10px 6px 12px; color: var(--color-accent); background: color-mix(in srgb, var(--color-surface-raised) 96%, transparent); font-size: 9.5px; font-weight: 900; text-transform: uppercase; letter-spacing: .045em; }
|
||||||
|
.repository-project-header span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.repository-project-header em { display: grid; flex: 0 0 auto; place-items: center; min-width: 19px; height: 16px; padding: 0 5px; color: var(--color-ink); background: color-mix(in srgb, var(--color-ink) 12%, transparent); font-size: 8px; font-style: normal; line-height: 1; letter-spacing: 0; }
|
||||||
|
.repository-option { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 8px; width: 100%; min-height: 44px; padding: 6px 10px 6px 12px; border: 0; border-bottom: 1px solid var(--color-border-subtle); border-radius: 0; color: var(--color-ink-dim); background: transparent; text-align: left; }
|
||||||
.repository-option:last-child { border-bottom: 0; }
|
.repository-option:last-child { border-bottom: 0; }
|
||||||
.repository-option:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
.repository-option:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||||
.repository-option.selected { color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface-hover)); box-shadow: inset 2px 0 0 var(--color-accent); }
|
.repository-option.selected { color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 15%, var(--color-surface-hover)); box-shadow: inset 3px 0 0 var(--color-accent); }
|
||||||
.repository-option-icon { display: grid; place-items: center; width: 30px; height: 30px; border: 1px solid var(--color-border-subtle); border-radius: 7px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 7%, transparent); }
|
.repository-option-icon { display: grid; place-items: center; width: 22px; height: 22px; color: var(--color-ink-faint); }
|
||||||
|
.repository-option.selected .repository-option-icon { color: var(--color-accent); }
|
||||||
.repository-option-copy { display: grid; min-width: 0; gap: 3px; }
|
.repository-option-copy { display: grid; min-width: 0; gap: 3px; }
|
||||||
.repository-option-copy strong, .repository-option-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.repository-option-copy strong, .repository-option-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.repository-option-copy strong { color: inherit; font-size: 10.5px; }
|
.repository-option-copy strong { color: inherit; font-size: 10.5px; }
|
||||||
.repository-option-copy small { color: var(--color-ink-faint); font-size: 9px; }
|
.repository-option-copy small { color: var(--color-ink-faint); font-size: 9px; }
|
||||||
|
.repository-project-group .repository-option { min-height: 38px; padding-block: 5px; }
|
||||||
|
.repository-project-group .repository-option-copy { gap: 0; }
|
||||||
|
.repository-project-group .repository-option-copy strong { color: var(--color-ink); font-size: 11.5px; font-weight: 800; }
|
||||||
|
.repository-project-group .repository-option-copy small { display: none; }
|
||||||
.repository-option-meta { display: flex; align-items: center; gap: 5px; color: var(--color-ink-faint); font-size: 8.5px; }
|
.repository-option-meta { display: flex; align-items: center; gap: 5px; color: var(--color-ink-faint); font-size: 8.5px; }
|
||||||
.repository-state, .integration-empty { display: grid; place-items: center; align-content: center; min-height: 160px; padding: 20px; color: var(--color-ink-faint); text-align: center; }
|
.repository-state, .integration-empty { display: grid; place-items: center; align-content: center; min-height: 188px; padding: 20px; color: var(--color-ink-faint); text-align: center; }
|
||||||
.repository-state { gap: 7px; font-size: 10.5px; }
|
.repository-state { gap: 7px; font-size: 10.5px; }
|
||||||
.repository-state strong, .integration-empty strong { color: var(--color-ink); font-size: 11px; }
|
.repository-state strong, .integration-empty strong { color: var(--color-ink); font-size: 11px; }
|
||||||
.repository-state-error strong { color: #e86060; }
|
.repository-state-error strong { color: #e86060; }
|
||||||
.repository-state-error span { max-width: 520px; line-height: 1.45; }
|
.repository-state-error span { max-width: 520px; line-height: 1.45; }
|
||||||
.integration-empty { min-height: 235px; gap: 8px; }
|
.integration-empty { min-height: 260px; gap: 8px; }
|
||||||
.integration-empty :global(svg) { color: var(--color-accent); }
|
.integration-empty :global(svg) { color: var(--color-accent); }
|
||||||
.integration-empty p { max-width: 400px; margin: 0; color: var(--color-ink-faint); font-size: 10px; line-height: 1.5; }
|
.integration-empty p { max-width: 400px; margin: 0; color: var(--color-ink-faint); font-size: 10px; line-height: 1.5; }
|
||||||
.selected-repository { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 3px 8px; min-width: 0; padding: 8px 9px; border-left: 2px solid var(--color-accent); background: color-mix(in srgb, var(--color-accent) 6%, transparent); }
|
.selected-repository { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 3px 8px; min-width: 0; padding: 8px 9px; border-left: 2px solid var(--color-accent); background: color-mix(in srgb, var(--color-accent) 6%, transparent); }
|
||||||
.selected-repository span { color: var(--color-accent); font-size: 8.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .04em; }
|
.selected-repository span { color: var(--color-accent); font-size: 8.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
.selected-repository strong { overflow: hidden; color: var(--color-ink); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
.selected-repository strong { overflow: hidden; color: var(--color-ink); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.selected-repository code { grid-column: 2; overflow: hidden; color: var(--color-ink-faint); font: 8.5px var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
|
.selected-repository code { grid-column: 2; overflow: hidden; color: var(--color-ink-faint); font: 8.5px var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.clone-target-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(150px, .75fr); gap: 10px; }
|
.clone-target-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(150px, .72fr); gap: 10px; }
|
||||||
@media (max-width: 620px) { .clone-repository-dialog { width: min(620px, calc(100vw - 20px)); } .clone-target-grid { grid-template-columns: 1fr; } .repository-option-meta { display: none; } }
|
.clone-dialog-actions { min-height: 54px; align-items: center; padding: 9px 16px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.clone-repository-dialog { width: min(660px, calc(100vw - 20px)); }
|
||||||
|
.clone-dialog-layout { grid-template-columns: 155px minmax(0, 1fr); }
|
||||||
|
.clone-source-list button { padding-inline: 10px; }
|
||||||
|
.clone-target-grid { grid-template-columns: 1fr; }
|
||||||
|
.repository-option-meta { display: none; }
|
||||||
|
}
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.dialog-backdrop { padding: 10px; }
|
||||||
|
.clone-repository-dialog { width: calc(100vw - 20px); height: min(660px, calc(100vh - 20px)); }
|
||||||
|
.clone-dialog-layout { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
|
||||||
|
.clone-source-nav { padding: 6px 0; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--color-border-subtle); }
|
||||||
|
.clone-source-heading, .clone-source-nav > p { display: none; }
|
||||||
|
.clone-source-list { display: flex; width: max-content; min-width: 100%; padding: 0 6px; }
|
||||||
|
.clone-source-list button { width: auto; min-height: 34px; padding-inline: 9px; border-radius: 5px; }
|
||||||
|
.clone-source-list button.active { box-shadow: inset 0 -2px 0 var(--color-accent); }
|
||||||
|
.clone-options { grid-template-columns: 1fr; }
|
||||||
|
.clone-dialog-content { grid-template-rows: auto; grid-auto-rows: auto; align-content: start; padding: 13px 12px; overflow: auto; }
|
||||||
|
.integration-browser { min-height: 260px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import {
|
import {
|
||||||
ArrowDownToLine, ArrowUpFromLine, Boxes, CircleHelp, FileCode, GitBranch,
|
ArrowDownToLine, ArrowUpFromLine, Boxes, Bug, CircleHelp, FileCode, GitBranch,
|
||||||
GitCompare, History, RefreshCw, Search, Settings, SlidersHorizontal, Sparkles,
|
GitCompare, History, RefreshCw, Search, Settings, SlidersHorizontal, Sparkles,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import type { AppLanguage, GitBranch as GitBranchInfo, GitCommit, GitRepositoryFile } from "../types";
|
import type { AppLanguage, GitBranch as GitBranchInfo, GitCommit, GitRepositoryFile } from "../types";
|
||||||
|
|
||||||
type ItemKind = "fetch" | "pull" | "push" | "refresh" | "search" | "compare" | "reflog" | "rebase" | "worktrees" | "sync" | "settings" | "ai-settings" | "help" | "branch" | "file" | "commit";
|
type ItemKind = "fetch" | "pull" | "push" | "refresh" | "search" | "compare" | "reflog" | "rebase" | "bisect" | "worktrees" | "sync" | "settings" | "ai-settings" | "help" | "branch" | "file" | "commit";
|
||||||
interface Item { id: string; group: string; kind: ItemKind; title: string; subtitle: string; search: string; disabled?: boolean; run: () => void | Promise<void>; }
|
interface Item { id: string; group: string; kind: ItemKind; title: string; subtitle: string; search: string; disabled?: boolean; run: () => void | Promise<void>; }
|
||||||
interface Props {
|
interface Props {
|
||||||
language: AppLanguage; hasRepository: boolean; isBusy: boolean;
|
language: AppLanguage; hasRepository: boolean; isBusy: boolean;
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
onSelectCommit: (commit: GitCommit) => void | Promise<void>;
|
onSelectCommit: (commit: GitCommit) => void | Promise<void>;
|
||||||
onFetch: () => void | Promise<void>; onPull: () => void | Promise<void>; onPush: () => void | Promise<void>;
|
onFetch: () => void | Promise<void>; onPull: () => void | Promise<void>; onPush: () => void | Promise<void>;
|
||||||
onRefresh: () => void | Promise<void>; onOpenSearch: () => void; onOpenCompare: () => void;
|
onRefresh: () => void | Promise<void>; onOpenSearch: () => void; onOpenCompare: () => void;
|
||||||
onOpenReflog: () => void | Promise<void>; onOpenInteractiveRebase: () => void;
|
onOpenReflog: () => void | Promise<void>; onOpenInteractiveRebase: () => void; onOpenBisect: () => void | Promise<void>;
|
||||||
onOpenWorktrees: () => void | Promise<void>; onOpenSyncSettings: () => void | Promise<void>;
|
onOpenWorktrees: () => void | Promise<void>; onOpenSyncSettings: () => void | Promise<void>;
|
||||||
onOpenSettings: () => void; onOpenAiSettings: () => void; onOpenHelp: () => void;
|
onOpenSettings: () => void; onOpenAiSettings: () => void; onOpenHelp: () => void;
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
language = "en", hasRepository = false, isBusy = false, branches = [], files = [], commits = [], onClose = () => {},
|
language = "en", hasRepository = false, isBusy = false, branches = [], files = [], commits = [], onClose = () => {},
|
||||||
onCheckoutBranch = () => {}, onOpenFile = () => {}, onSelectCommit = () => {}, onFetch = () => {}, onPull = () => {},
|
onCheckoutBranch = () => {}, onOpenFile = () => {}, onSelectCommit = () => {}, onFetch = () => {}, onPull = () => {},
|
||||||
onPush = () => {}, onRefresh = () => {}, onOpenSearch = () => {}, onOpenCompare = () => {}, onOpenReflog = () => {},
|
onPush = () => {}, onRefresh = () => {}, onOpenSearch = () => {}, onOpenCompare = () => {}, onOpenReflog = () => {},
|
||||||
onOpenInteractiveRebase = () => {}, onOpenWorktrees = () => {}, onOpenSyncSettings = () => {}, onOpenSettings = () => {},
|
onOpenInteractiveRebase = () => {}, onOpenBisect = () => {}, onOpenWorktrees = () => {}, onOpenSyncSettings = () => {}, onOpenSettings = () => {},
|
||||||
onOpenAiSettings = () => {}, onOpenHelp = () => {},
|
onOpenAiSettings = () => {}, onOpenHelp = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
action("compare", "compare", isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits", isGerman ? "Zwei vollständige Revisionen vergleichen" : "Diff two complete revisions", onOpenCompare),
|
action("compare", "compare", isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits", isGerman ? "Zwei vollständige Revisionen vergleichen" : "Diff two complete revisions", onOpenCompare),
|
||||||
action("reflog", "reflog", "Reflog", isGerman ? "Verlorene Commits finden und wiederherstellen" : "Find and recover lost commits", onOpenReflog),
|
action("reflog", "reflog", "Reflog", isGerman ? "Verlorene Commits finden und wiederherstellen" : "Find and recover lost commits", onOpenReflog),
|
||||||
action("rebase", "rebase", "Interactive Rebase", isGerman ? "Commit-Historie bearbeiten" : "Edit commit history", onOpenInteractiveRebase),
|
action("rebase", "rebase", "Interactive Rebase", isGerman ? "Commit-Historie bearbeiten" : "Edit commit history", onOpenInteractiveRebase),
|
||||||
|
action("bisect", "bisect", "Git Bisect", isGerman ? "Fehlerhaften Commit schrittweise finden" : "Find a bad commit step by step", onOpenBisect),
|
||||||
action("worktrees", "worktrees", "Worktrees", isGerman ? "Arbeitsverzeichnisse verwalten" : "Manage linked working trees", onOpenWorktrees),
|
action("worktrees", "worktrees", "Worktrees", isGerman ? "Arbeitsverzeichnisse verwalten" : "Manage linked working trees", onOpenWorktrees),
|
||||||
action("sync", "sync", isGerman ? "Synchronisierung konfigurieren" : "Configure synchronization", isGerman ? "Remote, Upstream und Pull-Strategie" : "Remote, upstream and pull strategy", onOpenSyncSettings),
|
action("sync", "sync", isGerman ? "Synchronisierung konfigurieren" : "Configure synchronization", isGerman ? "Remote, Upstream und Pull-Strategie" : "Remote, upstream and pull strategy", onOpenSyncSettings),
|
||||||
action("settings", "settings", isGerman ? "Einstellungen" : "Settings", isGerman ? "Darstellung, Sprache und Verhalten" : "Appearance, language and behavior", onOpenSettings, false),
|
action("settings", "settings", isGerman ? "Einstellungen" : "Settings", isGerman ? "Darstellung, Sprache und Verhalten" : "Appearance, language and behavior", onOpenSettings, false),
|
||||||
@@ -118,6 +119,7 @@
|
|||||||
{:else if item.kind === "compare"}<GitCompare size={16} />
|
{:else if item.kind === "compare"}<GitCompare size={16} />
|
||||||
{:else if item.kind === "reflog" || item.kind === "commit"}<History size={16} />
|
{:else if item.kind === "reflog" || item.kind === "commit"}<History size={16} />
|
||||||
{:else if item.kind === "rebase" || item.kind === "branch"}<GitBranch size={16} />
|
{:else if item.kind === "rebase" || item.kind === "branch"}<GitBranch size={16} />
|
||||||
|
{:else if item.kind === "bisect"}<Bug size={16} />
|
||||||
{:else if item.kind === "worktrees"}<Boxes size={16} />
|
{:else if item.kind === "worktrees"}<Boxes size={16} />
|
||||||
{:else if item.kind === "sync"}<SlidersHorizontal size={16} />
|
{:else if item.kind === "sync"}<SlidersHorizontal size={16} />
|
||||||
{:else if item.kind === "settings"}<Settings size={16} />
|
{:else if item.kind === "settings"}<Settings size={16} />
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, GitCommitHorizontal, LoaderCircle, RotateCcw, Settings, ShieldCheck, Sparkles } from "@lucide/svelte";
|
import { Check, GitCommitHorizontal, LoaderCircle, RotateCcw, Settings, ShieldCheck, Sparkles } from "@lucide/svelte";
|
||||||
import type { CommitAiPhase, CommitAiProvider } from "../types";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
commitMessage: string;
|
commitMessage: string;
|
||||||
@@ -10,8 +9,6 @@
|
|||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
operation: string;
|
operation: string;
|
||||||
stagedCount: number;
|
stagedCount: number;
|
||||||
commitAiProvider: CommitAiProvider;
|
|
||||||
commitAiPhase: CommitAiPhase;
|
|
||||||
commitAiGenerating: boolean;
|
commitAiGenerating: boolean;
|
||||||
commitAiReviewing: boolean;
|
commitAiReviewing: boolean;
|
||||||
commitAiSplitting: boolean;
|
commitAiSplitting: boolean;
|
||||||
@@ -35,8 +32,6 @@
|
|||||||
isBusy = false,
|
isBusy = false,
|
||||||
operation = "",
|
operation = "",
|
||||||
stagedCount = 0,
|
stagedCount = 0,
|
||||||
commitAiProvider = "local",
|
|
||||||
commitAiPhase = "idle",
|
|
||||||
commitAiGenerating = false,
|
commitAiGenerating = false,
|
||||||
commitAiReviewing = false,
|
commitAiReviewing = false,
|
||||||
commitAiSplitting = false,
|
commitAiSplitting = false,
|
||||||
@@ -57,23 +52,19 @@
|
|||||||
onCommit();
|
onCommit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function aiButtonTitle(provider: CommitAiProvider, phase: CommitAiPhase, staged: number): string {
|
function aiButtonTitle(staged: number): string {
|
||||||
if (staged === 0) return "Stage changes first";
|
if (staged === 0) return "Stage changes first";
|
||||||
if (provider === "local" && phase === "loading") return "AI model is downloading/loading — this happens once";
|
|
||||||
if (provider === "local" && phase === "error") return "AI model failed to load — check AI settings";
|
|
||||||
return "Generate commit message with AI from the staged diff";
|
return "Generate commit message with AI from the staged diff";
|
||||||
}
|
}
|
||||||
|
|
||||||
let localModelLoading = $derived(commitAiProvider === "local" && commitAiPhase === "loading");
|
|
||||||
let canGenerate = $derived(
|
let canGenerate = $derived(
|
||||||
hasRepository &&
|
hasRepository &&
|
||||||
!isBusy &&
|
!isBusy &&
|
||||||
!commitAiGenerating &&
|
!commitAiGenerating &&
|
||||||
!commitAiReviewing &&
|
!commitAiReviewing &&
|
||||||
stagedCount > 0 &&
|
stagedCount > 0,
|
||||||
(commitAiProvider !== "local" || commitAiPhase === "ready"),
|
|
||||||
);
|
);
|
||||||
let canReview = $derived(canGenerate && commitAiProvider !== "local");
|
let canReview = $derived(canGenerate);
|
||||||
let canSplit = $derived(canReview && stagedCount > 1 && !commitAiSplitting);
|
let canSplit = $derived(canReview && stagedCount > 1 && !commitAiSplitting);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -90,7 +81,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={onSplitStaged}
|
onclick={onSplitStaged}
|
||||||
disabled={!canSplit}
|
disabled={!canSplit}
|
||||||
title={commitAiProvider === "local" ? "Commit splitting currently requires an API provider" : "Suggest logical commits for the staged files"}
|
title="Suggest logical commits for the staged files"
|
||||||
>
|
>
|
||||||
{#if commitAiSplitting}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<GitCommitHorizontal size={14} aria-hidden="true" />{/if}
|
{#if commitAiSplitting}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<GitCommitHorizontal size={14} aria-hidden="true" />{/if}
|
||||||
Split
|
Split
|
||||||
@@ -100,7 +91,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={onReviewStaged}
|
onclick={onReviewStaged}
|
||||||
disabled={!canReview}
|
disabled={!canReview}
|
||||||
title={commitAiProvider === "local" ? "Pre-commit review currently requires an API provider" : stagedCount === 0 ? "Stage changes first" : "Review staged changes for bugs and risks"}
|
title={stagedCount === 0 ? "Stage changes first" : "Review staged changes for bugs and risks"}
|
||||||
>
|
>
|
||||||
{#if commitAiReviewing}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<ShieldCheck size={14} aria-hidden="true" />{/if}
|
{#if commitAiReviewing}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<ShieldCheck size={14} aria-hidden="true" />{/if}
|
||||||
Review
|
Review
|
||||||
@@ -110,9 +101,9 @@
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={onGenerateCommitMessage}
|
onclick={onGenerateCommitMessage}
|
||||||
disabled={!canGenerate}
|
disabled={!canGenerate}
|
||||||
title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)}
|
title={aiButtonTitle(stagedCount)}
|
||||||
>
|
>
|
||||||
{#if commitAiGenerating || localModelLoading}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<Sparkles size={14} aria-hidden="true" />{/if}
|
{#if commitAiGenerating}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<Sparkles size={14} aria-hidden="true" />{/if}
|
||||||
Generate
|
Generate
|
||||||
</button>
|
</button>
|
||||||
<button class="commit-settings-button" type="button" onclick={onOpenAiSettings} disabled={isBusy} title="AI settings" aria-label="AI settings">
|
<button class="commit-settings-button" type="button" onclick={onOpenAiSettings} disabled={isBusy} title="AI settings" aria-label="AI settings">
|
||||||
|
|||||||
@@ -2116,7 +2116,8 @@
|
|||||||
.help-search-icon { position: absolute; left: 12px; display: grid; color: var(--color-ink-faint); pointer-events: none; }
|
.help-search-icon { position: absolute; left: 12px; display: grid; color: var(--color-ink-faint); pointer-events: none; }
|
||||||
.help-search input { width: 100%; height: 40px; padding: 0 68px 0 39px; border-color: var(--color-border-input); border-radius: 8px; background: var(--app-input-bg); color: var(--color-ink); font-size: 12.5px; }
|
.help-search input { width: 100%; height: 40px; padding: 0 68px 0 39px; border-color: var(--color-border-input); border-radius: 8px; background: var(--app-input-bg); color: var(--color-ink); font-size: 12.5px; }
|
||||||
.help-search kbd { position: absolute; right: 8px; }
|
.help-search kbd { position: absolute; right: 8px; }
|
||||||
.help-close { justify-self: end; width: 36px; min-height: 36px; padding: 0; border-color: transparent; border-radius: 7px; background: transparent; color: var(--color-ink-muted); }
|
.help-close { justify-self: end; width: 36px; min-height: 36px; padding: 0; border-color: transparent; border-radius: 7px; background: transparent; color: var(--color-ink-muted); transition: color 120ms ease, border-color 120ms ease, background 120ms ease, box-shadow 120ms ease; }
|
||||||
|
.help-close:hover:not(:disabled), .help-close:focus-visible:not(:disabled) { color: #fff; border-color: #f0646d; background: #d93641; box-shadow: inset 0 0 0 1px rgba(255,255,255,.08); }
|
||||||
|
|
||||||
.help-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); min-height: 0; }
|
.help-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); min-height: 0; }
|
||||||
.help-nav { display: flex; flex-direction: column; min-height: 0; padding: 14px 10px 12px; border-right: 1px solid var(--color-border); background: var(--color-surface-dim); }
|
.help-nav { display: flex; flex-direction: column; min-height: 0; padding: 14px 10px 12px; border-right: 1px solid var(--color-border); background: var(--color-surface-dim); }
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { Folder, FolderOpen, GitBranch, LoaderCircle, Plus, X } from "@lucide/svelte";
|
||||||
|
import type { AppLanguage } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isBusy: boolean;
|
||||||
|
language: AppLanguage;
|
||||||
|
onInit: (path: string, branch: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
isBusy = false,
|
||||||
|
language = "en",
|
||||||
|
onInit = () => {},
|
||||||
|
onClose = () => {},
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let path = $state("");
|
||||||
|
let branch = $state("main");
|
||||||
|
let browseError = $state("");
|
||||||
|
const isGerman = $derived(language === "de");
|
||||||
|
const pathPlaceholder = navigator.userAgent.includes("Windows")
|
||||||
|
? "C:\\Projects\\my-repository"
|
||||||
|
: "/home/user/projects/my-repository";
|
||||||
|
|
||||||
|
function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const targetPath = path.trim();
|
||||||
|
const branchName = branch.trim();
|
||||||
|
if (!targetPath || !branchName || isBusy) return;
|
||||||
|
onInit(targetPath, branchName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape" && !isBusy) onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseFolder() {
|
||||||
|
if (isBusy) return;
|
||||||
|
browseError = "";
|
||||||
|
try {
|
||||||
|
const selected = await openDialog({
|
||||||
|
title: isGerman ? "Ordner für das neue Repository auswählen" : "Select folder for the new repository",
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
defaultPath: path.trim() || undefined,
|
||||||
|
});
|
||||||
|
if (typeof selected === "string") path = selected;
|
||||||
|
} catch (error) {
|
||||||
|
browseError = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
window.addEventListener("keydown", handleKeydown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeydown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
||||||
|
<div
|
||||||
|
class="dialog init-repository-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="init-repository-title"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<header class="dialog-header init-repository-header">
|
||||||
|
<div class="init-repository-heading">
|
||||||
|
<span class="init-repository-icon" aria-hidden="true"><Plus size={18} /></span>
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">{isGerman ? "Neues Repository" : "New repository"}</span>
|
||||||
|
<h2 id="init-repository-title">{isGerman ? "Repository initialisieren" : "Initialize repository"}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={isGerman ? "Schließen" : "Close"}>
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="init-repository-form" onsubmit={submit}>
|
||||||
|
<p class="init-repository-description">
|
||||||
|
{isGerman
|
||||||
|
? "GitLite richtet in diesem Ordner ein neues Git-Repository ein. Vorhandene Dateien bleiben unverändert."
|
||||||
|
: "GitLite will create a new Git repository in this folder. Existing files will remain unchanged."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="new-branch-field init-repository-field init-repository-path-field">
|
||||||
|
<span>{isGerman ? "Zielordner" : "Repository folder"}</span>
|
||||||
|
<div class="init-repository-path-control">
|
||||||
|
<Folder size={16} aria-hidden="true" />
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input
|
||||||
|
bind:value={path}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder={pathPlaceholder}
|
||||||
|
disabled={isBusy}
|
||||||
|
autofocus
|
||||||
|
aria-describedby="init-repository-path-hint"
|
||||||
|
/>
|
||||||
|
<button class="btn-secondary init-repository-browse" type="button" onclick={chooseFolder} disabled={isBusy}>
|
||||||
|
<FolderOpen size={15} aria-hidden="true" />
|
||||||
|
{isGerman ? "Auswählen…" : "Browse…"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small id="init-repository-path-hint">
|
||||||
|
{isGerman ? "Der Ordner wird angelegt, falls er noch nicht existiert." : "The folder will be created if it does not exist."}
|
||||||
|
</small>
|
||||||
|
{#if browseError}<small class="init-repository-error" role="alert">{browseError}</small>{/if}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="new-branch-field init-repository-field">
|
||||||
|
<span>{isGerman ? "Name des ersten Branches" : "Initial branch name"}</span>
|
||||||
|
<div>
|
||||||
|
<GitBranch size={16} aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
bind:value={branch}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="main"
|
||||||
|
disabled={isBusy}
|
||||||
|
aria-describedby="init-repository-branch-hint"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<small id="init-repository-branch-hint">
|
||||||
|
{isGerman ? "Du kannst den Branch später jederzeit umbenennen." : "You can rename the branch at any time."}
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="new-branch-actions init-repository-actions">
|
||||||
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
||||||
|
{isGerman ? "Abbrechen" : "Cancel"}
|
||||||
|
</button>
|
||||||
|
<button class="btn-primary" type="submit" disabled={isBusy || path.trim().length === 0 || branch.trim().length === 0}>
|
||||||
|
{#if isBusy}
|
||||||
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<Plus size={16} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
{isGerman ? "Repository erstellen" : "Create repository"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { Check, GitMerge, LoaderCircle, X } from "@lucide/svelte";
|
||||||
|
import type { AppLanguage, GitBranch, MergeStrategy } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
branch: GitBranch;
|
||||||
|
currentBranch: string;
|
||||||
|
isBusy: boolean;
|
||||||
|
language: AppLanguage;
|
||||||
|
onMerge: (strategy: MergeStrategy) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
branch,
|
||||||
|
currentBranch,
|
||||||
|
isBusy = false,
|
||||||
|
language = "en",
|
||||||
|
onMerge = () => {},
|
||||||
|
onClose = () => {},
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let strategy = $state<MergeStrategy>("default");
|
||||||
|
const isGerman = $derived(language === "de");
|
||||||
|
const options = $derived([
|
||||||
|
{
|
||||||
|
value: "default" as const,
|
||||||
|
label: isGerman ? "Standard" : "Default",
|
||||||
|
description: isGerman ? "Git wählt Fast-forward oder erstellt einen Merge-Commit." : "Git chooses fast-forward or creates a merge commit.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "squash" as const,
|
||||||
|
label: "Squash",
|
||||||
|
description: isGerman ? "Fasst alle Änderungen zu einem neuen Commit zusammen." : "Combines all changes into one new commit.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "ff-only" as const,
|
||||||
|
label: "Fast-forward only",
|
||||||
|
description: isGerman ? "Bricht ab, wenn ein Merge-Commit erforderlich wäre." : "Stops if a merge commit would be required.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "no-ff" as const,
|
||||||
|
label: "No fast-forward",
|
||||||
|
description: isGerman ? "Erstellt immer einen eigenen Merge-Commit." : "Always creates a dedicated merge commit.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!isBusy) onMerge(strategy);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape" && !isBusy) onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
window.addEventListener("keydown", handleKeydown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeydown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
||||||
|
<div class="dialog merge-branch-dialog" role="dialog" aria-modal="true" aria-labelledby="merge-branch-title" tabindex="-1">
|
||||||
|
<header class="dialog-header merge-branch-header">
|
||||||
|
<div class="merge-branch-heading">
|
||||||
|
<span class="merge-branch-icon" aria-hidden="true"><GitMerge size={18} /></span>
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">{isGerman ? "Branches zusammenführen" : "Combine branches"}</span>
|
||||||
|
<h2 id="merge-branch-title">{isGerman ? "Merge konfigurieren" : "Configure merge"}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={isGerman ? "Schließen" : "Close"}>
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="merge-branch-form" onsubmit={submit}>
|
||||||
|
<div class="merge-branch-route" aria-label={isGerman ? "Merge-Richtung" : "Merge direction"}>
|
||||||
|
<div>
|
||||||
|
<span>{isGerman ? "Quell-Branch" : "Source branch"}</span>
|
||||||
|
<strong>{branch.name}</strong>
|
||||||
|
</div>
|
||||||
|
<GitMerge size={18} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<span>{isGerman ? "In aktuellen Branch" : "Into current branch"}</span>
|
||||||
|
<strong>{currentBranch || "HEAD"}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="merge-strategy-fieldset" disabled={isBusy}>
|
||||||
|
<legend>{isGerman ? "Merge-Strategie" : "Merge strategy"}</legend>
|
||||||
|
<div class="merge-strategy-grid">
|
||||||
|
{#each options as option}
|
||||||
|
<label class:active={strategy === option.value} class="merge-strategy-option">
|
||||||
|
<input type="radio" name="merge-strategy" value={option.value} bind:group={strategy} />
|
||||||
|
<span class="merge-strategy-check" aria-hidden="true">
|
||||||
|
{#if strategy === option.value}<Check size={13} />{/if}
|
||||||
|
</span>
|
||||||
|
<span class="merge-strategy-copy">
|
||||||
|
<strong>{option.label}</strong>
|
||||||
|
<small>{option.description}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="merge-branch-actions">
|
||||||
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{isGerman ? "Abbrechen" : "Cancel"}</button>
|
||||||
|
<button class="btn-primary" type="submit" disabled={isBusy}>
|
||||||
|
{#if isBusy}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<GitMerge size={16} aria-hidden="true" />{/if}
|
||||||
|
{isGerman ? "Branch mergen" : "Merge branch"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.repo-loading {
|
.repo-loading {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: calc(var(--app-titlebar-height, 42px) + 56px);
|
top: calc(var(--app-titlebar-height, 40px) + var(--app-repo-tabbar-height, 36px));
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -47,8 +47,8 @@
|
|||||||
color-mix(in srgb, var(--color-accent) 13%, transparent),
|
color-mix(in srgb, var(--color-accent) 13%, transparent),
|
||||||
transparent 38%
|
transparent 38%
|
||||||
),
|
),
|
||||||
var(--app-dialog-backdrop);
|
color-mix(in srgb, var(--app-dialog-bg) 78%, #05070a 22%);
|
||||||
backdrop-filter: blur(5px);
|
backdrop-filter: blur(9px);
|
||||||
animation: overlay-in 180ms ease;
|
animation: overlay-in 180ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, Circle, Download, FolderGit2, GitBranch, GitPullRequest, LoaderCircle, Plus, Search, Star, TriangleAlert, Trash2, X } from "@lucide/svelte";
|
||||||
|
import { listIntegrationReviewRequests, listRemotes } from "../git";
|
||||||
|
import { configuredIntegrationSources, integrationCredentialKey } from "../integrations";
|
||||||
|
import type { GitIntegrationSettings, GitIntegrationSource, IntegrationReviewRequest, StoredCredential } from "../types";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
|
interface DashboardRepository {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
branch: string | null;
|
||||||
|
ahead: number;
|
||||||
|
behind: number;
|
||||||
|
changed: number;
|
||||||
|
lastOpened: number;
|
||||||
|
known: boolean;
|
||||||
|
favorite: boolean;
|
||||||
|
isOpen: boolean;
|
||||||
|
recent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Workspace { id: string; name: string; }
|
||||||
|
interface PullRequestBadgeState {
|
||||||
|
status: "loading" | "ready" | "error" | "unavailable";
|
||||||
|
count: number;
|
||||||
|
sourceId: string;
|
||||||
|
repository: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoteIdentity { host: string; repository: string; organization: string; }
|
||||||
|
|
||||||
|
export let repos: DashboardRepository[] = [];
|
||||||
|
export let language: "de" | "en" = "en";
|
||||||
|
export let isBusy = false;
|
||||||
|
export let onOpen: (path: string) => unknown;
|
||||||
|
export let onClose: (path: string) => unknown;
|
||||||
|
export let onAdd: () => unknown;
|
||||||
|
export let onClone: () => unknown;
|
||||||
|
export let onInit: () => unknown;
|
||||||
|
export let onFavorite: (path: string) => unknown;
|
||||||
|
export let onRemoveRecent: (path: string) => unknown;
|
||||||
|
export let integrations: GitIntegrationSettings;
|
||||||
|
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||||
|
export let onOpenPullRequests: (repository: string, sourceId: string) => unknown;
|
||||||
|
|
||||||
|
const STORAGE_KEY = "gitty.dashboard.v1";
|
||||||
|
let query = "";
|
||||||
|
let selectedWorkspace = "";
|
||||||
|
let collapsedCategories = new Set<string>();
|
||||||
|
let workspaces: Workspace[] = [];
|
||||||
|
let assignments: Record<string, string> = {};
|
||||||
|
let workspaceDialog: HTMLDialogElement;
|
||||||
|
let workspaceInput: HTMLInputElement;
|
||||||
|
let workspaceName = "";
|
||||||
|
let workspaceError = "";
|
||||||
|
let workspaceSelection = new Set<string>();
|
||||||
|
let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
|
||||||
|
let pullRequestLoadGeneration = 0;
|
||||||
|
let lastPullRequestLoadKey = "";
|
||||||
|
|
||||||
|
$: de = language === "de";
|
||||||
|
$: normalizedQuery = query.trim().toLocaleLowerCase();
|
||||||
|
$: filteredRepos = repos.filter((repo) => {
|
||||||
|
const matchesSearch = !normalizedQuery || `${repo.name} ${repo.path} ${repo.branch ?? ""}`.toLocaleLowerCase().includes(normalizedQuery);
|
||||||
|
return matchesSearch && (!selectedWorkspace || assignments[repo.path] === selectedWorkspace);
|
||||||
|
});
|
||||||
|
$: openRepos = filteredRepos.filter((repo) => repo.isOpen);
|
||||||
|
$: favoriteRepos = filteredRepos.filter((repo) => repo.favorite);
|
||||||
|
$: recentRepos = filteredRepos.filter((repo) => repo.recent && !repo.isOpen);
|
||||||
|
$: categories = [
|
||||||
|
{ id: "open", label: "Open", repos: openRepos },
|
||||||
|
{ id: "favorites", label: de ? "Favoriten" : "Favorites", repos: favoriteRepos },
|
||||||
|
{ id: "recent", label: "Recent", repos: recentRepos },
|
||||||
|
];
|
||||||
|
$: changedCount = repos.filter((repo) => repo.known && repo.changed > 0).length;
|
||||||
|
$: workspaceOptions = [
|
||||||
|
{ value: "", label: de ? "Alle Workspaces" : "All workspaces" },
|
||||||
|
...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name, group: "Workspaces" })),
|
||||||
|
];
|
||||||
|
$: integrationSources = configuredIntegrationSources(integrations);
|
||||||
|
$: pullRequestLoadKey = JSON.stringify({
|
||||||
|
repositories: repos.map((repo) => repo.path),
|
||||||
|
sources: integrationSources.map((source) => [source.id, source.baseUrl]),
|
||||||
|
});
|
||||||
|
$: if (pullRequestLoadKey !== lastPullRequestLoadKey) {
|
||||||
|
lastPullRequestLoadKey = pullRequestLoadKey;
|
||||||
|
void loadPullRequestBadges();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPreferences();
|
||||||
|
|
||||||
|
function loadPreferences() {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") as { workspaces?: unknown; assignments?: unknown };
|
||||||
|
if (Array.isArray(saved.workspaces)) {
|
||||||
|
workspaces = saved.workspaces.filter((item): item is Workspace => Boolean(
|
||||||
|
item && typeof item === "object"
|
||||||
|
&& "id" in item && typeof item.id === "string" && item.id.startsWith("workspace-")
|
||||||
|
&& "name" in item && typeof item.name === "string" && item.name.trim(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (saved.assignments && typeof saved.assignments === "object" && !Array.isArray(saved.assignments)) {
|
||||||
|
assignments = Object.fromEntries(Object.entries(saved.assignments).filter(([, workspaceId]) =>
|
||||||
|
typeof workspaceId === "string" && workspaces.some((workspace) => workspace.id === workspaceId)));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
workspaces = [];
|
||||||
|
assignments = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePreferences() {
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ workspaces, assignments })); }
|
||||||
|
catch { /* Preferences remain optional when storage is unavailable. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openWorkspaceDialog() {
|
||||||
|
workspaceName = "";
|
||||||
|
workspaceError = "";
|
||||||
|
workspaceSelection = new Set();
|
||||||
|
workspaceDialog.showModal();
|
||||||
|
requestAnimationFrame(() => workspaceInput.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleWorkspaceRepo(path: string) {
|
||||||
|
const next = new Set(workspaceSelection);
|
||||||
|
if (next.has(path)) next.delete(path);
|
||||||
|
else next.add(path);
|
||||||
|
workspaceSelection = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWorkspace(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const name = workspaceName.trim();
|
||||||
|
if (!name) {
|
||||||
|
workspaceError = de ? "Bitte einen Namen eingeben." : "Please enter a name.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (workspaces.some((workspace) => workspace.name.toLocaleLowerCase() === name.toLocaleLowerCase())) {
|
||||||
|
workspaceError = de ? "Dieser Workspace existiert bereits." : "This workspace already exists.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = `workspace-${crypto.randomUUID()}`;
|
||||||
|
workspaces = [...workspaces, { id, name }];
|
||||||
|
assignments = { ...assignments, ...Object.fromEntries([...workspaceSelection].map((path) => [path, id])) };
|
||||||
|
selectedWorkspace = id;
|
||||||
|
savePreferences();
|
||||||
|
workspaceDialog.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSelectedWorkspace() {
|
||||||
|
if (!selectedWorkspace) return;
|
||||||
|
const deletedWorkspace = selectedWorkspace;
|
||||||
|
workspaces = workspaces.filter((workspace) => workspace.id !== deletedWorkspace);
|
||||||
|
assignments = Object.fromEntries(Object.entries(assignments).filter(([, workspaceId]) => workspaceId !== deletedWorkspace));
|
||||||
|
selectedWorkspace = "";
|
||||||
|
savePreferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCard(path: string) { if (!isBusy) onOpen(path); }
|
||||||
|
function closeCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onClose(path); }
|
||||||
|
function favoriteCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onFavorite(path); }
|
||||||
|
function removeRecentCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onRemoveRecent(path); }
|
||||||
|
function openPullRequests(event: MouseEvent, repo: DashboardRepository) {
|
||||||
|
event.stopPropagation();
|
||||||
|
const badge = pullRequestBadges[repo.path];
|
||||||
|
if (!isBusy && badge?.status === "ready") onOpenPullRequests(badge.repository || repo.name, badge.sourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remoteIdentity(value: string): RemoteIdentity | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
let host = "";
|
||||||
|
let path = "";
|
||||||
|
const scp = trimmed.match(/^[^@\s]+@([^:\s]+):(.+)$/);
|
||||||
|
if (scp) {
|
||||||
|
host = scp[1];
|
||||||
|
path = scp[2];
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
|
||||||
|
host = url.hostname;
|
||||||
|
path = url.pathname;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parts = path.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").split("/").filter(Boolean);
|
||||||
|
const gitIndex = parts.findIndex((part) => part.toLocaleLowerCase() === "_git");
|
||||||
|
const normalizedHost = host.toLocaleLowerCase();
|
||||||
|
if (gitIndex > 0 && parts[gitIndex + 1]) return { host: normalizedHost, repository: `${parts[gitIndex - 1]}/${parts[gitIndex + 1]}`.toLocaleLowerCase(), organization: normalizedHost === "dev.azure.com" ? (parts[0] ?? "").toLocaleLowerCase() : "" };
|
||||||
|
if (parts[0]?.toLocaleLowerCase() === "v3" && parts.length >= 4) return { host: normalizedHost, repository: `${parts[2]}/${parts[3]}`.toLocaleLowerCase(), organization: parts[1].toLocaleLowerCase() };
|
||||||
|
return { host: normalizedHost, repository: parts.join("/").toLocaleLowerCase(), organization: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceMatchesRemote(source: GitIntegrationSource, identity: RemoteIdentity): boolean {
|
||||||
|
try {
|
||||||
|
const sourceUrl = new URL(source.baseUrl);
|
||||||
|
const sourceHost = sourceUrl.hostname.toLocaleLowerCase();
|
||||||
|
const remoteHost = identity.host;
|
||||||
|
if (source.provider === "azure-devops" ? sourceHost.replace(/^ssh\./, "") !== remoteHost.replace(/^ssh\./, "") : sourceHost !== remoteHost) return false;
|
||||||
|
if (source.provider !== "azure-devops") return true;
|
||||||
|
const organization = sourceUrl.hostname.toLocaleLowerCase() === "dev.azure.com"
|
||||||
|
? sourceUrl.pathname.split("/").filter(Boolean)[0]?.toLocaleLowerCase() ?? ""
|
||||||
|
: sourceUrl.hostname.split(".")[0]?.toLocaleLowerCase() ?? "";
|
||||||
|
return !organization || !identity.organization || organization === identity.organization;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestMatchesRepository(request: IntegrationReviewRequest, repository: string): boolean {
|
||||||
|
const requestRepository = request.repositoryName.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLocaleLowerCase();
|
||||||
|
if (!requestRepository) return false;
|
||||||
|
if (requestRepository === repository || repository.endsWith(`/${requestRepository}`) || requestRepository.endsWith(`/${repository}`)) return true;
|
||||||
|
const requestParts = requestRepository.split("/");
|
||||||
|
const remoteParts = repository.split("/");
|
||||||
|
return requestParts[requestParts.length - 1] === remoteParts[remoteParts.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePullRequestBadge(path: string, state: PullRequestBadgeState, generation: number) {
|
||||||
|
if (generation !== pullRequestLoadGeneration) return;
|
||||||
|
pullRequestBadges = { ...pullRequestBadges, [path]: state };
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||||
|
promise.then((value) => { window.clearTimeout(timer); resolve(value); }, (error) => { window.clearTimeout(timer); reject(error); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPullRequestBadges() {
|
||||||
|
const generation = ++pullRequestLoadGeneration;
|
||||||
|
const sources = integrationSources;
|
||||||
|
pullRequestBadges = Object.fromEntries(repos.map((repo) => [repo.path, {
|
||||||
|
status: sources.length ? "loading" : "unavailable",
|
||||||
|
count: 0,
|
||||||
|
sourceId: "",
|
||||||
|
repository: "",
|
||||||
|
message: sources.length ? "" : (de ? "Keine passende Integration eingerichtet." : "No matching integration configured."),
|
||||||
|
} satisfies PullRequestBadgeState]));
|
||||||
|
if (!repos.length || !sources.length) return;
|
||||||
|
|
||||||
|
const grouped = new Map<string, Array<{ repo: DashboardRepository; identity: RemoteIdentity }>>();
|
||||||
|
await Promise.all(repos.map(async (repo) => {
|
||||||
|
try {
|
||||||
|
const remotes = await listRemotes(repo.path);
|
||||||
|
const identities = remotes
|
||||||
|
.sort((left, right) => Number(right.name === "origin") - Number(left.name === "origin"))
|
||||||
|
.map((remote) => remoteIdentity(remote.fetch_url))
|
||||||
|
.filter((identity): identity is RemoteIdentity => Boolean(identity));
|
||||||
|
const match = identities.map((identity) => ({ identity, source: sources.find((source) => sourceMatchesRemote(source, identity)) })).find((item) => item.source);
|
||||||
|
if (!match?.source) {
|
||||||
|
updatePullRequestBadge(repo.path, { status: "unavailable", count: 0, sourceId: "", repository: "", message: de ? "Keine passende Integration für das Remote gefunden." : "No matching integration for this remote." }, generation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grouped.set(match.source.id, [...(grouped.get(match.source.id) ?? []), { repo, identity: match.identity }]);
|
||||||
|
} catch (error) {
|
||||||
|
updatePullRequestBadge(repo.path, { status: "error", count: 0, sourceId: "", repository: "", message: error instanceof Error ? error.message : String(error) }, generation);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
await Promise.all([...grouped.entries()].map(async ([sourceId, entries]) => {
|
||||||
|
const source = sources.find((candidate) => candidate.id === sourceId);
|
||||||
|
if (!source) return;
|
||||||
|
try {
|
||||||
|
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000, de ? "Der System-Schlüsselbund hat nicht geantwortet." : "The system keychain did not respond.");
|
||||||
|
if (!credential?.password) throw new Error(de ? "Kein Integrationstoken gespeichert." : "No integration token stored.");
|
||||||
|
const requests = await withTimeout(listIntegrationReviewRequests(source.provider, source.baseUrl, credential.username, credential.password, "open"), 46_000, de ? `${source.label} hat nicht geantwortet.` : `${source.label} did not respond.`);
|
||||||
|
for (const { repo, identity } of entries) {
|
||||||
|
updatePullRequestBadge(repo.path, {
|
||||||
|
status: "ready",
|
||||||
|
count: requests.filter((request) => requestMatchesRepository(request, identity.repository)).length,
|
||||||
|
sourceId,
|
||||||
|
repository: identity.repository,
|
||||||
|
message: "",
|
||||||
|
}, generation);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
for (const { repo, identity } of entries) updatePullRequestBadge(repo.path, { status: "error", count: 0, sourceId, repository: identity.repository, message }, generation);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pullRequestTitle(repo: DashboardRepository): string {
|
||||||
|
const badge = pullRequestBadges[repo.path];
|
||||||
|
if (!badge || badge.status === "loading") return de ? "Offene Pull Requests werden geladen" : "Loading open pull requests";
|
||||||
|
if (badge.status === "error" || badge.status === "unavailable") return badge.message;
|
||||||
|
return de ? `${badge.count} offene Pull Requests` : `${badge.count} open pull requests`;
|
||||||
|
}
|
||||||
|
function toggleCategory(id: string) {
|
||||||
|
const next = new Set(collapsedCategories);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
collapsedCategories = next;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="repo-dashboard" aria-label={de ? "Repository-Verwaltung" : "Repository management"}>
|
||||||
|
<header class="dashboard-header">
|
||||||
|
<h1>Repository Management</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button type="button" onclick={onClone} disabled={isBusy}><Download size={14} />{de ? "Klonen" : "Clone"}</button>
|
||||||
|
<button type="button" onclick={onInit} disabled={isBusy}><FolderGit2 size={14} />{de ? "Initialisieren" : "Initialize"}</button>
|
||||||
|
<button class="primary" type="button" onclick={onAdd} disabled={isBusy}><Plus size={14} />Repository</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="dashboard-toolbar">
|
||||||
|
<label class="dashboard-search">
|
||||||
|
<Search size={14} />
|
||||||
|
<input bind:value={query} autocomplete="off" spellcheck="false" placeholder={de ? "Repositories suchen …" : "Search repositories …"} aria-label={de ? "Repositories suchen" : "Search repositories"} />
|
||||||
|
</label>
|
||||||
|
<div class="workspace-tools">
|
||||||
|
<SelectMenu
|
||||||
|
class="workspace-select"
|
||||||
|
value={selectedWorkspace}
|
||||||
|
options={workspaceOptions}
|
||||||
|
ariaLabel={de ? "Workspace auswählen" : "Select workspace"}
|
||||||
|
onChange={(value) => { selectedWorkspace = value; }}
|
||||||
|
/>
|
||||||
|
{#if selectedWorkspace}
|
||||||
|
<button class="workspace-delete" type="button" onclick={deleteSelectedWorkspace} aria-label={de ? "Ausgewählten Workspace löschen" : "Delete selected workspace"} title={de ? "Workspace löschen" : "Delete workspace"}><Trash2 size={15} strokeWidth={2} /></button>
|
||||||
|
{/if}
|
||||||
|
<button type="button" onclick={openWorkspaceDialog}><Plus size={14} />Workspace</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-summary">{repos.length} Repositories <span>·</span> {changedCount} {de ? "mit Änderungen" : "with changes"}</div>
|
||||||
|
|
||||||
|
<div class="dashboard-content">
|
||||||
|
{#each categories as category (category.id)}
|
||||||
|
<section class="dashboard-section">
|
||||||
|
<header class="section-header">
|
||||||
|
<button class="section-toggle" type="button" aria-expanded={!collapsedCategories.has(category.id)} onclick={() => toggleCategory(category.id)}>
|
||||||
|
{#if collapsedCategories.has(category.id)}<ChevronRight size={14} />{:else}<ChevronDown size={14} />{/if}
|
||||||
|
<strong>{category.label}</strong><span>{category.repos.length}</span><i></i>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
{#if !collapsedCategories.has(category.id)}
|
||||||
|
{#if category.repos.length > 0}
|
||||||
|
<div class="repo-grid">
|
||||||
|
{#each category.repos as repo (repo.path)}
|
||||||
|
{@const pullRequests = pullRequestBadges[repo.path]}
|
||||||
|
<article class="repo-card" class:open={repo.isOpen}>
|
||||||
|
<button class="card-main" type="button" onclick={() => openCard(repo.path)} disabled={isBusy} title={repo.path}>
|
||||||
|
<span class="card-title"><FolderGit2 size={15} /><strong>{repo.name}</strong></span>
|
||||||
|
<span class="card-path">{repo.path}</span>
|
||||||
|
<span class="card-branch"><GitBranch size={13} />{repo.branch ?? (de ? "Branch unbekannt" : "Unknown branch")}</span>
|
||||||
|
<span class="card-status">
|
||||||
|
{#if !repo.known}<span class="muted"><Circle size={10} />{de ? "Status nicht geladen" : "Status not loaded"}</span>
|
||||||
|
{:else if repo.changed > 0}<span class="changed"><Circle class="filled" size={9} />{repo.changed} {de ? (repo.changed === 1 ? "Änderung" : "Änderungen") : (repo.changed === 1 ? "change" : "changes")}</span>
|
||||||
|
{:else}<span class="clean"><Circle class="filled" size={9} />{de ? "Sauber" : "Clean"}</span>{/if}
|
||||||
|
{#if repo.ahead > 0}<span class="ahead"><ArrowUp size={13} />{repo.ahead} {de ? "voraus" : "ahead"}</span>{/if}
|
||||||
|
{#if repo.behind > 0}<span class="behind"><ArrowDown size={13} />{repo.behind} {de ? "zurück" : "behind"}</span>{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class:has-open-prs={pullRequests?.status === "ready" && pullRequests.count > 0} class:pr-error={pullRequests?.status === "error"} class="pr-badge" type="button" onclick={(event) => openPullRequests(event, repo)} disabled={isBusy || !pullRequests || pullRequests.status !== "ready"} aria-label={`${repo.name}: ${pullRequestTitle(repo)}`} title={pullRequestTitle(repo)}>
|
||||||
|
{#if !pullRequests || pullRequests.status === "loading"}<LoaderCircle class="spin" size={14} />{:else if pullRequests.status === "error"}<TriangleAlert size={14} />{:else}<GitPullRequest size={14} /><span>{pullRequests.status === "ready" ? pullRequests.count : "–"}</span>{/if}
|
||||||
|
</button>
|
||||||
|
<button class:active={repo.favorite} type="button" onclick={(event) => favoriteCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Favorit umschalten" : "Toggle favorite"}`} aria-pressed={repo.favorite}><Star size={15} /></button>
|
||||||
|
{#if repo.isOpen}<button type="button" onclick={(event) => closeCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Schließen" : "Close"}`} title={de ? "Repository schließen" : "Close repository"}><X size={15} /></button>{/if}
|
||||||
|
{#if category.id === "recent"}<button type="button" onclick={(event) => removeRecentCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Aus Recent entfernen" : "Remove from recent"}`} title={de ? "Aus Recent entfernen" : "Remove from recent"}><X size={15} /></button>{/if}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="category-empty">{de ? "Keine Repositories in dieser Kategorie." : "No repositories in this category."}</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<footer class="dashboard-footer">{repos.length} {de ? "lokale Repositories" : "local repositories"}</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<dialog class="workspace-dialog" bind:this={workspaceDialog} aria-labelledby="workspace-title">
|
||||||
|
<form onsubmit={createWorkspace}>
|
||||||
|
<header><h2 id="workspace-title">{de ? "Workspace anlegen" : "Create workspace"}</h2><button type="button" onclick={() => workspaceDialog.close()} aria-label={de ? "Dialog schließen" : "Close dialog"}><X size={14} /></button></header>
|
||||||
|
<label for="workspace-name">Name</label>
|
||||||
|
<input id="workspace-name" bind:this={workspaceInput} bind:value={workspaceName} maxlength="64" autocomplete="off" aria-invalid={Boolean(workspaceError)} />
|
||||||
|
{#if workspaceError}<p class="dialog-error" role="alert">{workspaceError}</p>{/if}
|
||||||
|
{#if repos.length > 0}
|
||||||
|
<fieldset><legend>{de ? "Repositories zuordnen" : "Assign repositories"}</legend><div class="workspace-repos">
|
||||||
|
{#each repos as repo (repo.path)}<label><input type="checkbox" checked={workspaceSelection.has(repo.path)} onchange={() => toggleWorkspaceRepo(repo.path)} /><span>{repo.name}</span><small>{repo.path}</small></label>{/each}
|
||||||
|
</div></fieldset>
|
||||||
|
{/if}
|
||||||
|
<footer><button type="button" onclick={() => workspaceDialog.close()}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit"><Check size={14} />{de ? "Anlegen" : "Create"}</button></footer>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.repo-dashboard{display:flex;flex:1;min-height:0;flex-direction:column;overflow:hidden;color:var(--color-ink);background:var(--app-bg);font-size:12px}button,input{font:inherit}button{color:inherit;cursor:pointer}button:disabled{cursor:default;opacity:.5}
|
||||||
|
.dashboard-header{display:flex;min-height:58px;align-items:center;justify-content:space-between;gap:16px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-header h1{margin:0;font-size:18px;line-height:1.2;font-weight:700}.header-actions,.workspace-tools{display:flex;align-items:center;gap:8px}.header-actions button,.workspace-tools button{display:inline-flex;min-height:30px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)}button.primary{border-color:var(--color-primary);background:var(--color-primary);color:#fff}
|
||||||
|
.dashboard-toolbar{display:flex;min-height:48px;align-items:center;gap:8px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-search{display:flex;width:300px;height:30px;align-items:center;gap:8px;padding:0 9px;border:1px solid var(--color-border-input);border-radius:var(--ui-radius-sm);color:var(--color-ink-faint);background:var(--app-input-bg)}.dashboard-search:focus-within{border-color:var(--color-accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--color-primary) 14%,transparent)}.dashboard-search :global(svg){flex:0 0 auto}.dashboard-search input{width:100%;min-width:0;height:100%;padding:0;border:0;border-radius:0;outline:0;color:var(--color-ink);background:transparent;box-shadow:none}.dashboard-search input:focus,.dashboard-search input:focus-visible{border:0;outline:0;box-shadow:none}.workspace-tools{margin-left:auto}.workspace-tools :global(.workspace-select){width:240px}.workspace-tools :global(.workspace-select .select-menu-trigger){height:30px;min-height:30px;padding:0 9px;border-color:var(--color-border-input);background:var(--app-input-bg);font-size:12px;font-weight:500}.workspace-tools .workspace-delete{width:30px;min-width:30px;padding:0;border-color:color-mix(in srgb,#df626b 55%,var(--color-border));color:#df747b;background:color-mix(in srgb,#c92f3a 8%,var(--app-button-bg))}.workspace-tools .workspace-delete:hover:not(:disabled){border-color:#df626b;color:#fff;background:#c93b45;box-shadow:0 0 0 2px color-mix(in srgb,#c92f3a 18%,transparent)}
|
||||||
|
.dashboard-summary{padding:8px 20px;color:var(--color-ink-muted)}.dashboard-summary span{padding:0 6px;color:var(--color-ink-faint)}.dashboard-content{flex:1;min-height:0;overflow:auto;padding:0 20px 24px}.dashboard-section+.dashboard-section{margin-top:12px}.section-header{min-height:31px}.section-toggle{display:flex;width:100%;min-height:31px;align-items:center;justify-content:flex-start;gap:7px;padding:0;border:0;background:transparent;color:var(--color-ink);text-align:left}.section-toggle:hover:not(:disabled){border:0;background:transparent;color:var(--color-ink)}.section-toggle :global(svg){flex:0 0 auto;color:var(--color-ink-muted)}.section-toggle strong{font-size:13px;line-height:1.2;font-weight:750}.section-toggle span{color:var(--color-ink-faint);font-weight:400}.section-toggle i{height:1px;flex:1;background:var(--color-border-subtle)}.category-empty{margin:0;padding:9px 12px;border:1px solid var(--color-border-subtle);color:var(--color-ink-faint);background:var(--color-surface)}
|
||||||
|
.repo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.repo-card{position:relative;min-width:0;height:138px;border:1px solid var(--color-border);background:var(--color-surface)}.repo-card:hover,.repo-card:focus-within{border-color:var(--color-border-input);background:var(--color-surface-hover)}.card-main{display:flex;width:100%;height:100%;flex-direction:column;align-items:flex-start;gap:7px;padding:12px 104px 11px 14px;border:0;background:transparent;text-align:left}.card-main:focus-visible{outline:2px solid var(--color-accent);outline-offset:-2px}.card-title{display:flex;max-width:100%;align-items:center;gap:8px}.card-title strong{overflow:hidden;font-size:14px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.card-path{width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-branch,.card-status,.card-status>span{display:flex;align-items:center;gap:6px}.card-branch{max-width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-status{flex-wrap:wrap;gap:8px 18px;margin-top:auto}.card-status :global(.filled){fill:currentColor}.changed,.behind{color:#eeb94e}.clean{color:#68c878}.ahead{color:var(--color-accent)}.muted{color:var(--color-ink-muted)}
|
||||||
|
.card-actions{position:absolute;top:8px;right:8px;z-index:2;display:flex;align-items:center;gap:2px}.card-actions button{display:grid;width:26px;height:26px;place-items:center;padding:0;border:0;color:var(--color-ink-muted);background:transparent}.card-actions button:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-dim)}.card-actions button.active{color:var(--color-accent)}.card-actions button.active :global(svg){fill:color-mix(in srgb,var(--color-accent) 22%,transparent)}.card-actions .pr-badge{display:flex;width:auto;min-width:31px;grid-template-columns:none;align-items:center;justify-content:center;gap:4px;padding:0 5px;color:var(--color-ink-faint);font-size:11px}.card-actions .pr-badge:disabled{opacity:.72}.card-actions .pr-badge.has-open-prs{color:var(--color-accent)}.card-actions .pr-badge.pr-error{color:#e0a35b}.card-actions .pr-badge:hover:not(:disabled){color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 9%,transparent)}
|
||||||
|
.dashboard-footer{display:flex;min-height:30px;align-items:center;padding:0 20px;border-top:1px solid var(--color-border-subtle);color:var(--color-ink-muted)}
|
||||||
|
.workspace-dialog{width:min(420px,calc(100vw - 32px));margin:auto;padding:0;border:1px solid var(--color-border);color:var(--color-ink);background:var(--app-dialog-bg);box-shadow:var(--app-dialog-shadow);font-size:12px}.workspace-dialog::backdrop{background:var(--app-dialog-backdrop)}.workspace-dialog form>header,.workspace-dialog form>footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--app-dialog-chrome)}.workspace-dialog form>header{border-bottom:1px solid var(--color-border)}.workspace-dialog form>footer{justify-content:flex-end;gap:8px;border-top:1px solid var(--color-border)}.workspace-dialog h2{margin:0;font-size:13px}.workspace-dialog header button{width:26px;height:26px;padding:0;border:0;background:transparent}.workspace-dialog form>label,.workspace-dialog form>input,.workspace-dialog fieldset,.dialog-error{margin-right:12px;margin-left:12px}.workspace-dialog form>label{display:block;margin-top:12px;margin-bottom:5px;color:var(--color-ink-muted)}.workspace-dialog form>input{width:calc(100% - 24px);height:30px;padding:0 8px;border:1px solid var(--color-border-input);color:var(--color-ink);background:var(--app-input-bg)}.dialog-error{margin-top:7px;color:#ed9292}.workspace-dialog fieldset{margin-top:14px;margin-bottom:14px;padding:0;border:0}.workspace-dialog legend{margin-bottom:6px;color:var(--color-ink-muted)}.workspace-repos{max-height:190px;overflow:auto;border:1px solid var(--color-border-subtle)}.workspace-repos label{display:grid;grid-template-columns:auto minmax(100px,auto) 1fr;align-items:center;gap:8px;min-height:32px;padding:4px 8px;border-bottom:1px solid var(--color-border-subtle)}.workspace-repos label:last-child{border-bottom:0}.workspace-repos small{overflow:hidden;color:var(--color-ink-faint);text-overflow:ellipsis;white-space:nowrap}.workspace-dialog footer button{display:inline-flex;min-height:28px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)}
|
||||||
|
@media(max-width:1000px){.dashboard-toolbar{flex-wrap:wrap;gap:8px}.dashboard-search{flex:1 1 260px}.workspace-tools{margin-left:0}.repo-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||||
|
@media(max-width:680px){.dashboard-header{min-height:auto;align-items:flex-start;flex-direction:column}.header-actions{flex-wrap:wrap}.workspace-tools{width:100%}.workspace-tools :global(.workspace-select){min-width:0;flex:1}.repo-grid{grid-template-columns:1fr}.repo-card{height:132px}}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,631 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
import { cubicOut } from "svelte/easing";
|
||||||
|
import { fly } from "svelte/transition";
|
||||||
|
import {
|
||||||
|
AlertTriangle, AtSign, Bold, Check, ChevronDown, ChevronRight, Code2,
|
||||||
|
CircleCheck, Clock3, CircleDotDashed, ExternalLink, GitBranch, GitMerge, GitPullRequest, Inbox, Italic,
|
||||||
|
Link, List, LoaderCircle, PanelRightOpen, RefreshCw, RotateCcw, Search, Settings2, X, XCircle,
|
||||||
|
} from "@lucide/svelte";
|
||||||
|
import { addIntegrationReviewComment, getIntegrationReviewDetails, listIntegrationReviewRequests, openInBrowser, runIntegrationReviewAction } from "../git";
|
||||||
|
import { configuredIntegrationSources, integrationCredentialKey, providerLabel } from "../integrations";
|
||||||
|
import type { AppLanguage, GitIntegrationSettings, IntegrationReviewAction, IntegrationReviewRequest, IntegrationReviewState, StoredCredential } from "../types";
|
||||||
|
|
||||||
|
type LocalResolutionPhase = "idle" | "preparing" | "conflicts" | "ready-to-continue" | "ready-to-push" | "complete" | "error";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
language: AppLanguage;
|
||||||
|
integrations: GitIntegrationSettings;
|
||||||
|
initialQuery?: string;
|
||||||
|
initialSourceId?: string;
|
||||||
|
localResolutionRequestId?: string;
|
||||||
|
localResolutionPhase?: LocalResolutionPhase;
|
||||||
|
localResolutionMessage?: string;
|
||||||
|
loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
onStartLocalResolution?: (request: IntegrationReviewRequest, sourceId: string) => void | Promise<void>;
|
||||||
|
onOpenLocalResolver?: () => void | Promise<void>;
|
||||||
|
onContinueLocalResolution?: () => void | Promise<void>;
|
||||||
|
onAbortLocalResolution?: () => void | Promise<void>;
|
||||||
|
onPushLocalResolution?: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { language = "en", integrations, initialQuery = "", initialSourceId = "", localResolutionRequestId = "", localResolutionPhase = "idle", localResolutionMessage = "", loadCredential, onOpenSettings = () => {}, onStartLocalResolution = () => {}, onOpenLocalResolver = () => {}, onContinueLocalResolution = () => {}, onAbortLocalResolution = () => {}, onPushLocalResolution = () => {} }: Props = $props();
|
||||||
|
let requests = $state<IntegrationReviewRequest[]>([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let errors = $state<Array<{ source: string; message: string }>>([]);
|
||||||
|
let query = $state("");
|
||||||
|
let stateFilter = $state<IntegrationReviewState>("open");
|
||||||
|
let selectedSourceId = $state("");
|
||||||
|
let selectedId = $state("");
|
||||||
|
let detailOpen = $state(false);
|
||||||
|
let detailPanel = $state<HTMLElement>();
|
||||||
|
let actionMenuId = $state("");
|
||||||
|
let actionBusyId = $state("");
|
||||||
|
let actionNotice = $state("");
|
||||||
|
let detailLoadingId = $state("");
|
||||||
|
let commentDraft = $state("");
|
||||||
|
let commentTextarea = $state<HTMLTextAreaElement>();
|
||||||
|
let commentPosting = $state(false);
|
||||||
|
let collapsedRepositories = $state(new Set<string>());
|
||||||
|
let loadGeneration = 0;
|
||||||
|
let loadedStates = $state(new Set<"open" | "merged" | "closed">());
|
||||||
|
let completedResolutionRefreshKey = "";
|
||||||
|
|
||||||
|
const SOURCE_TIMEOUT_MS = 46_000;
|
||||||
|
const de = $derived(language === "de");
|
||||||
|
const sources = $derived(configuredIntegrationSources(integrations));
|
||||||
|
const activeSource = $derived(sources.find((source) => source.id === selectedSourceId) ?? null);
|
||||||
|
const normalizedQuery = $derived(query.trim().toLocaleLowerCase());
|
||||||
|
const filtered = $derived(requests.filter((request) => {
|
||||||
|
const haystack = `${request.title} ${request.repositoryName} ${request.author} ${request.number} ${request.sourceBranch} ${request.targetBranch}`.toLocaleLowerCase();
|
||||||
|
return request.state === stateFilter && (!normalizedQuery || haystack.includes(normalizedQuery));
|
||||||
|
}));
|
||||||
|
const groupedRequests = $derived.by(() => {
|
||||||
|
const groups = new Map<string, IntegrationReviewRequest[]>();
|
||||||
|
for (const request of filtered) {
|
||||||
|
const key = request.repositoryName || providerLabel(request.provider);
|
||||||
|
groups.set(key, [...(groups.get(key) ?? []), request]);
|
||||||
|
}
|
||||||
|
return [...groups.entries()].map(([name, items]) => ({ name, items }));
|
||||||
|
});
|
||||||
|
const selected = $derived(requests.find((request) => request.id === selectedId) ?? null);
|
||||||
|
const backendRestartRequired = $derived(errors.some((error) => /command.*not found|unknown command|not registered/i.test(error.message)));
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const closeActionMenu = () => { actionMenuId = ""; };
|
||||||
|
const closeDetailsOutside = (event: PointerEvent) => {
|
||||||
|
if (detailOpen && detailPanel && event.target instanceof Node && !detailPanel.contains(event.target)) {
|
||||||
|
detailOpen = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("click", closeActionMenu);
|
||||||
|
window.addEventListener("pointerdown", closeDetailsOutside, true);
|
||||||
|
query = initialQuery;
|
||||||
|
const source = sources.find((candidate) => candidate.id === initialSourceId) ?? sources[0];
|
||||||
|
if (source) {
|
||||||
|
selectedSourceId = source.id;
|
||||||
|
void loadRequests("open", source.id);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("click", closeActionMenu);
|
||||||
|
window.removeEventListener("pointerdown", closeDetailsOutside, true);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const key = localResolutionPhase === "complete" && localResolutionRequestId && selectedSourceId
|
||||||
|
? `${selectedSourceId}:${localResolutionRequestId}`
|
||||||
|
: "";
|
||||||
|
if (!key || key === completedResolutionRefreshKey) return;
|
||||||
|
completedResolutionRefreshKey = key;
|
||||||
|
void refreshCompletedResolution(key, localResolutionRequestId, selectedSourceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
function withTimeout<T>(promise: Promise<T>, source: string, timeoutMs = SOURCE_TIMEOUT_MS): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const seconds = Math.round(timeoutMs / 1_000);
|
||||||
|
const timer = window.setTimeout(() => reject(new Error(de ? `${source} hat nach ${seconds} Sekunden nicht geantwortet.` : `${source} did not respond within ${seconds} seconds.`)), timeoutMs);
|
||||||
|
promise.then((value) => { window.clearTimeout(timer); resolve(value); }, (error) => { window.clearTimeout(timer); reject(error); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRequests(state: "open" | "merged" | "closed" = stateFilter === "draft" ? "open" : stateFilter, sourceId = selectedSourceId) {
|
||||||
|
const source = sources.find((candidate) => candidate.id === sourceId);
|
||||||
|
if (!source) return;
|
||||||
|
const generation = ++loadGeneration;
|
||||||
|
loading = true;
|
||||||
|
errors = [];
|
||||||
|
requests = requests.filter((request) => state === "open" ? request.state !== "open" && request.state !== "draft" : request.state !== state);
|
||||||
|
selectedId = "";
|
||||||
|
detailOpen = false;
|
||||||
|
actionMenuId = "";
|
||||||
|
actionNotice = "";
|
||||||
|
try {
|
||||||
|
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
|
||||||
|
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
|
||||||
|
const loaded = await withTimeout(listIntegrationReviewRequests(source.provider, source.baseUrl, credential.username, credential.password, state), source.label);
|
||||||
|
if (generation !== loadGeneration) return;
|
||||||
|
requests = [...requests, ...loaded].sort((left, right) => Date.parse(right.updatedAt || right.createdAt) - Date.parse(left.updatedAt || left.createdAt));
|
||||||
|
loadedStates = new Set([...loadedStates, state]);
|
||||||
|
const missingBranches = loaded.filter((request) => !request.sourceBranch || !request.targetBranch);
|
||||||
|
if (missingBranches.length) void enrichMissingBranches(missingBranches, source, credential, generation);
|
||||||
|
} catch (error) {
|
||||||
|
if (generation !== loadGeneration) return;
|
||||||
|
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
|
||||||
|
} finally {
|
||||||
|
if (generation === loadGeneration) loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichMissingBranches(items: IntegrationReviewRequest[], source: (typeof sources)[number], credential: StoredCredential, generation: number) {
|
||||||
|
const queue = [...items];
|
||||||
|
const worker = async () => {
|
||||||
|
while (queue.length && generation === loadGeneration) {
|
||||||
|
const request = queue.shift();
|
||||||
|
if (!request) return;
|
||||||
|
try {
|
||||||
|
const details = await withTimeout(getIntegrationReviewDetails(source.provider, source.baseUrl, credential.username, credential.password, request), source.label, 20_000);
|
||||||
|
if (generation !== loadGeneration) return;
|
||||||
|
requests = requests.map((item) => item.id === details.id ? details : item);
|
||||||
|
} catch {
|
||||||
|
// Keep the request visible; selecting it retries the detail request and surfaces the error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await Promise.all(Array.from({ length: Math.min(4, queue.length) }, worker));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSource(sourceId: string) {
|
||||||
|
if (sourceId === selectedSourceId) return;
|
||||||
|
selectedSourceId = sourceId;
|
||||||
|
stateFilter = "open";
|
||||||
|
requests = [];
|
||||||
|
errors = [];
|
||||||
|
selectedId = "";
|
||||||
|
detailOpen = false;
|
||||||
|
actionMenuId = "";
|
||||||
|
collapsedRepositories = new Set();
|
||||||
|
loadedStates = new Set();
|
||||||
|
void loadRequests("open", sourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectState(state: IntegrationReviewState) {
|
||||||
|
stateFilter = state;
|
||||||
|
selectedId = "";
|
||||||
|
detailOpen = false;
|
||||||
|
const apiState = state === "draft" ? "open" : state;
|
||||||
|
if (!loadedStates.has(apiState)) void loadRequests(apiState);
|
||||||
|
}
|
||||||
|
|
||||||
|
function count(state: IntegrationReviewState): number {
|
||||||
|
return requests.filter((request) => request.state === state).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countLabel(state: IntegrationReviewState): string {
|
||||||
|
return loadedStates.has(state === "draft" ? "open" : state) ? String(count(state)) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateLabel(state: IntegrationReviewState): string {
|
||||||
|
if (state === "draft") return "Draft";
|
||||||
|
if (state === "merged") return de ? "Zusammengeführt" : "Merged";
|
||||||
|
if (state === "closed") return de ? "Geschlossen" : "Closed";
|
||||||
|
return "Open";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
if (!value) return "–";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "–";
|
||||||
|
return new Intl.DateTimeFormat(de ? "de-DE" : "en-US", { dateStyle: "medium", timeStyle: "short" }).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeDate(value: string): string {
|
||||||
|
if (!value) return "–";
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
if (Number.isNaN(timestamp)) return "–";
|
||||||
|
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
|
||||||
|
if (minutes < 1) return de ? "gerade eben" : "just now";
|
||||||
|
if (minutes < 60) return de ? `vor ${minutes} Min.` : `${minutes}m ago`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
if (hours < 24) return de ? `vor ${hours} Std.` : `${hours}h ago`;
|
||||||
|
const days = Math.round(hours / 24);
|
||||||
|
return de ? `vor ${days} T.` : `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initials(name: string): string {
|
||||||
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (!parts.length) return "?";
|
||||||
|
return (parts.length > 1 ? parts[0][0] + parts[parts.length - 1][0] : parts[0].slice(0, 2)).toLocaleUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionLabel(provider: IntegrationReviewRequest["provider"]): string {
|
||||||
|
return provider === "azure-devops" ? "Open in Azure" : `Open in ${providerLabel(provider)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestTypeLabel(provider: IntegrationReviewRequest["provider"]): string {
|
||||||
|
return provider === "gitlab" || provider === "gitlab-self-hosted" ? "Merge Request" : "Pull Request";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRepository(name: string) {
|
||||||
|
const next = new Set(collapsedRepositories);
|
||||||
|
if (next.has(name)) next.delete(name);
|
||||||
|
else next.add(name);
|
||||||
|
collapsedRepositories = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatComment(prefix: string, suffix = prefix) {
|
||||||
|
const textarea = commentTextarea;
|
||||||
|
if (!textarea) return;
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const selection = commentDraft.slice(start, end);
|
||||||
|
commentDraft = `${commentDraft.slice(0, start)}${prefix}${selection}${suffix}${commentDraft.slice(end)}`;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
textarea.focus();
|
||||||
|
const cursorStart = start + prefix.length;
|
||||||
|
textarea.setSelectionRange(cursorStart, cursorStart + selection.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRequest(request: IntegrationReviewRequest, showDetail = false) {
|
||||||
|
if (selectedId !== request.id) commentDraft = "";
|
||||||
|
selectedId = request.id;
|
||||||
|
if (showDetail) detailOpen = true;
|
||||||
|
if (request.changedFiles === null && detailLoadingId !== request.id) void loadDetails(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(request: IntegrationReviewRequest) {
|
||||||
|
selectRequest(request, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetails(request: IntegrationReviewRequest) {
|
||||||
|
const source = activeSource;
|
||||||
|
if (!source) return;
|
||||||
|
detailLoadingId = request.id;
|
||||||
|
try {
|
||||||
|
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
|
||||||
|
if (!credential?.password) return;
|
||||||
|
const details = await withTimeout(getIntegrationReviewDetails(source.provider, source.baseUrl, credential.username, credential.password, request), source.label);
|
||||||
|
requests = requests.map((item) => item.id === details.id ? details : item);
|
||||||
|
} catch (error) {
|
||||||
|
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
|
||||||
|
} finally { detailLoadingId = ""; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCompletedResolution(key: string, requestId: string, sourceId: string) {
|
||||||
|
const source = sources.find((candidate) => candidate.id === sourceId);
|
||||||
|
const initialRequest = requests.find((candidate) => candidate.id === requestId);
|
||||||
|
if (!source || !initialRequest) return;
|
||||||
|
let request: IntegrationReviewRequest = initialRequest;
|
||||||
|
actionNotice = de ? "Branch wurde gepusht. Der Merge-Status wird geprüft …" : "Branch pushed. Checking the merge status …";
|
||||||
|
try {
|
||||||
|
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
|
||||||
|
if (!credential?.password) return;
|
||||||
|
for (const delay of [700, 1_500, 3_000, 5_000]) {
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, delay));
|
||||||
|
if (completedResolutionRefreshKey !== key || selectedSourceId !== sourceId) return;
|
||||||
|
const details: IntegrationReviewRequest = await withTimeout(getIntegrationReviewDetails(source.provider, source.baseUrl, credential.username, credential.password, request), source.label, 20_000);
|
||||||
|
requests = requests.map((candidate) => candidate.id === details.id ? details : candidate);
|
||||||
|
request = details;
|
||||||
|
if (details.mergeStatus === "mergeable") {
|
||||||
|
actionNotice = de ? "Der Konflikt ist gelöst. Der Request kann jetzt zusammengeführt werden." : "The conflict is resolved. The request can now be merged.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (details.mergeStatus !== "conflicts" && details.mergeStatus !== "checking") return;
|
||||||
|
}
|
||||||
|
actionNotice = de ? "Der Anbieter prüft den neuen Branch noch. Aktualisiere den Status in einigen Sekunden erneut." : "The provider is still checking the updated branch. Refresh the status again in a few seconds.";
|
||||||
|
} catch (error) {
|
||||||
|
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasConflicts(request: IntegrationReviewRequest): boolean { return request.mergeStatus === "conflicts"; }
|
||||||
|
|
||||||
|
function isLocalResolutionActive(request: IntegrationReviewRequest): boolean {
|
||||||
|
return request.id === localResolutionRequestId && localResolutionPhase !== "idle";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWaitingForResolvedStatus(request: IntegrationReviewRequest): boolean {
|
||||||
|
return isLocalResolutionActive(request) && localResolutionPhase === "complete" && hasConflicts(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startLocalResolution(request: IntegrationReviewRequest) {
|
||||||
|
const source = activeSource;
|
||||||
|
if (!source || !request.sourceBranch || !request.targetBranch) return;
|
||||||
|
actionMenuId = "";
|
||||||
|
selectedId = request.id;
|
||||||
|
detailOpen = true;
|
||||||
|
await onStartLocalResolution(request, source.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPrimaryAction(request: IntegrationReviewRequest) {
|
||||||
|
if (isWaitingForResolvedStatus(request)) return;
|
||||||
|
if (hasConflicts(request)) await startLocalResolution(request);
|
||||||
|
else if (request.state === "open" || request.state === "draft") await performReviewAction(request, "merge");
|
||||||
|
else if (request.state === "closed") await performReviewAction(request, "reopen");
|
||||||
|
else await openRequest(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
function primaryActionLabel(request: IntegrationReviewRequest): string {
|
||||||
|
if (isWaitingForResolvedStatus(request)) return de ? "Status wird geprüft" : "Checking status";
|
||||||
|
if (hasConflicts(request)) return de ? "Konflikt lösen" : "Resolve conflict";
|
||||||
|
if (request.state === "open" || request.state === "draft") return de ? "Zusammenführen" : "Merge";
|
||||||
|
if (request.state === "closed") return de ? "Wieder öffnen" : "Reopen";
|
||||||
|
return actionLabel(request.provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openRequest(request: IntegrationReviewRequest | null = selected) {
|
||||||
|
if (!request?.webUrl) return;
|
||||||
|
try { await openInBrowser(request.webUrl); }
|
||||||
|
catch (error) { errors = [{ source: providerLabel(request.provider), message: error instanceof Error ? error.message : String(error) }]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewActionLabel(action: IntegrationReviewAction): string {
|
||||||
|
if (action === "merge") return de ? "Zusammenführen" : "Merge";
|
||||||
|
if (action === "approve") return de ? "Freigeben" : "Approve";
|
||||||
|
if (action === "close") return de ? "Request schließen" : "Close request";
|
||||||
|
return de ? "Request wieder öffnen" : "Reopen request";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performReviewAction(request: IntegrationReviewRequest, action: IntegrationReviewAction) {
|
||||||
|
const source = activeSource;
|
||||||
|
if (!source || actionBusyId) return;
|
||||||
|
if (action !== "approve") {
|
||||||
|
const prompt = action === "merge"
|
||||||
|
? (de ? `Request #${request.number} wirklich zusammenführen?` : `Merge request #${request.number}?`)
|
||||||
|
: action === "close"
|
||||||
|
? (de ? `Request #${request.number} wirklich schließen?` : `Close request #${request.number}?`)
|
||||||
|
: (de ? `Request #${request.number} wieder öffnen?` : `Reopen request #${request.number}?`);
|
||||||
|
if (!window.confirm(prompt)) return;
|
||||||
|
}
|
||||||
|
actionMenuId = "";
|
||||||
|
actionNotice = "";
|
||||||
|
actionBusyId = request.id;
|
||||||
|
errors = [];
|
||||||
|
try {
|
||||||
|
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
|
||||||
|
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
|
||||||
|
await withTimeout(runIntegrationReviewAction(source.provider, source.baseUrl, credential.username, credential.password, request, action), source.label);
|
||||||
|
actionNotice = de ? `${reviewActionLabel(action)} erfolgreich.` : `${reviewActionLabel(action)} succeeded.`;
|
||||||
|
requests = [];
|
||||||
|
loadedStates = new Set();
|
||||||
|
await loadRequests(stateFilter === "draft" ? "open" : stateFilter);
|
||||||
|
} catch (error) {
|
||||||
|
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
|
||||||
|
} finally {
|
||||||
|
actionBusyId = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitComment() {
|
||||||
|
const request = selected;
|
||||||
|
const source = activeSource;
|
||||||
|
const body = commentDraft.trim();
|
||||||
|
if (!request || !source || !body || commentPosting) return;
|
||||||
|
commentPosting = true;
|
||||||
|
errors = [];
|
||||||
|
try {
|
||||||
|
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
|
||||||
|
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
|
||||||
|
await withTimeout(addIntegrationReviewComment(source.provider, source.baseUrl, credential.username, credential.password, request, body), source.label);
|
||||||
|
commentDraft = "";
|
||||||
|
await loadDetails(request);
|
||||||
|
} catch (error) {
|
||||||
|
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
|
||||||
|
} finally { commentPosting = false; }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class:has-inspector={detailOpen && !!selected} class="review-center" aria-label="Review Center">
|
||||||
|
<header class="review-header">
|
||||||
|
<div class="review-heading"><GitPullRequest size={17} /><h1>Review Center</h1></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if sources.length === 0}
|
||||||
|
<div class="empty-state"><div class="empty-icon"><GitPullRequest size={23} /></div><h2>{de ? "Keine Integration eingerichtet" : "No integration configured"}</h2><p>{de ? "Verbinde GitHub, GitLab, Gitea oder Azure DevOps, um Pull- und Merge-Requests hier zu sehen." : "Connect GitHub, GitLab, Gitea, or Azure DevOps to see pull and merge requests here."}</p><button class="primary" type="button" onclick={onOpenSettings}><Settings2 size={14} />{de ? "Integrationen öffnen" : "Open integrations"}</button></div>
|
||||||
|
{:else}
|
||||||
|
<div class="review-toolbar">
|
||||||
|
<nav class="state-tabs" aria-label={de ? "Request-Status" : "Request status"}>
|
||||||
|
{#each ["open", "draft", "merged", "closed"] as state}
|
||||||
|
<button class:active={stateFilter === state} type="button" onclick={() => selectState(state as IntegrationReviewState)}>{state === "draft" ? (de ? "Entwürfe" : "Drafts") : stateLabel(state as IntegrationReviewState)}{#if countLabel(state as IntegrationReviewState)}<span>{countLabel(state as IntegrationReviewState)}</span>{/if}</button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
<label class="review-search"><Search size={14} /><input bind:value={query} placeholder={de ? "Requests durchsuchen …" : "Search requests …"} /></label>
|
||||||
|
<div class="integration-picker">
|
||||||
|
<SelectMenu value={selectedSourceId} options={sources.map(source => ({ value: source.id, label: source.label }))} ariaLabel={de ? "Integration auswählen" : "Select integration"} onChange={selectSource} />
|
||||||
|
</div>
|
||||||
|
<div class="group-actions">
|
||||||
|
<button class="icon-button" title={de ? "Aktualisieren" : "Refresh"} aria-label={de ? "Aktualisieren" : "Refresh"} type="button" onclick={() => loadRequests()} disabled={loading}><RefreshCw class={loading ? "spin" : ""} size={14} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if actionNotice}<div class="action-notice"><Check size={13} />{actionNotice}</div>{/if}
|
||||||
|
|
||||||
|
{#if errors.length > 0}
|
||||||
|
<details class="source-warning" open>
|
||||||
|
<summary><AlertTriangle size={14} /><span>{backendRestartRequired ? (de ? "Gitty muss neu gestartet werden, damit das Review-Backend geladen wird." : "Restart Gitty to load the Review Center backend.") : (de ? "Die ausgewählte Integration konnte nicht geladen werden." : "The selected integration could not be loaded.")}</span></summary>
|
||||||
|
<ul>{#each errors as error}<li><strong>{error.source}:</strong> {error.message}</li>{/each}</ul>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class:inspector-open={detailOpen && selected} class="review-layout">
|
||||||
|
<main class="table-pane">
|
||||||
|
<div class="table-head">
|
||||||
|
<span>Status</span><span>Request</span><span>{de ? "Autor" : "Author"}</span><span>{de ? "Prüfer" : "Reviewers"}</span><span>{de ? "Repository / Branch" : "Repository / Branch"}</span><span>{de ? "Aktion" : "Action"}</span>
|
||||||
|
</div>
|
||||||
|
{#if loading && requests.length === 0}
|
||||||
|
<div class="loading-state"><LoaderCircle class="spin" size={17} />{de ? "Requests werden geladen …" : "Loading requests …"}</div>
|
||||||
|
{:else if groupedRequests.length === 0}
|
||||||
|
<div class="list-empty"><Inbox size={20} /><strong>{de ? "Keine passenden Requests" : "No matching requests"}</strong><span>{de ? "Passe Status oder Suche an." : "Adjust the status or search."}</span></div>
|
||||||
|
{:else}
|
||||||
|
<div class="request-groups">
|
||||||
|
{#each groupedRequests as group (group.name)}
|
||||||
|
<section class="request-group">
|
||||||
|
<button class="group-header" type="button" onclick={() => toggleRepository(group.name)}>
|
||||||
|
{#if collapsedRepositories.has(group.name)}<ChevronRight size={14} />{:else}<ChevronDown size={14} />{/if}
|
||||||
|
<GitBranch size={13} /><strong>{group.name}</strong><span>{group.items.length}</span>
|
||||||
|
</button>
|
||||||
|
{#if !collapsedRepositories.has(group.name)}
|
||||||
|
{#each group.items as request (request.id)}
|
||||||
|
<div class:selected={selectedId === request.id} class="request-row" role="button" tabindex="0" onclick={() => selectRequest(request)} onkeydown={(event) => { if (event.key === "Enter") selectRequest(request); }}>
|
||||||
|
<span class="request-status" class:open={request.state === "open"} class:draft={request.state === "draft"} class:merged={request.state === "merged"} class:closed={request.state === "closed"}>
|
||||||
|
{#if request.state === "merged"}<GitMerge size={13} />{:else if request.state === "closed"}<XCircle size={13} />{:else if request.state === "draft"}<CircleDotDashed size={13} />{:else}<GitPullRequest size={13} />{/if}
|
||||||
|
<span>{stateLabel(request.state)}<small title={formatDate(request.updatedAt || request.createdAt)}>{formatRelativeDate(request.updatedAt || request.createdAt)}</small></span>
|
||||||
|
</span>
|
||||||
|
<span class="request-title"><span><code>#{request.number}</code><strong title={request.title}>{request.title}</strong></span><small class="change-stats"><b>+{request.additions ?? "–"}</b><i>/</i><em>−{request.deletions ?? "–"}</em>{#if request.changedFiles !== null}<span title={de ? "Geänderte Dateien" : "Changed files"}>{request.changedFiles} {request.changedFiles === 1 ? (de ? "Datei geändert" : "file changed") : (de ? "Dateien geändert" : "files changed")}</span>{:else if detailLoadingId === request.id}<span>{de ? "Lädt …" : "Loading …"}</span>{/if}</small></span>
|
||||||
|
<span class="request-author"><i>{initials(request.author)}</i><span>{request.author || (de ? "Unbekannt" : "Unknown")}</span></span>
|
||||||
|
<span class="collaborators">{#if request.collaborators.length}{#each request.collaborators.slice(0, 3) as collaborator}<i title={collaborator}>{initials(collaborator)}</i>{/each}{:else}<span>–</span>{/if}</span>
|
||||||
|
<span class="repo-branch"><strong>{request.repositoryName}</strong>{#if request.sourceBranch && request.targetBranch}<span class="branch-route"><GitBranch size={11} /><code title={request.sourceBranch}>{request.sourceBranch}</code><b>→</b><code title={request.targetBranch}>{request.targetBranch}</code></span>{:else}<span class="branch-loading"><LoaderCircle class="spin" size={11} />{de ? "Branches werden geladen …" : "Loading branches …"}</span>{/if}</span>
|
||||||
|
<span class="row-actions">
|
||||||
|
<span class:merge-action={request.state === "open" || request.state === "draft"} class:conflict-action={hasConflicts(request)} class="provider-action">
|
||||||
|
<button class="provider-button" class:merge-primary={request.state === "open" || request.state === "draft"} class:conflict={hasConflicts(request)} disabled={!!actionBusyId || isWaitingForResolvedStatus(request) || (hasConflicts(request) && (!request.sourceBranch || !request.targetBranch || (isLocalResolutionActive(request) && localResolutionPhase === "preparing")))} type="button" onclick={(event) => { event.stopPropagation(); void runPrimaryAction(request); }}>{#if actionBusyId === request.id || isWaitingForResolvedStatus(request) || (isLocalResolutionActive(request) && localResolutionPhase === "preparing")}<LoaderCircle class="spin" size={12} />{:else if request.state === "open" || request.state === "draft"}<GitMerge size={12} />{:else}<ExternalLink size={12} />{/if}{primaryActionLabel(request)}</button>
|
||||||
|
<button class="action-toggle" class:active={actionMenuId === request.id} type="button" aria-label={de ? "Weitere Aktionen" : "More actions"} onclick={(event) => { event.stopPropagation(); actionMenuId = actionMenuId === request.id ? "" : request.id; }}><ChevronDown size={13} /></button>
|
||||||
|
{#if actionMenuId === request.id}
|
||||||
|
<span class="action-menu" role="menu" tabindex="-1">
|
||||||
|
{#if request.state === "open" || request.state === "draft"}
|
||||||
|
{#if isWaitingForResolvedStatus(request)}<button class="menu-merge" type="button" role="menuitem" disabled><LoaderCircle class="spin" size={13} />{de ? "Merge-Status wird geprüft" : "Checking merge status"}</button>{:else if hasConflicts(request)}<button class="menu-conflict" type="button" role="menuitem" disabled={!request.sourceBranch || !request.targetBranch || (isLocalResolutionActive(request) && localResolutionPhase === "preparing")} onclick={() => void startLocalResolution(request)}><GitMerge size={13} />{de ? "Konflikt lokal lösen" : "Resolve conflict locally"}</button>{:else}<button class="menu-merge" type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "merge")}><GitMerge size={13} />{reviewActionLabel("merge")}</button>{/if}
|
||||||
|
<button class="menu-approve" type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "approve")}><CircleCheck size={14} />{reviewActionLabel("approve")}</button>
|
||||||
|
<button class="menu-provider" type="button" role="menuitem" disabled={!request.webUrl} onclick={() => void openRequest(request)}><ExternalLink size={13} />{actionLabel(request.provider)}</button>
|
||||||
|
<button class="danger" type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "close")}><XCircle size={13} />{reviewActionLabel("close")}</button>
|
||||||
|
{:else if request.state === "closed"}
|
||||||
|
<button type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "reopen")}><RotateCcw size={13} />{reviewActionLabel("reopen")}</button>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<button class="panel-button" class:active={detailOpen && selectedId === request.id} title={de ? "Detailpanel öffnen" : "Open detail panel"} aria-label={de ? "Detailpanel öffnen" : "Open detail panel"} type="button" onclick={(event) => { event.stopPropagation(); openDetail(request); }}><PanelRightOpen size={14} /></button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{#if detailOpen && selected}
|
||||||
|
<aside bind:this={detailPanel} class="detail-panel" transition:fly={{ x: 140, duration: 210, easing: cubicOut }}>
|
||||||
|
<header class="detail-header">
|
||||||
|
<div class="detail-provider"><GitPullRequest size={17} /><strong>{providerLabel(selected.provider)} {requestTypeLabel(selected.provider)}</strong></div>
|
||||||
|
<div class="detail-header-actions"><button class="icon-button" type="button" aria-label={de ? "Im Anbieter öffnen" : "Open in provider"} onclick={() => void openRequest()}><ExternalLink size={16} /></button><button class="icon-button" type="button" aria-label={de ? "Detailansicht schließen" : "Close details"} onclick={() => { detailOpen = false; }}><X size={17} /></button></div>
|
||||||
|
</header>
|
||||||
|
<div class="detail-content">
|
||||||
|
<main class="detail-main">
|
||||||
|
<section class="detail-title"><div class="detail-title-line"><span>#{selected.number}</span><h2>{selected.title}</h2></div><div class="detail-summary"><span class="state-badge" class:open={selected.state === "open"} class:draft={selected.state === "draft"} class:merged={selected.state === "merged"} class:closed={selected.state === "closed"}>{stateLabel(selected.state)}</span><span class="avatar">{initials(selected.author)}</span><strong>{selected.author || (de ? "Unbekannt" : "Unknown")}</strong><span class="summary-separator">·</span><span class="detail-branch-route"><GitBranch size={13} />{#if selected.sourceBranch && selected.targetBranch}<code>{selected.sourceBranch}</code><span>→</span><code>{selected.targetBranch}</code>{:else}<span>{de ? "Branches werden geladen …" : "Loading branches …"}</span>{/if}</span></div></section>
|
||||||
|
<div class:conflict={hasConflicts(selected) && !isWaitingForResolvedStatus(selected)} class="merge-summary">{#if detailLoadingId === selected.id || isWaitingForResolvedStatus(selected)}<LoaderCircle class="spin" size={14} /><span>{de ? "Der Anbieter prüft den gelösten Branch …" : "The provider is checking the resolved branch …"}</span>{:else if hasConflicts(selected)}<AlertTriangle size={14} /><strong>{de ? "Dieser Request hat Merge-Konflikte." : "This request has merge conflicts."}</strong>{:else}<CircleCheck size={19} /><span>{de ? "Kann ohne erkannte Konflikte zusammengeführt werden." : "Can be merged without detected conflicts."}</span>{/if}<span class="merge-spacer"></span><small class="detail-change-stats"><b>+{selected.additions ?? "–"}</b><em>−{selected.deletions ?? "–"}</em>{#if selected.changedFiles !== null}<span>{selected.changedFiles} {selected.changedFiles === 1 ? (de ? "Datei geändert" : "file changed") : (de ? "Dateien geändert" : "files changed")}</span>{/if}</small></div>
|
||||||
|
{#if hasConflicts(selected) || isLocalResolutionActive(selected)}
|
||||||
|
<div class:error={localResolutionPhase === "error"} class:complete={localResolutionPhase === "complete"} class="local-resolution">
|
||||||
|
<div>{#if localResolutionPhase === "preparing"}<LoaderCircle class="spin" size={14} />{:else if localResolutionPhase === "complete"}<Check size={14} />{:else}<GitMerge size={14} />{/if}<span>{isLocalResolutionActive(selected) && localResolutionMessage ? localResolutionMessage : (de ? "Löse den Konflikt mit dem lokalen Repository und Gittys Konflikt-Editor." : "Resolve this conflict with the local repository and Gitty's conflict editor.")}</span></div>
|
||||||
|
<footer>
|
||||||
|
{#if !isLocalResolutionActive(selected) || localResolutionPhase === "idle" || localResolutionPhase === "error"}<button type="button" onclick={() => void startLocalResolution(selected)} disabled={!selected.sourceBranch || !selected.targetBranch}>{de ? "Konflikt lokal lösen" : "Resolve locally"}</button>{/if}
|
||||||
|
{#if isLocalResolutionActive(selected) && localResolutionPhase === "conflicts"}<button type="button" onclick={() => void onOpenLocalResolver()}>{de ? "Konflikt-Editor öffnen" : "Open conflict editor"}</button><button class="secondary" type="button" onclick={() => void onAbortLocalResolution()}>{de ? "Abbrechen" : "Abort"}</button>{/if}
|
||||||
|
{#if isLocalResolutionActive(selected) && localResolutionPhase === "ready-to-continue"}<button type="button" onclick={() => void onContinueLocalResolution()}>{de ? "Merge abschließen" : "Complete merge"}</button><button class="secondary" type="button" onclick={() => void onAbortLocalResolution()}>{de ? "Abbrechen" : "Abort"}</button>{/if}
|
||||||
|
{#if isLocalResolutionActive(selected) && localResolutionPhase === "ready-to-push"}<button type="button" onclick={() => void onPushLocalResolution()}>{de ? "Gelösten Branch pushen" : "Push resolved branch"}</button>{/if}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<section class="description"><h3>{de ? "Beschreibung" : "Description"}</h3><p class:muted={!selected.description}>{selected.description || (de ? "Keine Beschreibung vorhanden." : "No description provided.")}</p></section>
|
||||||
|
<section class="comments-section">
|
||||||
|
<header><h3>{de ? "Kommentare" : "Comments"}</h3><span>{selected.comments?.length ?? 0}</span></header>
|
||||||
|
{#if detailLoadingId === selected.id && !(selected.comments?.length)}<div class="comments-loading"><LoaderCircle class="spin" size={13} />{de ? "Kommentare werden geladen …" : "Loading comments …"}</div>{:else if selected.comments?.length}<div class="comment-list">{#each selected.comments as comment (comment.id)}<article><div class="comment-card"><header><span class="avatar">{initials(comment.author)}</span><strong>{comment.author || (de ? "Unbekannt" : "Unknown")}</strong><time>{formatRelativeDate(comment.createdAt)}</time>{#if comment.author === selected.author}<span class="owner-badge" title={de ? "Autor dieses Requests" : "Author of this request"}>{de ? "Autor" : "Author"}</span>{/if}</header><p>{comment.body}</p></div></article>{/each}</div>{:else}<p class="no-comments">{de ? "Noch keine Kommentare." : "No comments yet."}</p>{/if}
|
||||||
|
<form class="comment-composer" onsubmit={(event) => { event.preventDefault(); void submitComment(); }}><textarea bind:this={commentTextarea} bind:value={commentDraft} maxlength="100000" rows="4" placeholder={de ? "Kommentar hinzufügen …" : "Add a comment …"}></textarea><footer><div class="format-actions"><button type="button" title={de ? "Fett" : "Bold"} onclick={() => formatComment("**")}><Bold size={14} /></button><button type="button" title={de ? "Kursiv" : "Italic"} onclick={() => formatComment("_")}><Italic size={14} /></button><button type="button" title="Code" onclick={() => formatComment("`", "`")}><Code2 size={14} /></button><button type="button" title="Link" onclick={() => formatComment("[", "](url)")}><Link size={14} /></button><button type="button" title={de ? "Liste" : "List"} onclick={() => formatComment("- ", "")}><List size={14} /></button><button type="button" title={de ? "Erwähnen" : "Mention"} onclick={() => formatComment("@", "")}><AtSign size={14} /></button></div><button class="submit-comment" type="submit" disabled={!commentDraft.trim() || commentPosting}>{#if commentPosting}<LoaderCircle class="spin" size={13} />{/if}{de ? "Kommentar senden" : "Add comment"}</button></footer></form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<aside class="detail-sidebar">
|
||||||
|
<div class="detail-actions">{#if selected.state === "open" || selected.state === "draft"}{#if isWaitingForResolvedStatus(selected)}<button class="detail-action" type="button" disabled><LoaderCircle class="spin" size={14} />{de ? "Status wird geprüft" : "Checking status"}</button>{:else if hasConflicts(selected)}<button class="detail-action danger-action" type="button" disabled={isLocalResolutionActive(selected) && localResolutionPhase === "preparing"} onclick={() => void startLocalResolution(selected)}><GitMerge size={14} />{de ? "Konflikt lösen" : "Resolve conflict"}</button>{:else}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "merge")}><GitMerge size={14} />{reviewActionLabel("merge")}</button>{/if}<button class="detail-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "approve")}><Check size={14} />{reviewActionLabel("approve")}</button><button class="detail-action danger-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "close")}><XCircle size={14} />{reviewActionLabel("close")}</button>{:else if selected.state === "closed"}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "reopen")}><RotateCcw size={14} />{reviewActionLabel("reopen")}</button>{/if}<button class="detail-action" type="button" onclick={() => void openRequest()} disabled={!selected.webUrl}><ExternalLink size={14} />{actionLabel(selected.provider)}</button></div>
|
||||||
|
<section class="people-section"><header><h3>{de ? "Teilnehmer" : "Participants"}</h3></header><div class="people compact"><span class="avatar">{initials(selected.author)}</span><strong>{selected.author || (de ? "Unbekannt" : "Unknown")}</strong></div></section>
|
||||||
|
<section class="detail-meta"><h3>Repository</h3><strong><GitBranch size={16} />{selected.repositoryName}</strong></section>
|
||||||
|
<section class="detail-meta"><h3>{de ? "Aktualisiert" : "Updated"}</h3><span><Clock3 size={16} />{formatDate(selected.updatedAt || selected.createdAt)}</span></section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.review-center{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden;color:var(--color-ink);background:var(--app-bg);font-size:12px}.review-center button,.review-center input{font:inherit}.review-header{display:flex;min-height:48px;align-items:center;padding:0 18px;border-bottom:1px solid var(--color-border-subtle);background:var(--color-surface)}.review-heading{display:flex;align-items:center;gap:9px}.review-heading>:global(svg){color:var(--color-accent)}.review-heading h1{margin:0;font-size:16px;font-weight:650}
|
||||||
|
.state-tabs{display:flex;min-height:42px;align-items:stretch;gap:18px;padding:0 18px;border-bottom:1px solid var(--color-border-subtle);background:var(--color-surface)}.state-tabs button{position:relative;display:flex;align-items:center;gap:7px;padding:0 5px;border:0;color:var(--color-ink-muted);background:transparent;font-size:11px}.state-tabs button:hover{color:var(--color-ink)}.state-tabs button.active{color:var(--color-accent)}.state-tabs button.active:after{position:absolute;right:0;bottom:0;left:0;height:2px;background:var(--color-accent);content:""}.state-tabs span,.group-header>span{display:grid;min-width:18px;height:18px;place-items:center;padding:0 5px;border-radius:9px;color:var(--color-ink-muted);background:var(--color-surface-raised);font-size:9.5px}
|
||||||
|
.review-toolbar{display:flex;min-height:48px;align-items:center;gap:10px;padding:7px 18px;border-bottom:1px solid var(--color-border-subtle)}.group-actions{display:flex;align-items:center;gap:2px}.group-actions button,.icon-button{display:inline-flex;height:30px;align-items:center;gap:5px;padding:0 7px;border:1px solid transparent;color:var(--color-ink-muted);background:transparent}.group-actions button:hover,.icon-button:hover{border-color:var(--color-border);color:var(--color-ink);background:var(--color-surface-hover)}.group-actions .icon-button{width:30px;justify-content:center;padding:0;border-color:var(--color-border-subtle);margin-left:3px}.review-search{display:flex;min-width:180px;height:30px;align-items:center;gap:7px;flex:1;padding:0 9px;border:1px solid var(--color-border-input);color:var(--color-ink-faint);background:var(--app-input-bg)}.review-search:focus-within{border-color:var(--color-accent)}.review-search input{width:100%;min-width:0;height:100%;padding:0;border:0;outline:0;color:var(--color-ink);background:transparent}
|
||||||
|
.source-warning{padding:7px 18px;border-bottom:1px solid color-mix(in srgb,#d3a64d 28%,var(--color-border));color:#d3a64d;background:color-mix(in srgb,#d3a64d 6%,var(--app-bg))}.source-warning summary{display:flex;align-items:center;gap:7px;cursor:pointer}.source-warning ul{display:grid;gap:4px;margin:7px 0 2px;padding-left:22px;color:var(--color-ink-muted);font-size:10.5px}.source-warning li strong{color:var(--color-ink)}.action-notice{display:flex;min-height:30px;align-items:center;gap:7px;padding:0 18px;border-bottom:1px solid color-mix(in srgb,#63c783 28%,var(--color-border));color:#63c783;background:color-mix(in srgb,#63c783 6%,var(--app-bg));font-size:10.5px}
|
||||||
|
.review-layout{display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-height:0}.review-layout.inspector-open{grid-template-columns:minmax(560px,1fr) minmax(360px,38%)}.table-pane{min-width:0;min-height:0;overflow:auto}.table-head,.request-row{display:grid;grid-template-columns:95px minmax(280px,1.5fr) 135px 145px minmax(210px,1fr) 190px;align-items:center}.table-head{position:sticky;z-index:2;top:0;min-height:30px;padding:0 12px;border-bottom:1px solid var(--color-border);color:var(--color-ink-faint);background:var(--color-surface-raised);font-size:9px;font-weight:700;letter-spacing:.035em;text-transform:uppercase}.request-groups{min-width:1075px}.request-group{border-bottom:1px solid var(--color-border-subtle)}.group-header{display:flex;width:100%;height:34px;align-items:center;justify-content:flex-start;gap:7px;padding:0 12px;border:0;border-bottom:1px solid var(--color-border-subtle);color:var(--color-ink);background:var(--color-surface);text-align:left}.group-header:hover{background:var(--color-surface-hover)}.group-header>:global(svg){color:var(--color-ink-faint)}.group-header strong{font-size:11px}.group-header>span{margin-left:2px}.request-row{position:relative;min-height:52px;padding:0 12px;border-bottom:1px solid var(--color-border-subtle);outline:1px solid transparent;outline-offset:-1px;color:var(--color-ink-muted);background:transparent;cursor:default}.request-row:hover{background:var(--color-surface-hover)}.request-row.selected{z-index:1;outline-color:var(--color-accent);background:var(--color-surface-raised)}.request-status{display:flex;align-items:center;gap:6px;font-size:10px}.request-status>span{display:grid;gap:2px}.request-status small{color:var(--color-ink-faint);font-size:9px}.request-title{display:grid;min-width:0;gap:5px;padding-right:14px}.request-title>span{display:flex;min-width:0;gap:8px}.request-title code{color:var(--color-accent);font-size:10px}.request-title strong{overflow:hidden;color:var(--color-ink);font-size:11px;font-weight:560;text-overflow:ellipsis;white-space:nowrap}.change-stats{display:flex;align-items:center;gap:6px;font-size:9.5px}.change-stats b{color:#63c783}.change-stats em{color:#e0737b;font-style:normal}.change-stats i{color:var(--color-ink-faint);font-style:normal}.change-stats span{color:var(--color-ink-faint)}.request-author{display:flex;min-width:0;align-items:center;gap:7px;padding-right:10px}.request-author i,.avatar,.collaborators i{display:grid;width:23px;height:23px;flex:0 0 auto;place-items:center;border:1px solid color-mix(in srgb,var(--color-accent) 35%,var(--color-border));border-radius:50%;color:#fff;background:color-mix(in srgb,var(--color-accent) 45%,var(--color-surface-raised));font-size:9px;font-style:normal;font-weight:750}.request-author>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.collaborators{display:flex;padding-left:3px}.collaborators i+ i{margin-left:-5px}.repo-branch{display:grid;min-width:0;gap:4px}.repo-branch>strong{overflow:hidden;color:var(--color-ink-muted);font-size:10px;text-overflow:ellipsis;white-space:nowrap}.branch-route{display:flex;min-width:0;align-items:center;gap:5px;padding-right:12px}.branch-route code{max-width:42%;overflow:hidden;color:var(--color-ink-faint);font-size:9px;text-overflow:ellipsis;white-space:nowrap}.branch-route b{color:var(--color-ink-faint);font-weight:400}.row-actions{display:flex;align-items:center;justify-content:flex-end;gap:5px}.provider-action{position:relative;display:flex}.provider-button,.action-toggle,.detail-action{display:inline-flex;height:28px;align-items:center;justify-content:center;gap:6px;border:1px solid var(--color-accent);color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 5%,transparent)}.provider-button{min-width:120px;padding:0 8px;border-right:0}.provider-button.merge-primary{color:#63c783;border-color:#4fa565;background:color-mix(in srgb,#4fa565 15%,var(--color-surface))}.provider-button.conflict{color:#e0737b;border-color:#a74b55}.action-toggle{width:27px;padding:0}.provider-button:hover,.action-toggle:hover,.action-toggle.active,.detail-action:hover{color:#fff;background:color-mix(in srgb,var(--color-accent) 24%,var(--color-surface))}.action-menu{position:absolute;z-index:8;top:31px;right:0;display:grid;width:178px;padding:4px;border:1px solid var(--color-border);box-shadow:0 8px 22px #0008;background:var(--color-surface-raised)}.action-menu button{display:flex;height:29px;align-items:center;gap:8px;padding:0 8px;border:0;color:var(--color-ink-muted);background:transparent;text-align:left}.action-menu button:hover{color:var(--color-ink);background:var(--color-surface-hover)}.action-menu button.danger{color:#e0737b}.panel-button{display:grid;width:28px;height:28px;place-items:center;border:1px solid var(--color-border);color:var(--color-ink-muted);background:var(--app-button-bg)}.panel-button:hover,.panel-button.active{border-color:var(--color-accent);color:var(--color-accent)}
|
||||||
|
.detail-panel{min-width:0;min-height:0;overflow:auto;border-left:1px solid var(--color-border);background:var(--color-surface)}.detail-header{display:flex;min-height:78px;align-items:flex-start;justify-content:space-between;gap:14px;padding:15px 16px;border-bottom:1px solid var(--color-border)}.detail-header>div{min-width:0}.detail-content{display:grid;gap:0;padding:0 16px}.detail-summary{display:flex;min-height:48px;align-items:center;gap:8px;border-bottom:1px solid var(--color-border-subtle)}.state-badge{padding:3px 7px;border:1px solid currentColor;font-size:9px;font-weight:700}.detail-summary .avatar{margin-left:4px}.detail-summary strong{font-size:11px}.description{padding:15px 0;border-bottom:1px solid var(--color-border-subtle)}.description h3{margin:0 0 9px;font-size:11px}.description p{margin:0;white-space:pre-wrap;color:var(--color-ink-muted);font-size:11px;line-height:1.55}.description p.muted{color:var(--color-ink-faint)}.detail-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:16px}.detail-action{width:100%;height:32px;padding:0 8px}.detail-action.primary-action{color:#63c783;border-color:#63c783}.detail-action.danger-action{color:#e0737b;border-color:color-mix(in srgb,#e0737b 75%,var(--color-border))}.open{color:#63c783}.draft{color:var(--color-ink-muted)}.merged{color:#b785e8}.closed{color:#e0737b}
|
||||||
|
.loading-state,.list-empty,.empty-state{display:flex;min-height:180px;align-items:center;justify-content:center;gap:8px;color:var(--color-ink-muted)}.list-empty,.empty-state{flex-direction:column}.list-empty strong{color:var(--color-ink);font-size:12px}.list-empty span{font-size:10px}.empty-state{flex:1;text-align:center}.empty-state h2{margin:4px 0 0;font-size:14px}.empty-state p{max-width:430px;margin:0 0 8px;line-height:1.5}.empty-icon{display:grid;width:42px;height:42px;place-items:center;border:1px solid var(--color-border);color:var(--color-accent);background:var(--color-surface)}.primary{display:inline-flex;min-height:30px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-primary);color:#fff;background:var(--color-primary)}
|
||||||
|
@media(max-width:1100px){.review-layout.inspector-open{grid-template-columns:minmax(500px,1fr) 360px}.table-head,.request-row{grid-template-columns:78px minmax(230px,1.5fr) minmax(180px,1fr) 90px 170px}.table-head>span:nth-child(3),.request-author{display:none}.request-groups{min-width:760px}}
|
||||||
|
@media(max-width:760px){.review-toolbar{flex-wrap:wrap}.group-actions{order:2}.review-search{order:1;flex-basis:calc(100% - 200px)}.review-layout.inspector-open{display:block}.detail-panel{position:absolute;inset:90px 0 0 15%;z-index:5;box-shadow:-12px 0 30px #0008}.state-tabs{gap:8px}.group-actions button{font-size:0}.group-actions button>:global(svg){margin:0}.table-head,.request-row{grid-template-columns:72px minmax(220px,1fr) 90px 160px}.table-head>span:nth-child(3),.table-head>span:nth-child(4),.request-author,.branch-route{display:none}.request-groups{min-width:580px}}
|
||||||
|
.merge-summary{display:flex;min-height:45px;align-items:center;gap:7px;border-bottom:1px solid var(--color-border-subtle);color:#63c783}.merge-summary.conflict{color:#e0737b}.merge-summary>span,.merge-summary>strong{font-size:10.5px}
|
||||||
|
@media(max-width:1100px){.table-head,.request-row{grid-template-columns:78px minmax(270px,1fr) minmax(190px,1fr) 190px}.table-head>span:nth-child(3),.table-head>span:nth-child(4),.request-author,.collaborators{display:none}.request-groups{min-width:780px}}
|
||||||
|
@media(max-width:760px){.table-head,.request-row{grid-template-columns:72px minmax(260px,1fr) 190px}.table-head>span:nth-child(5),.repo-branch{display:none}.request-groups{min-width:600px}.merge-summary{align-items:flex-start;flex-wrap:wrap;padding:10px 0}}
|
||||||
|
.branch-loading{display:flex;align-items:center;gap:5px;color:var(--color-ink-faint);font-size:9px}
|
||||||
|
.provider-action{overflow:visible;border-radius:2px;box-shadow:0 1px 0 #0005}.provider-button{border-radius:2px 0 0 2px;font-weight:600}.action-toggle{border-radius:0 2px 2px 0;background:color-mix(in srgb,var(--color-accent) 14%,var(--color-surface))}.panel-button{margin-left:2px;border-radius:2px}.review-layout.inspector-open{grid-template-columns:minmax(580px,1fr) minmax(420px,44%)}.detail-header{background:linear-gradient(180deg,color-mix(in srgb,var(--color-surface-raised) 72%,var(--color-surface)),var(--color-surface))}
|
||||||
|
.comments-section{padding:15px 0;border-bottom:1px solid var(--color-border-subtle)}.comments-section>header{display:flex;align-items:center;gap:7px;margin-bottom:10px}.comments-section h3{margin:0;font-size:11px}.comments-section>header>span{display:grid;min-width:18px;height:18px;place-items:center;border-radius:9px;color:var(--color-ink-faint);background:var(--color-surface-raised);font-size:9px}.comments-loading,.no-comments{margin:8px 0;color:var(--color-ink-faint);font-size:10px}.comments-loading{display:flex;align-items:center;gap:6px}.comment-list{display:grid;gap:10px;margin-bottom:12px}.comment-list article{display:flex;align-items:flex-start;gap:9px}.comment-list article>div{min-width:0;flex:1;padding:9px 10px;border:1px solid var(--color-border-subtle);border-radius:2px;background:var(--app-bg)}.comment-list article header{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}.comment-list article strong{font-size:10.5px}.comment-list article time{color:var(--color-ink-faint);font-size:9px}.comment-list article p{margin:0;white-space:pre-wrap;color:var(--color-ink-muted);font-size:10.5px;line-height:1.5}.comment-composer{display:flex;align-items:flex-start;gap:9px;margin-top:10px}.comment-composer textarea{box-sizing:border-box;width:100%;min-height:82px;resize:vertical;padding:9px 10px;border:1px solid var(--color-border-input);outline:0;color:var(--color-ink);background:var(--app-input-bg);font:inherit;line-height:1.45}.comment-composer textarea:focus{border-color:var(--color-accent)}.comment-composer button{display:inline-flex;height:29px;align-items:center;gap:6px;padding:0 10px;border:1px solid #4fa565;border-radius:2px;color:#63c783;background:color-mix(in srgb,#4fa565 12%,var(--color-surface))}.comment-composer button:hover:not(:disabled){color:#fff;background:#397849}
|
||||||
|
@media(max-width:1100px){.review-layout.inspector-open{grid-template-columns:minmax(480px,1fr) 380px}}
|
||||||
|
@media(max-width:760px){.review-layout.inspector-open{display:block}.detail-panel{position:absolute;inset:90px 0 0 12%;z-index:5;box-shadow:-12px 0 30px #0008}}
|
||||||
|
.comment-list{max-height:230px;overflow-y:auto;overscroll-behavior:contain;padding-right:5px;scrollbar-gutter:stable}.comment-list::-webkit-scrollbar{width:7px}.comment-list::-webkit-scrollbar-thumb{border:2px solid transparent;border-radius:4px;background:var(--color-border-input);background-clip:padding-box}.comment-list::-webkit-scrollbar-track{background:transparent}
|
||||||
|
.detail-panel{display:flex;flex-direction:column;overflow:hidden}.detail-header{flex:0 0 auto}.detail-content{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden}.detail-summary,.merge-summary,.description,.detail-actions{flex:0 0 auto}.description p{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:3;line-clamp:3}.comments-section{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden}.comments-section>header,.comment-composer{flex:0 0 auto}.comment-list{min-height:0;max-height:none;flex:1;overflow-y:auto}.comment-composer textarea{min-height:68px;max-height:92px}.detail-actions{margin-bottom:12px}
|
||||||
|
.review-center{position:relative}.review-layout.inspector-open{grid-template-columns:minmax(0,1fr)}.detail-panel{position:absolute;z-index:20;inset:0;display:flex;width:100%;height:100%;flex-direction:column;overflow:hidden;border:0;background:var(--app-bg)}.detail-header{display:flex;min-height:48px;align-items:center;padding:0 20px;border-bottom:1px solid var(--color-border);background:var(--color-surface-raised)}.detail-provider{display:flex;align-items:center;gap:9px;color:var(--color-ink-muted)}.detail-provider>:global(svg){color:var(--color-accent)}.detail-provider strong{font-size:13px;font-weight:600}.detail-header-actions{display:flex;align-items:center;gap:3px;margin-left:auto}.detail-content{display:grid;min-height:0;flex:1;grid-template-columns:minmax(0,1fr) 300px;gap:28px;overflow:hidden;padding:0 28px}.detail-main{display:flex;min-width:0;min-height:0;flex-direction:column;overflow:hidden}.detail-title{flex:0 0 auto;padding:18px 0 0;border-bottom:1px solid var(--color-border-subtle)}.detail-title h2{margin:7px 0 10px;font-size:20px;font-weight:520;line-height:1.25}.detail-title .detail-summary{min-height:34px;border:0}.detail-title .detail-summary>span:last-child{color:var(--color-ink-muted);font-size:10.5px}.detail-main>.description{padding:13px 0}.detail-main>.comments-section{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden;padding:13px 0 18px}.detail-main .merge-summary{flex:0 0 auto;margin-bottom:11px;padding:0 12px;border:1px solid var(--color-border);background:var(--color-surface)}.detail-main .comment-list{min-height:0;flex:1}.detail-main .comment-composer{flex:0 0 auto}.detail-sidebar{display:flex;min-height:0;flex-direction:column;gap:0;overflow:hidden;padding:18px 0;border-left:1px solid var(--color-border-subtle);padding-left:22px;background:var(--color-surface)}.detail-sidebar .detail-actions{display:grid;grid-template-columns:1fr;gap:6px;margin:0 0 8px}.detail-sidebar section{padding:17px 0;border-bottom:1px solid var(--color-border-subtle)}.detail-sidebar section header{display:flex;align-items:center;justify-content:space-between;gap:12px}.detail-sidebar section h3{margin:0;color:var(--color-ink-muted);font-size:11px;font-weight:560}.people{display:grid;grid-template-columns:24px minmax(0,1fr);align-items:center;gap:8px;margin-top:12px}.people.compact{grid-template-columns:24px}
|
||||||
|
@media(max-width:900px){.detail-content{grid-template-columns:minmax(0,1fr) 270px;gap:18px;padding:0 18px}.detail-sidebar{padding-left:16px}.detail-title h2{font-size:17px}}
|
||||||
|
@media(max-width:680px){.detail-content{display:block;overflow:hidden;padding:0 15px}.detail-main{height:100%}.detail-sidebar{display:none}.detail-panel{inset:0}}
|
||||||
|
.comment-list{align-content:start;grid-auto-rows:max-content}
|
||||||
|
.detail-panel{inset:0 0 0 auto;width:50%;min-width:760px;max-width:100%;height:100%;border-left:1px solid var(--color-border);box-shadow:-14px 0 30px #0007}.review-layout.inspector-open{grid-template-columns:minmax(0,1fr)}
|
||||||
|
.provider-action.merge-action .action-toggle{border-color:#4fa565;color:#70d58d;background:color-mix(in srgb,#4fa565 15%,var(--color-surface))}.provider-action.merge-action .action-toggle:hover,.provider-action.merge-action .action-toggle.active{color:#fff;background:#397849}.provider-action.conflict-action .action-toggle{border-color:#a74b55;color:#e0737b;background:color-mix(in srgb,#a74b55 12%,var(--color-surface))}.panel-button{border-color:color-mix(in srgb,var(--color-accent) 45%,var(--color-border));color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 6%,var(--color-surface))}.panel-button:hover,.panel-button.active{border-color:var(--color-accent);color:#fff;background:color-mix(in srgb,var(--color-accent) 25%,var(--color-surface))}
|
||||||
|
.local-resolution{display:grid;flex:0 0 auto;gap:9px;margin:0 0 11px;padding:10px 12px;border:1px solid color-mix(in srgb,#e0737b 52%,var(--color-border));background:color-mix(in srgb,#e0737b 6%,var(--color-surface))}.local-resolution>div{display:flex;align-items:flex-start;gap:8px;color:var(--color-ink-muted);font-size:10.5px;line-height:1.45}.local-resolution>div>:global(svg){flex:0 0 auto;margin-top:1px;color:#e0737b}.local-resolution footer{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}.local-resolution button{display:inline-flex;min-height:28px;align-items:center;padding:0 9px;border:1px solid #c65e67;color:#f08a92;background:color-mix(in srgb,#c65e67 12%,var(--color-surface))}.local-resolution button:hover:not(:disabled){color:#fff;background:#8e3f47}.local-resolution button.secondary{border-color:var(--color-border-input);color:var(--color-ink-muted);background:var(--app-button-bg)}.local-resolution.error{border-color:#d29a47;background:color-mix(in srgb,#d29a47 7%,var(--color-surface))}.local-resolution.error>div>:global(svg){color:#d29a47}.local-resolution.complete{border-color:#4fa565;background:color-mix(in srgb,#4fa565 7%,var(--color-surface))}.local-resolution.complete>div>:global(svg){color:#63c783}
|
||||||
|
@media(max-width:760px){.detail-panel{inset:0;width:100%;min-width:0;max-width:none}}
|
||||||
|
|
||||||
|
/* Review Center B — compact hierarchy, overlay inspector, semantic actions. */
|
||||||
|
.review-center{font-size:11px}.review-header{min-height:44px}.review-heading h1{font-size:14px}
|
||||||
|
.review-toolbar{display:grid;min-height:50px;grid-template-columns:auto minmax(220px,1fr) 190px auto;align-items:center;gap:10px;padding:0 14px;background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))}
|
||||||
|
.state-tabs{height:100%;min-height:0;gap:3px;padding:0;border:0;background:transparent}.state-tabs button{min-width:58px;justify-content:center;padding:0 9px;font-size:10.5px}.state-tabs button.active{color:var(--color-ink)}.state-tabs button.active:after{right:7px;left:7px}.state-tabs span,.group-header>span{min-width:17px;height:17px;padding:0 4px;border:1px solid var(--color-border-subtle);border-radius:1px;font-size:9px}
|
||||||
|
.review-search{min-width:0;height:30px}.review-search:focus-within{box-shadow:inset 2px 0 0 var(--color-accent)}.group-actions{gap:4px}.group-actions .icon-button{width:30px;margin:0;padding:0;border-color:var(--color-border-subtle)}
|
||||||
|
.table-head,.request-row{grid-template-columns:92px minmax(285px,1.65fr) 132px 126px minmax(220px,1fr) 190px}.table-head{min-height:34px;padding:0 16px;border-top:1px solid var(--color-border-subtle);border-bottom-color:var(--color-border);color:color-mix(in srgb,var(--color-ink-faint) 82%,transparent);background:color-mix(in srgb,var(--color-surface-raised) 80%,var(--app-bg));font-size:8.5px;letter-spacing:.07em}.request-groups{min-width:1080px}.request-group{border-bottom:0}.group-header{height:39px;gap:8px;padding:0 16px;border-bottom-color:var(--color-border);color:var(--color-ink);background:color-mix(in srgb,var(--color-surface) 78%,var(--app-bg))}.group-header:hover{background:color-mix(in srgb,var(--color-surface-hover) 82%,var(--app-bg))}.group-header>:global(svg){color:var(--color-accent)}.group-header strong{font-size:11.5px;font-weight:680;letter-spacing:.005em}.group-header>span{margin-left:4px;color:var(--color-ink-muted);background:color-mix(in srgb,var(--color-surface-raised) 88%,var(--app-bg))}
|
||||||
|
.request-row{min-height:64px;padding:0 16px;border-bottom-color:color-mix(in srgb,var(--color-border-subtle) 76%,transparent);background:color-mix(in srgb,var(--app-bg) 96%,var(--color-surface))}.request-row:before{position:absolute;top:0;bottom:0;left:0;width:3px;background:transparent;content:""}.request-row:hover{background:color-mix(in srgb,var(--color-surface-hover) 72%,var(--app-bg))}.request-row.selected{outline-color:color-mix(in srgb,var(--color-accent) 74%,var(--color-border));background:color-mix(in srgb,var(--color-accent) 5%,var(--color-surface-raised));box-shadow:inset 0 1px color-mix(in srgb,var(--color-accent) 15%,transparent),inset 0 -1px color-mix(in srgb,var(--color-accent) 15%,transparent)}.request-row.selected:before{background:var(--color-accent);box-shadow:2px 0 10px color-mix(in srgb,var(--color-accent) 24%,transparent)}.request-status{gap:8px}.request-status>:global(svg){width:15px;height:15px}.request-status>span{gap:4px;font-weight:600}.request-status small{font-weight:400}.request-title{gap:7px}.request-title>span{align-items:center}.request-title code{font-size:10.5px;font-weight:650}.request-title strong{font-size:11.5px;font-weight:680}.change-stats{gap:7px;font-size:9.5px}.request-author i,.avatar,.collaborators i{border-radius:1px}.repo-branch{gap:6px}.repo-branch>strong{color:var(--color-ink);font-size:10.5px;font-weight:650}.branch-route code{padding:2px 4px;color:var(--color-ink-muted);background:color-mix(in srgb,var(--color-accent) 6%,transparent)}
|
||||||
|
.provider-action{box-shadow:none}.provider-button,.action-toggle{height:32px}.provider-button{min-width:124px;padding:0 11px;font-weight:650}.provider-button.merge-primary{color:#72d58f;border-color:#4e9f64;background:color-mix(in srgb,#4fa565 9%,var(--app-bg))}.provider-button.merge-primary:hover{color:#fff;background:#397849}.provider-action.merge-action .action-toggle{background:color-mix(in srgb,#4fa565 20%,var(--app-bg))}.provider-button.conflict{color:#e2ac58;border-color:#b88432;background:color-mix(in srgb,#b88432 9%,var(--app-bg))}.provider-action.conflict-action .action-toggle{color:#e2ac58;border-color:#b88432;background:color-mix(in srgb,#b88432 17%,var(--app-bg))}.panel-button{width:32px;height:32px;margin-left:3px;background:color-mix(in srgb,var(--color-accent) 5%,var(--app-bg))}
|
||||||
|
.action-menu{top:36px;width:190px;padding:6px;border-color:color-mix(in srgb,var(--color-border) 86%,var(--color-accent));box-shadow:0 12px 30px #000b,0 0 0 1px #0005;background:color-mix(in srgb,var(--color-surface-raised) 92%,#10151a)}.action-menu:before{position:absolute;top:-5px;right:10px;width:8px;height:8px;transform:rotate(45deg);border-top:1px solid var(--color-border);border-left:1px solid var(--color-border);background:inherit;content:""}.action-menu button{position:relative;height:34px;gap:10px;padding:0 10px;border-left:2px solid transparent;color:var(--color-ink-muted);font-size:10.5px}.action-menu button:hover:not(:disabled){color:var(--color-ink);border-left-color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 8%,var(--color-surface-hover))}.action-menu button>:global(svg){flex:0 0 auto}.action-menu .menu-merge>:global(svg){color:#72d58f}.action-menu .menu-approve>:global(svg),.action-menu .menu-provider>:global(svg){color:var(--color-accent)}.action-menu button.danger{margin-top:4px;border-top:1px solid var(--color-border-subtle);color:#e7767e}.action-menu button.danger:hover{border-left-color:#e7767e;background:color-mix(in srgb,#e7767e 8%,var(--color-surface-hover))}.action-menu .menu-conflict{color:#e2ac58}.action-menu button:disabled{opacity:.48}
|
||||||
|
.detail-panel{width:50%;min-width:720px;max-width:960px;box-shadow:-14px 0 30px #0008}.detail-header{min-height:47px;padding:0 16px}.detail-provider strong{font-size:12px}.detail-content{grid-template-columns:minmax(0,1fr) 250px;gap:20px;padding:0 16px}.detail-title{padding:15px 0 10px}.detail-title h2{margin:5px 0 8px;font-size:18px;font-weight:620}.detail-summary{min-height:27px;border:0}.detail-summary .avatar{margin-left:3px}
|
||||||
|
.detail-main>.merge-summary{min-height:43px;margin:11px 0 0;padding:0 10px;border:1px solid color-mix(in srgb,#63c783 48%,var(--color-border));background:color-mix(in srgb,#63c783 6%,var(--color-surface))}.detail-main>.merge-summary.conflict{border-color:color-mix(in srgb,#d6a64f 55%,var(--color-border));color:#d6a64f;background:color-mix(in srgb,#d6a64f 7%,var(--color-surface))}.merge-spacer{flex:1}.detail-change-stats{display:flex;align-items:center;gap:7px;white-space:nowrap;font-size:9px}.detail-change-stats b{color:#63c783}.detail-change-stats em{color:#e0737b;font-style:normal}.detail-change-stats span{color:var(--color-ink-faint)}
|
||||||
|
.detail-main>.description{padding:12px 0}.detail-main>.comments-section{padding:12px 0 14px}.comment-list{gap:8px}.comment-list article{gap:8px}.comment-list article>div{padding:8px 9px;border-radius:0;background:color-mix(in srgb,var(--color-surface) 48%,var(--app-bg))}.comment-composer{margin-top:9px;padding-top:9px;border-top:1px solid var(--color-border-subtle)}.comment-composer textarea{min-height:72px;max-height:92px}.comment-composer button{color:var(--color-accent);border-color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 6%,var(--color-surface))}
|
||||||
|
.detail-sidebar{padding:15px 0 15px 16px;background:color-mix(in srgb,var(--color-surface) 58%,var(--app-bg))}.detail-sidebar .detail-actions{gap:6px;margin-bottom:9px}.detail-action{height:31px}.detail-sidebar section{padding:14px 0}.people{grid-template-columns:23px minmax(0,1fr);gap:8px;margin-top:10px}.people strong,.detail-meta strong,.detail-meta span{overflow:hidden;color:var(--color-ink-muted);font-size:10px;font-weight:500;text-overflow:ellipsis}.detail-meta{display:grid;gap:8px}.detail-actions>.danger-action:first-child{color:#e0aa55;border-color:#b88432;background:color-mix(in srgb,#b88432 8%,var(--color-surface))}
|
||||||
|
.local-resolution{margin:8px 0 0;border-color:color-mix(in srgb,#d6a64f 55%,var(--color-border));background:color-mix(in srgb,#d6a64f 7%,var(--color-surface))}.local-resolution>div>:global(svg){color:#d6a64f}.local-resolution button{color:#e0aa55;border-color:#b88432;background:color-mix(in srgb,#b88432 10%,var(--color-surface))}
|
||||||
|
@media(max-width:1280px){.review-toolbar{grid-template-columns:auto minmax(180px,1fr) 155px auto}.state-tabs button{min-width:52px;padding:0 6px}.table-head,.request-row{grid-template-columns:80px minmax(250px,1.5fr) 110px minmax(190px,1fr) 180px}.table-head>span:nth-child(4),.collaborators{display:none}.request-groups{min-width:900px}.detail-panel{width:58%;min-width:680px}}
|
||||||
|
@media(max-width:900px){.review-toolbar{grid-template-columns:1fr 150px auto;grid-template-rows:38px 38px;padding:0 10px}.state-tabs{grid-column:1/-1;grid-row:1}.review-search{grid-column:1;grid-row:2}.group-actions{grid-column:3;grid-row:2}.detail-panel{width:76%;min-width:620px}.detail-content{grid-template-columns:minmax(0,1fr) 220px;gap:14px}.table-head,.request-row{grid-template-columns:76px minmax(240px,1.5fr) minmax(180px,1fr) 180px}.table-head>span:nth-child(3),.request-author{display:none}.request-groups{min-width:760px}}
|
||||||
|
@media(max-width:680px){.state-tabs button{min-width:0;flex:1}.group-actions .icon-button:nth-child(-n+2){display:none}.detail-panel{width:100%;min-width:0;max-width:none}.detail-content{display:block;padding:0 13px}.detail-main{height:100%}.detail-sidebar{display:none}}
|
||||||
|
|
||||||
|
/* Accepted Review Center concept — faithful final layout. */
|
||||||
|
.review-header{min-height:54px;padding:0 24px;background:color-mix(in srgb,var(--app-bg) 78%,var(--color-surface))}.review-heading{gap:10px}.review-heading h1{font-size:14px;font-weight:700}
|
||||||
|
.review-toolbar{min-height:55px;grid-template-columns:350px minmax(220px,1fr) 160px 34px;gap:10px;padding:0 16px;border-bottom-color:var(--color-border);background:color-mix(in srgb,var(--app-bg) 88%,var(--color-surface))}.state-tabs{gap:8px}.state-tabs button{min-width:72px;justify-content:flex-start;padding:0 8px;color:var(--color-ink-muted);font-size:11px}.state-tabs button.active{color:var(--color-ink)}.state-tabs button.active:after{right:0;left:0;height:2px}.state-tabs span{min-width:17px;height:17px;margin-left:auto;border:0;background:var(--color-surface-raised)}.review-search,.group-actions .icon-button{height:34px;background:color-mix(in srgb,var(--app-input-bg) 92%,#11171c)}.review-search{padding:0 11px}.group-actions .icon-button{width:34px}
|
||||||
|
.table-head,.request-row{grid-template-columns:90px minmax(255px,1.65fr) 105px 120px minmax(180px,1fr) 150px}.table-head{min-height:37px;padding:0 16px;background:color-mix(in srgb,var(--color-surface-raised) 88%,var(--app-bg));font-size:8.5px}.request-groups{min-width:900px}.group-header{height:45px;padding:0 17px;background:color-mix(in srgb,var(--app-bg) 72%,var(--color-surface))}.group-header strong{font-size:11.5px}.request-row{min-height:78px;padding:0 16px}.request-row.selected{background:linear-gradient(90deg,color-mix(in srgb,var(--color-accent) 7%,var(--color-surface-raised)),color-mix(in srgb,var(--color-surface-raised) 72%,var(--app-bg)))}.request-title{gap:8px}.request-title strong{font-size:12px}.request-status{font-size:10.5px}.request-author i,.avatar,.collaborators i{width:28px;height:28px;background:color-mix(in srgb,var(--color-accent) 58%,#26333a)}.provider-button,.action-toggle,.panel-button{height:34px}.provider-button{min-width:102px}.action-toggle{width:34px}.panel-button{width:34px}.action-menu{top:38px;width:154px}.action-menu button{height:36px}
|
||||||
|
.detail-panel{width:44%;min-width:680px;max-width:none;background:color-mix(in srgb,var(--app-bg) 94%,var(--color-surface))}.detail-header{min-height:54px;padding:0 24px;background:color-mix(in srgb,var(--app-bg) 78%,var(--color-surface))}.detail-provider strong{font-size:13px}.detail-content{grid-template-columns:minmax(0,1fr) 225px;gap:18px;padding:0 0 0 24px}.detail-main{padding-right:0}.detail-title{padding:18px 0 16px}.detail-title-line{display:flex;min-width:0;align-items:baseline;gap:10px}.detail-title-line>span{flex:0 0 auto;color:var(--color-accent);font-size:17px;font-weight:700}.detail-title h2{min-width:0;margin:0;overflow:hidden;font-size:19px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.detail-title .detail-summary{min-height:33px;gap:7px}.detail-summary .avatar{width:23px;height:23px;margin-left:5px}.detail-summary strong{font-size:10.5px}.summary-separator{color:var(--color-ink-faint)}.detail-branch-route{display:flex;min-width:0;align-items:center;gap:6px;color:var(--color-ink-muted)}.detail-branch-route>:global(svg){color:var(--color-ink-muted)}.detail-branch-route code{max-width:95px;overflow:hidden;color:var(--color-ink-muted);font-size:10.5px;text-overflow:ellipsis;white-space:nowrap}.detail-branch-route>span{color:var(--color-ink-faint)}.state-badge{padding:0;border:0;font-size:10.5px}
|
||||||
|
.detail-main>.merge-summary{min-height:55px;margin:0 0 4px;padding:0 12px;border-color:color-mix(in srgb,#63c783 65%,var(--color-border));background:color-mix(in srgb,#63c783 5%,var(--app-bg))}.detail-main>.description{padding:14px 0 18px}.description h3,.comments-section h3{font-size:11.5px}.description p{font-size:10.5px}.detail-main>.comments-section{padding:13px 0 0}.comments-section>header{min-height:28px;margin:0 0 8px}.comments-section>header>span{border-radius:1px}.comment-list{gap:10px;padding-right:0}.comment-list article{display:block}.comment-card{padding:0!important;border:1px solid var(--color-border)!important;background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))!important}.comment-card header{min-height:42px;margin:0!important;padding:0 10px;border-bottom:0}.comment-card header .avatar{width:25px;height:25px;margin-right:2px}.comment-card header strong{font-size:10.5px}.comment-card header time{margin-left:auto}.owner-badge{padding:3px 6px;border:1px solid var(--color-border);color:var(--color-ink-faint);font-size:8.5px}.comment-card p{padding:0 44px 13px!important;color:var(--color-ink)!important}
|
||||||
|
.comment-composer{display:grid;gap:0;margin:0;padding:14px 0 18px;border-top:1px solid var(--color-border-subtle)}.comment-composer textarea{min-height:86px;max-height:120px;resize:none;padding:11px 12px;border-bottom:0;background:color-mix(in srgb,var(--app-input-bg) 94%,#11171c)}.comment-composer footer{display:flex;min-height:39px;align-items:center;justify-content:space-between;border:1px solid var(--color-border-input);border-top:0;background:color-mix(in srgb,var(--app-bg) 85%,var(--color-surface))}.format-actions{display:flex;align-items:center;padding-left:5px}.comment-composer .format-actions button{display:grid;width:30px;height:30px;place-items:center;padding:0;border:0;color:var(--color-ink-muted);background:transparent}.comment-composer .format-actions button:hover{color:var(--color-ink);background:var(--color-surface-hover)}.comment-composer .submit-comment{height:29px;margin-right:6px;padding:0 11px;border:1px solid var(--color-accent);border-radius:0;color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 5%,var(--app-bg))}.comment-composer .submit-comment:hover:not(:disabled){color:#fff;background:color-mix(in srgb,var(--color-accent) 72%,var(--app-bg))}
|
||||||
|
.detail-sidebar{padding:28px 17px 16px;border-left-color:var(--color-border);background:color-mix(in srgb,var(--color-surface) 46%,var(--app-bg))}.detail-sidebar .detail-actions{gap:10px;margin:0 0 12px}.detail-action{height:39px;font-size:11px}.detail-sidebar section{padding:17px 0}.detail-sidebar section h3{font-size:11.5px}.people{margin-top:12px}.detail-meta{gap:11px}
|
||||||
|
@media(max-width:1280px){.review-toolbar{grid-template-columns:310px minmax(180px,1fr) 145px 34px}.state-tabs{gap:3px}.state-tabs button{min-width:64px}.table-head,.request-row{grid-template-columns:80px minmax(250px,1.5fr) 105px minmax(190px,1fr) 150px}.request-groups{min-width:840px}.detail-panel{width:50%;min-width:650px}}
|
||||||
|
@media(max-width:900px){.review-toolbar{grid-template-columns:1fr 150px 34px;grid-template-rows:40px 40px}.detail-panel{width:72%;min-width:600px}.detail-content{grid-template-columns:minmax(0,1fr) 205px;gap:14px;padding-left:16px}.table-head,.request-row{grid-template-columns:76px minmax(240px,1.5fr) minmax(180px,1fr) 150px}.request-groups{min-width:720px}}
|
||||||
|
@media(max-width:680px){.detail-panel{width:100%;min-width:0}.detail-content{display:block;padding:0 14px}.detail-sidebar{display:none}.detail-title h2{font-size:16px}.detail-title-line>span{font-size:14px}}
|
||||||
|
|
||||||
|
/* Match the approved reference at its 1672px desktop width. */
|
||||||
|
.review-center{--review-divider:color-mix(in srgb,var(--color-border) 82%,transparent);font-size:13px;background:color-mix(in srgb,var(--app-bg) 96%,#172129)}
|
||||||
|
.review-center.has-inspector{--inspector-width:44%}
|
||||||
|
.review-header,.detail-header{box-sizing:border-box;height:54px;min-height:54px;background:color-mix(in srgb,var(--app-bg) 94%,var(--color-surface))}
|
||||||
|
.review-heading h1,.detail-provider strong{font-size:14px}.detail-provider>:global(svg){display:none}.detail-header{gap:4px}.detail-header-actions{display:contents}.detail-header-actions button:first-child{margin-right:auto}.detail-provider{flex:none}
|
||||||
|
.review-toolbar{height:60px;min-height:60px}.review-search input{font-size:13px}.review-center .state-tabs button{font-size:13px}.state-tabs span{font-size:11px}.table-pane{padding:8px 7px 0;box-sizing:border-box}.table-head{padding:0 16px;height:37px}.table-head,.request-row{grid-template-columns:100px minmax(210px,1fr) 106px 120px 165px 145px}.table-head>span{font-size:10px}.request-groups{min-width:900px}.group-header{height:44px;padding:0 12px}.group-header strong{font-size:13px}.request-row{padding:0 10px;min-height:78px}.request-row.selected{outline-color:color-mix(in srgb,var(--color-accent) 50%,var(--color-border));background:color-mix(in srgb,var(--app-bg) 93%,var(--color-accent));box-shadow:none}.request-row.selected:before{width:2px;box-shadow:none}.request-status{font-size:13px;font-weight:400}.request-status>span{font-weight:400}.request-status small{font-size:11px}.request-title strong{font-size:13px;font-weight:650}.request-title code{font-size:12px}.change-stats{font-size:12px}.request-author{font-size:12px}.repo-branch>strong{font-size:12px;font-weight:500}.branch-route code{padding:0;background:none;font-size:11px}.branch-route{gap:6px}
|
||||||
|
.review-center .provider-button{min-width:68px;padding:0 8px;font-size:13px;font-weight:500}.provider-button.merge-primary>:global(svg){display:none}.provider-action .action-toggle{width:32px;background:transparent}.row-actions{gap:7px}.panel-button{margin:0;width:32px}.action-menu{width:154px;padding:4px;background:color-mix(in srgb,var(--app-bg) 90%,var(--color-surface-raised));border:1px solid var(--color-border);box-shadow:0 6px 12px #0005}.action-menu button{font-size:13px;gap:10px;height:37px;padding:0 10px}.action-menu button.danger{margin:0;border-top:0;color:var(--color-ink)}.action-menu button.danger>:global(svg){color:#e7767e}.action-menu .menu-merge>:global(svg),.action-menu .menu-approve>:global(svg),.action-menu .menu-provider>:global(svg){color:var(--color-ink-muted)}
|
||||||
|
.detail-panel{min-width:0;width:var(--inspector-width,44%);box-shadow:-2px 0 8px #0002}.detail-content{grid-template-columns:minmax(0,1fr) 225px;gap:0;padding:0}.detail-main{padding:0 18px 0 24px}.detail-sidebar{padding:28px 17px 16px;background:transparent;border-left:1px solid var(--review-divider)}.detail-title{padding:17px 0 9px;border:0}.detail-title h2{font-size:21px;font-weight:650}.detail-title-line>span{font-size:20px;color:var(--color-ink-muted)}.detail-title .detail-summary{min-height:34px;gap:7px}.detail-summary strong{font-size:12px;font-weight:400}.state-badge{font-size:13px;font-weight:400}.detail-branch-route code{font-size:13px}.detail-main>.merge-summary{min-height:54px;margin:0;padding:0 11px;gap:10px}.merge-summary>span,.merge-summary>strong{font-size:12px}.detail-change-stats{font-size:12px;gap:9px}.merge-summary>:global(svg){flex:none}.detail-main>.description{padding:20px 0 22px}.description h3{font-size:14px;font-weight:600;margin-bottom:10px}.description p{font-size:13px}.detail-main>.comments-section{padding:12px 0 0;overflow:visible;border:0}.comments-section>header{margin-bottom:10px}.comments-section h3{font-size:14px;font-weight:600}.comments-section>header>span{font-size:10px}.comment-list{gap:10px;margin:0;scrollbar-gutter:auto}.comment-list article .comment-card{min-height:78px;border-color:var(--review-divider)!important;background:transparent!important}.comment-list article header{justify-content:flex-start;gap:5px;padding:0 11px}.comment-card header .avatar{margin-right:5px;width:28px;height:28px}.comment-list article strong{font-size:12px;font-weight:500}.comment-list article time{font-size:11px;margin:0}.owner-badge{margin-left:auto;font-size:10px}.comment-list article p{font-size:12px;padding:2px 14px 15px 56px!important}.comment-composer{margin-top:auto;padding:16px 0 26px}.comment-composer textarea{font-size:13px;min-height:86px;height:86px;max-height:86px;border-bottom:1px solid var(--color-border-input);background:transparent}.comment-composer footer{margin-top:8px;border:0;background:transparent;min-height:39px}.comment-composer .submit-comment{height:37px;margin:0;font-size:13px}.format-actions{gap:4px;padding:0}.comment-composer .format-actions button{width:27px}.detail-sidebar .detail-actions{gap:15px;margin-bottom:27px}.review-center .detail-action{font-size:13px;position:relative}.detail-action>:global(svg){position:absolute;left:12px;width:18px;height:18px}.detail-action.primary-action{background:color-mix(in srgb,#63c783 4%,transparent)}.detail-sidebar section{padding:16px 0;border-top:1px solid var(--review-divider);border-bottom:0}.detail-sidebar section h3{font-size:14px;font-weight:600}.people.compact{display:flex;gap:10px}.people strong,.detail-meta strong,.detail-meta span{font-size:12px}.detail-meta strong,.detail-meta span{display:flex;align-items:center;gap:10px}.detail-meta :global(svg){flex:none;color:var(--color-ink-muted)}
|
||||||
|
@media(max-width:1450px){.review-center.has-inspector{--inspector-width:52%}.detail-content{grid-template-columns:minmax(0,1fr) 190px}.detail-main{padding:0 14px}.detail-title h2{font-size:17px}.detail-title-line>span{font-size:17px}.detail-summary{flex-wrap:wrap}.detail-main>.merge-summary{flex-wrap:wrap;gap:7px;padding:9px}.detail-change-stats{font-size:10px}.detail-main>.merge-summary>span{font-size:11px}.detail-sidebar{padding:24px 12px}.format-actions{gap:0}.comment-composer .format-actions button{width:23px}}
|
||||||
|
@media(max-width:900px){.review-center.has-inspector{--inspector-width:100%}.detail-content{display:grid;grid-template-columns:minmax(0,1fr) 200px}.detail-sidebar{display:flex}.detail-main{height:auto}.detail-panel{inset:0 0 0 auto;min-width:0}.group-actions .icon-button{display:flex!important}}
|
||||||
|
@media(max-width:520px){.detail-content{display:flex;flex-direction:column}.detail-main{flex:1;min-height:0}.detail-sidebar{flex:none;padding:8px 14px;border-top:1px solid var(--review-divider);border-left:0}.detail-sidebar section{display:none}.detail-sidebar .detail-actions{grid-template-columns:1fr 1fr;gap:6px;margin:0}.detail-action{height:32px}.detail-header{padding:0 14px}.detail-main>.description{padding:10px 0}.comment-composer{padding-bottom:8px}.detail-title h2{white-space:normal}}
|
||||||
|
|
||||||
|
|
||||||
|
/* The inspector overlays an unchanged background; only viewport size reflows it. */
|
||||||
|
.review-toolbar{box-sizing:border-box;width:100%;height:56px;min-height:56px;grid-template-columns:316px minmax(140px,1fr) 145px 30px;grid-template-rows:1fr;gap:10px;padding:0 14px 0 8px}
|
||||||
|
.review-toolbar .state-tabs{grid-column:1;grid-row:1;gap:8px;align-items:stretch}
|
||||||
|
.review-center .state-tabs button{min-width:0;flex:1;gap:6px;padding:0 6px;justify-content:center;font-size:12px;white-space:nowrap}
|
||||||
|
.state-tabs span{min-width:14px;height:16px;margin-left:0;padding:0 3px;font-size:10px}
|
||||||
|
.state-tabs button.active span{background:transparent}
|
||||||
|
.review-search{grid-column:2;grid-row:1;height:32px;order:0}
|
||||||
|
.review-search input{font-size:12px}
|
||||||
|
.integration-picker{position:relative;grid-column:3;grid-row:1;height:32px;min-width:0}
|
||||||
|
|
||||||
|
|
||||||
|
.review-toolbar .group-actions{grid-column:4;grid-row:1;order:0}
|
||||||
|
.review-toolbar .group-actions .icon-button{width:30px;height:32px}
|
||||||
|
@media(max-width:720px){.review-toolbar{grid-template-columns:minmax(120px,1fr) 145px 30px;grid-template-rows:36px 38px;height:80px;min-height:80px;gap:0 10px}.review-toolbar .state-tabs{grid-column:1/-1;grid-row:1;max-width:316px}.review-search{grid-column:1;grid-row:2}.integration-picker{grid-column:2;grid-row:2}.review-toolbar .group-actions{grid-column:3;grid-row:2}}
|
||||||
|
|
||||||
|
.integration-picker :global(.select-menu){width:100%;height:32px}
|
||||||
|
.integration-picker :global(.select-menu-trigger){height:32px;min-height:32px;font-size:12px}
|
||||||
|
/* Compact request actions, matching the approved menu reference. */
|
||||||
|
.review-center .row-actions .provider-button{min-width:54px;height:27px;min-height:27px;padding:0 8px;font-size:11px;font-weight:500}
|
||||||
|
.review-center .row-actions .action-toggle{width:25px;height:27px;min-height:27px;background:transparent}
|
||||||
|
.review-center .row-actions .action-toggle.active{color:#72d58f;background:color-mix(in srgb,#4fa565 8%,var(--app-bg))}
|
||||||
|
.review-center .row-actions .panel-button{width:27px;height:27px;min-height:27px}
|
||||||
|
.review-center .action-menu{top:36px;right:-2px;box-sizing:border-box;width:max-content;min-width:122px;padding:3px 0;border:1px solid var(--color-border);border-radius:0;background:color-mix(in srgb,var(--app-bg) 96%,var(--color-surface-raised));box-shadow:0 5px 12px #0006}
|
||||||
|
.review-center .action-menu:before{right:calc(50% - 4px)}
|
||||||
|
.review-center .action-menu button{box-sizing:border-box;display:flex;width:100%;height:30px;min-height:30px;justify-content:flex-start;align-items:center;gap:9px;padding:0 11px;margin:0;border:0;border-radius:0;font-size:11px;font-weight:400;line-height:1.2;text-align:left;white-space:nowrap;color:var(--color-ink)}
|
||||||
|
.review-center .action-menu button>:global(svg){width:14px;height:14px;flex:0 0 14px;color:var(--color-ink-muted)}
|
||||||
|
.review-center .action-menu button.danger>:global(svg){color:#e7767e}
|
||||||
|
.review-center .action-menu button.menu-conflict{color:#e2ac58}
|
||||||
|
.review-center .action-menu button:hover:not(:disabled){background:var(--color-surface-hover)}
|
||||||
|
</style>
|
||||||
@@ -50,7 +50,8 @@
|
|||||||
const rect = trigger.getBoundingClientRect();
|
const rect = trigger.getBoundingClientRect();
|
||||||
const viewportGap = 8;
|
const viewportGap = 8;
|
||||||
const menuGap = 5;
|
const menuGap = 5;
|
||||||
const desiredHeight = Math.min(300, options.length * 34 + 24);
|
const groupHeaderCount = new Set(options.map((option) => option.group).filter(Boolean)).size;
|
||||||
|
const desiredHeight = Math.min(300, options.length * 32 + groupHeaderCount * 36 + 12);
|
||||||
const spaceBelow = window.innerHeight - rect.bottom - viewportGap;
|
const spaceBelow = window.innerHeight - rect.bottom - viewportGap;
|
||||||
const spaceAbove = rect.top - viewportGap;
|
const spaceAbove = rect.top - viewportGap;
|
||||||
const openAbove = spaceBelow < Math.min(desiredHeight, 180) && spaceAbove > spaceBelow;
|
const openAbove = spaceBelow < Math.min(desiredHeight, 180) && spaceAbove > spaceBelow;
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
);
|
);
|
||||||
const versionLabel = $derived(
|
const versionLabel = $derived(
|
||||||
version && currentVersion
|
version && currentVersion
|
||||||
? `${currentVersion} -> ${version}`
|
? `${currentVersion} → ${version}`
|
||||||
: version
|
: version
|
||||||
? `Version ${version}`
|
? `Version ${version}`
|
||||||
: "New version",
|
: "New version",
|
||||||
@@ -69,34 +69,32 @@
|
|||||||
role={state === "error" ? "alert" : "status"}
|
role={state === "error" ? "alert" : "status"}
|
||||||
aria-live={state === "error" ? "assertive" : "polite"}
|
aria-live={state === "error" ? "assertive" : "polite"}
|
||||||
>
|
>
|
||||||
<div class="update-toast-glow" aria-hidden="true"></div>
|
<header class="update-toast-header">
|
||||||
|
<div class="update-toast-icon" class:busy={isBusy}>
|
||||||
<div class="update-toast-icon" class:busy={isBusy}>
|
{#if state === "downloading"}
|
||||||
{#if state === "downloading"}
|
<LoaderCircle class="spin" size={18} aria-hidden="true" />
|
||||||
<LoaderCircle class="spin" size={21} aria-hidden="true" />
|
{:else if state === "installed"}
|
||||||
{:else if state === "installed"}
|
<PackageCheck size={18} aria-hidden="true" />
|
||||||
<PackageCheck size={21} aria-hidden="true" />
|
{:else if state === "error"}
|
||||||
{:else if state === "error"}
|
<AlertCircle size={18} aria-hidden="true" />
|
||||||
<AlertCircle size={21} aria-hidden="true" />
|
{:else}
|
||||||
{:else}
|
<Sparkles size={18} aria-hidden="true" />
|
||||||
<Sparkles size={21} aria-hidden="true" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="update-toast-content">
|
|
||||||
<div class="update-toast-top">
|
|
||||||
<div class="update-toast-copy">
|
|
||||||
<span class="update-toast-kicker">{versionLabel}</span>
|
|
||||||
<h2>{title}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if !isBusy}
|
|
||||||
<button class="update-toast-close" type="button" onclick={onDismiss} title="Close" aria-label="Close">
|
|
||||||
<X size={15} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="update-toast-copy">
|
||||||
|
<span class="update-toast-kicker">Gitty update · {versionLabel}</span>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !isBusy}
|
||||||
|
<button class="update-toast-close" type="button" onclick={onDismiss} title="Close" aria-label="Close">
|
||||||
|
<X size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="update-toast-body">
|
||||||
<p>{description}</p>
|
<p>{description}</p>
|
||||||
|
|
||||||
{#if showProgress}
|
{#if showProgress}
|
||||||
@@ -115,33 +113,34 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="update-toast-actions">
|
|
||||||
{#if state === "installed"}
|
|
||||||
<button class="update-toast-secondary" type="button" onclick={onDismiss}>
|
|
||||||
Got it
|
|
||||||
</button>
|
|
||||||
{:else if state === "error"}
|
|
||||||
<button class="update-toast-secondary" type="button" onclick={onDismiss}>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
<button class="update-toast-primary" type="button" onclick={onInstall}>
|
|
||||||
<Download size={14} aria-hidden="true" />
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<button class="update-toast-secondary" type="button" onclick={onLater} disabled={isBusy}>
|
|
||||||
Later
|
|
||||||
</button>
|
|
||||||
<button class="update-toast-primary" type="button" onclick={onInstall} disabled={isBusy}>
|
|
||||||
{#if isBusy}
|
|
||||||
<LoaderCircle class="spin" size={14} aria-hidden="true" />
|
|
||||||
Installing
|
|
||||||
{:else}
|
|
||||||
<Download size={14} aria-hidden="true" />
|
|
||||||
Install
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<footer class="update-toast-actions">
|
||||||
|
{#if state === "installed"}
|
||||||
|
<button class="update-toast-secondary" type="button" onclick={onDismiss}>
|
||||||
|
Got it
|
||||||
|
</button>
|
||||||
|
{:else if state === "error"}
|
||||||
|
<button class="update-toast-secondary" type="button" onclick={onDismiss}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button class="update-toast-primary" type="button" onclick={onInstall}>
|
||||||
|
<Download size={14} aria-hidden="true" />
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button class="update-toast-secondary" type="button" onclick={onLater} disabled={isBusy}>
|
||||||
|
Later
|
||||||
|
</button>
|
||||||
|
<button class="update-toast-primary" type="button" onclick={onInstall} disabled={isBusy}>
|
||||||
|
{#if isBusy}
|
||||||
|
<LoaderCircle class="spin" size={14} aria-hidden="true" />
|
||||||
|
Installing
|
||||||
|
{:else}
|
||||||
|
<Download size={14} aria-hidden="true" />
|
||||||
|
Install
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+66
-19
@@ -3,9 +3,9 @@ import { tracedInvoke as invoke } from "./telemetry";
|
|||||||
import type {
|
import type {
|
||||||
AiReviewResult,
|
AiReviewResult,
|
||||||
AiCommitPlan,
|
AiCommitPlan,
|
||||||
CommitAiLocalProfile,
|
BisectState,
|
||||||
CommitAiProvider,
|
CommitAiProvider,
|
||||||
CommitAiStatus,
|
CloneOptions,
|
||||||
ConflictFile,
|
ConflictFile,
|
||||||
DetectedExternalTool,
|
DetectedExternalTool,
|
||||||
ExternalToolCommand,
|
ExternalToolCommand,
|
||||||
@@ -17,6 +17,8 @@ import type {
|
|||||||
GitIgnoreKind,
|
GitIgnoreKind,
|
||||||
GitIntegrationProvider,
|
GitIntegrationProvider,
|
||||||
GitIntegrationRepository,
|
GitIntegrationRepository,
|
||||||
|
IntegrationReviewRequest,
|
||||||
|
IntegrationReviewAction,
|
||||||
GitLfsStatus,
|
GitLfsStatus,
|
||||||
GitRepositoryFile,
|
GitRepositoryFile,
|
||||||
GitRemote,
|
GitRemote,
|
||||||
@@ -30,7 +32,6 @@ import type {
|
|||||||
GitStatus,
|
GitStatus,
|
||||||
GitTag,
|
GitTag,
|
||||||
GitWorktree,
|
GitWorktree,
|
||||||
LocalModelOption,
|
|
||||||
PatchApplyAction,
|
PatchApplyAction,
|
||||||
RepositoryBundle,
|
RepositoryBundle,
|
||||||
StoredCredential,
|
StoredCredential,
|
||||||
@@ -60,6 +61,26 @@ export function listIntegrationRepositories(provider: GitIntegrationProvider, ba
|
|||||||
return invoke<GitIntegrationRepository[]>("list_integration_repositories", { provider, baseUrl, accountId: accountId ?? null });
|
return invoke<GitIntegrationRepository[]>("list_integration_repositories", { provider, baseUrl, accountId: accountId ?? null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listIntegrationReviewRequests(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, state: "open" | "merged" | "closed"): Promise<IntegrationReviewRequest[]> {
|
||||||
|
return invoke<IntegrationReviewRequest[]>("list_integration_review_requests", { provider, baseUrl, username, token, state });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runIntegrationReviewAction(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest, action: IntegrationReviewAction): Promise<void> {
|
||||||
|
return invoke<void>("run_integration_review_action", { provider, baseUrl, username, token, repositoryId: request.repositoryId, repositoryName: request.repositoryName, number: request.number, action });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIntegrationReviewDetails(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest): Promise<IntegrationReviewRequest> {
|
||||||
|
return invoke<IntegrationReviewRequest>("get_integration_review_details", { provider, baseUrl, username, token, request });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addIntegrationReviewComment(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest, body: string): Promise<void> {
|
||||||
|
return invoke<void>("add_integration_review_comment", { provider, baseUrl, username, token, request, body });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openInBrowser(url: string): Promise<void> {
|
||||||
|
return invoke<void>("open_in_browser", { url });
|
||||||
|
}
|
||||||
|
|
||||||
export function launchExternalTool(path: string, command: ExternalToolCommand, file?: string): Promise<void> {
|
export function launchExternalTool(path: string, command: ExternalToolCommand, file?: string): Promise<void> {
|
||||||
return invoke<void>("launch_external_tool", { path, file: file ?? null, command });
|
return invoke<void>("launch_external_tool", { path, file: file ?? null, command });
|
||||||
}
|
}
|
||||||
@@ -83,6 +104,7 @@ export function cloneRepository(
|
|||||||
username?: string,
|
username?: string,
|
||||||
password?: string,
|
password?: string,
|
||||||
commitLimit = 100,
|
commitLimit = 100,
|
||||||
|
options: CloneOptions = { branch: null, blobless: false, customFlags: "", shallowDepth: null, shallowSince: null, sparse: false, sparsePaths: [] },
|
||||||
): Promise<RepositoryBundle> {
|
): Promise<RepositoryBundle> {
|
||||||
return invoke<RepositoryBundle>("clone_repository", {
|
return invoke<RepositoryBundle>("clone_repository", {
|
||||||
remoteUrl,
|
remoteUrl,
|
||||||
@@ -91,6 +113,13 @@ export function cloneRepository(
|
|||||||
username: username ?? null,
|
username: username ?? null,
|
||||||
password: password ?? null,
|
password: password ?? null,
|
||||||
commitLimit,
|
commitLimit,
|
||||||
|
branch: options.branch,
|
||||||
|
blobless: options.blobless,
|
||||||
|
customFlags: options.customFlags,
|
||||||
|
shallowDepth: options.shallowDepth,
|
||||||
|
shallowSince: options.shallowSince,
|
||||||
|
sparse: options.sparse,
|
||||||
|
sparsePaths: options.sparsePaths,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +127,22 @@ export function getStatus(path: string): Promise<GitStatus> {
|
|||||||
return invoke<GitStatus>("get_status", { path });
|
return invoke<GitStatus>("get_status", { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getBisectState(path: string): Promise<BisectState> {
|
||||||
|
return invoke<BisectState>("get_bisect_state", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startBisect(path: string, good: string, bad: string): Promise<BisectState> {
|
||||||
|
return invoke<BisectState>("start_bisect", { path, good, bad });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markBisect(path: string, verdict: "good" | "bad" | "skip"): Promise<BisectState> {
|
||||||
|
return invoke<BisectState>("mark_bisect", { path, verdict });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetBisect(path: string): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("reset_bisect", { path });
|
||||||
|
}
|
||||||
|
|
||||||
export function getGitLfsStatus(path: string): Promise<GitLfsStatus> {
|
export function getGitLfsStatus(path: string): Promise<GitLfsStatus> {
|
||||||
return invoke<GitLfsStatus>("git_lfs_status", { path });
|
return invoke<GitLfsStatus>("git_lfs_status", { path });
|
||||||
}
|
}
|
||||||
@@ -369,22 +414,9 @@ export function stashDrop(path: string, selector: string): Promise<GitStatus> {
|
|||||||
return invoke<GitStatus>("stash_drop", { path, selector });
|
return invoke<GitStatus>("stash_drop", { path, selector });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function commitAiStatus(): Promise<CommitAiStatus> {
|
|
||||||
return invoke<CommitAiStatus>("commit_ai_status");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function commitAiLoad(modelId: string): Promise<void> {
|
|
||||||
return invoke<void>("commit_ai_load", { modelId });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function commitAiLocalModels(): Promise<LocalModelOption[]> {
|
|
||||||
return invoke<LocalModelOption[]>("commit_ai_local_models");
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommitAiGenerateOptions {
|
export interface CommitAiGenerateOptions {
|
||||||
notes?: string;
|
notes?: string;
|
||||||
provider: CommitAiProvider;
|
provider: CommitAiProvider;
|
||||||
localProfile?: CommitAiLocalProfile;
|
|
||||||
model?: string;
|
model?: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
@@ -395,7 +427,6 @@ export function commitAiGenerate(path: string, options: CommitAiGenerateOptions)
|
|||||||
path,
|
path,
|
||||||
notes: options.notes,
|
notes: options.notes,
|
||||||
provider: options.provider,
|
provider: options.provider,
|
||||||
localProfile: options.localProfile,
|
|
||||||
model: options.model,
|
model: options.model,
|
||||||
apiKey: options.apiKey,
|
apiKey: options.apiKey,
|
||||||
baseUrl: options.baseUrl,
|
baseUrl: options.baseUrl,
|
||||||
@@ -422,8 +453,24 @@ export function commitAiSplit(path: string, options: CommitAiGenerateOptions): P
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pull(path: string, username?: string, password?: string, strategy: PullStrategy = "merge", remote?: string, branch?: string): Promise<GitStatus> {
|
export function pull(
|
||||||
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null, strategy, remote: remote || null, branch: branch || null });
|
path: string,
|
||||||
|
username?: string,
|
||||||
|
password?: string,
|
||||||
|
strategy: PullStrategy = "merge",
|
||||||
|
remote?: string,
|
||||||
|
branch?: string,
|
||||||
|
allowUnrelatedHistories = false,
|
||||||
|
): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("pull", {
|
||||||
|
path,
|
||||||
|
username: username ?? null,
|
||||||
|
password: password ?? null,
|
||||||
|
strategy,
|
||||||
|
remote: remote || null,
|
||||||
|
branch: branch || null,
|
||||||
|
allowUnrelatedHistories,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchRemote(path: string, username?: string, password?: string, prune = false, remote?: string): Promise<GitStatus> {
|
export function fetchRemote(path: string, username?: string, password?: string, prune = false, remote?: string): Promise<GitStatus> {
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export function beginFrontendShutdown() {
|
|||||||
frontendShuttingDown = true;
|
frontendShuttingDown = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resumeFrontend() {
|
||||||
|
frontendShuttingDown = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Telemetry must never contain repository contents or identifying local data.
|
// Telemetry must never contain repository contents or identifying local data.
|
||||||
function sanitize(message: string): string {
|
function sanitize(message: string): string {
|
||||||
return message
|
return message
|
||||||
|
|||||||
+62
-17
@@ -9,9 +9,7 @@ export type FileStatusKind =
|
|||||||
|
|
||||||
export type GitIgnoreKind = "file" | "extension" | "folder";
|
export type GitIgnoreKind = "file" | "extension" | "folder";
|
||||||
|
|
||||||
export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
|
export type CommitAiProvider = "openai" | "anthropic" | "custom";
|
||||||
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
|
|
||||||
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
|
|
||||||
export type AppTheme = "system" | "light" | "dark";
|
export type AppTheme = "system" | "light" | "dark";
|
||||||
export type AppAppearance = "modern" | "classic" | "custom";
|
export type AppAppearance = "modern" | "classic" | "custom";
|
||||||
export type AppLanguage = "en" | "de";
|
export type AppLanguage = "en" | "de";
|
||||||
@@ -67,6 +65,39 @@ export interface GitIntegrationRepository {
|
|||||||
private: boolean;
|
private: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IntegrationReviewState = "open" | "draft" | "merged" | "closed";
|
||||||
|
export type IntegrationReviewAction = "merge" | "approve" | "close" | "reopen";
|
||||||
|
|
||||||
|
export interface IntegrationReviewComment {
|
||||||
|
id: string;
|
||||||
|
author: string;
|
||||||
|
body: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntegrationReviewRequest {
|
||||||
|
id: string;
|
||||||
|
number: number;
|
||||||
|
provider: GitIntegrationProvider;
|
||||||
|
repositoryId: string;
|
||||||
|
repositoryName: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
author: string;
|
||||||
|
state: IntegrationReviewState;
|
||||||
|
sourceBranch: string;
|
||||||
|
targetBranch: string;
|
||||||
|
webUrl: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
collaborators: string[];
|
||||||
|
additions: number | null;
|
||||||
|
deletions: number | null;
|
||||||
|
changedFiles: number | null;
|
||||||
|
mergeStatus: "" | "mergeable" | "conflicts" | "checking" | "blocked";
|
||||||
|
comments: IntegrationReviewComment[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CustomThemeColors {
|
export interface CustomThemeColors {
|
||||||
background: string;
|
background: string;
|
||||||
surface: string;
|
surface: string;
|
||||||
@@ -74,12 +105,6 @@ export interface CustomThemeColors {
|
|||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommitAiStatus {
|
|
||||||
phase: CommitAiPhase;
|
|
||||||
model_id: string | null;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AiReviewRisk = "low" | "medium" | "high";
|
export type AiReviewRisk = "low" | "medium" | "high";
|
||||||
export type AiReviewSeverity = "critical" | "warning" | "info";
|
export type AiReviewSeverity = "critical" | "warning" | "info";
|
||||||
|
|
||||||
@@ -109,16 +134,8 @@ export interface AiCommitPlan {
|
|||||||
groups: AiCommitGroup[];
|
groups: AiCommitGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalModelOption {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
approx_size_mb: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiSettings {
|
export interface AiSettings {
|
||||||
provider: CommitAiProvider;
|
provider: CommitAiProvider;
|
||||||
localModelId: string;
|
|
||||||
localProfile: CommitAiLocalProfile;
|
|
||||||
openaiModel: string;
|
openaiModel: string;
|
||||||
anthropicModel: string;
|
anthropicModel: string;
|
||||||
customBaseUrl: string;
|
customBaseUrl: string;
|
||||||
@@ -177,6 +194,16 @@ export interface GitRemote { name: string; fetch_url: string; push_url: string;
|
|||||||
export type PullStrategy = "merge" | "rebase" | "ff-only";
|
export type PullStrategy = "merge" | "rebase" | "ff-only";
|
||||||
export type MergeStrategy = "default" | "squash" | "ff-only" | "no-ff";
|
export type MergeStrategy = "default" | "squash" | "ff-only" | "no-ff";
|
||||||
|
|
||||||
|
export interface CloneOptions {
|
||||||
|
branch: string | null;
|
||||||
|
blobless: boolean;
|
||||||
|
customFlags: string;
|
||||||
|
shallowDepth: number | null;
|
||||||
|
shallowSince: string | null;
|
||||||
|
sparse: boolean;
|
||||||
|
sparsePaths: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitFileStatus {
|
export interface GitFileStatus {
|
||||||
path: string;
|
path: string;
|
||||||
old_path: string | null;
|
old_path: string | null;
|
||||||
@@ -242,6 +269,24 @@ export interface GitCommit {
|
|||||||
has_note: boolean;
|
has_note: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BisectCommit {
|
||||||
|
hash: string;
|
||||||
|
short_hash: string;
|
||||||
|
summary: string;
|
||||||
|
author_name: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BisectState {
|
||||||
|
active: boolean;
|
||||||
|
finished: boolean;
|
||||||
|
current: BisectCommit | null;
|
||||||
|
culprit: BisectCommit | null;
|
||||||
|
remaining_revisions: number | null;
|
||||||
|
remaining_steps: number | null;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitCommitFile {
|
export interface GitCommitFile {
|
||||||
path: string;
|
path: string;
|
||||||
old_path: string | null;
|
old_path: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user