Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c652b5df8 | ||
|
|
200f01185a | ||
|
|
814f0e3a40 | ||
|
|
cd224fcc55 | ||
|
|
298bda2b54 | ||
|
|
b7a5bfe29f | ||
|
|
ee35169feb | ||
|
|
c7d6fa680a | ||
|
|
f7f85d387d | ||
|
|
0539020545 | ||
|
|
80c7cf7280 | ||
|
|
1b2460761f | ||
|
|
9e373e77d1 | ||
|
|
e7d6a6e4f4 | ||
|
|
5aa9d291b6 | ||
|
|
82a5f16a0c | ||
|
|
daa3298c44 | ||
|
|
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 |
@@ -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
|
||||
|
||||
> 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
|
||||
|
||||
@@ -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
|
||||
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
+25
-2
@@ -1,18 +1,20 @@
|
||||
{
|
||||
"name": "gitty",
|
||||
"version": "2026.8.8",
|
||||
"version": "2026.9.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitty",
|
||||
"version": "2026.8.8",
|
||||
"version": "2026.9.3",
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.21.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"dompurify": "^3.4.15",
|
||||
"marked": "^18.0.12",
|
||||
"simple-icons": "^16.24.1",
|
||||
"svelte": "^5.0.0",
|
||||
"tailwindcss": "^4.3.1"
|
||||
@@ -1557,6 +1559,15 @@
|
||||
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.15",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz",
|
||||
"integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.21.6",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
|
||||
@@ -1975,6 +1986,18 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.12",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.12.tgz",
|
||||
"integrity": "sha512-LEm4ga2YeI2T3GVHj9b0BaDPPk93LLTHMFeMyQbNIzPxc8vCI0y/scy0ZA6z6lXKyT9j9Nhl/OC6ZYKYGuFScA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitty",
|
||||
"version": "2026.8.8",
|
||||
"version": "2026.9.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -20,6 +20,8 @@
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"dompurify": "^3.4.15",
|
||||
"marked": "^18.0.12",
|
||||
"simple-icons": "^16.24.1",
|
||||
"svelte": "^5.0.0",
|
||||
"tailwindcss": "^4.3.1"
|
||||
|
||||
Generated
+1
@@ -1478,6 +1478,7 @@ dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shlex",
|
||||
"sysinfo",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
|
||||
@@ -20,10 +20,11 @@ tauri-plugin-dialog = "=2.7.0"
|
||||
tauri-plugin-aptabase = "1.0"
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
|
||||
commit_ai = { path = "crates/commit_ai" }
|
||||
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] }
|
||||
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "time"] }
|
||||
log = "0.4"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
sysinfo = { version = "=0.38.3", default-features = false, features = ["system"] }
|
||||
shlex = "2"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
+732
-9
@@ -8,7 +8,7 @@ use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Output, Stdio},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
Arc, Mutex, OnceLock,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
thread,
|
||||
@@ -131,6 +131,26 @@ pub struct GitCommit {
|
||||
pub has_note: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct BisectCommit {
|
||||
pub hash: String,
|
||||
pub short_hash: String,
|
||||
pub summary: String,
|
||||
pub author_name: String,
|
||||
pub date: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct BisectState {
|
||||
pub active: bool,
|
||||
pub finished: bool,
|
||||
pub current: Option<BisectCommit>,
|
||||
pub culprit: Option<BisectCommit>,
|
||||
pub remaining_revisions: Option<u32>,
|
||||
pub remaining_steps: Option<u32>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct GitCommitFile {
|
||||
pub path: String,
|
||||
@@ -632,8 +652,24 @@ pub async fn clone_repository(
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
commit_limit: Option<u32>,
|
||||
branch: Option<String>,
|
||||
blobless: Option<bool>,
|
||||
custom_flags: Option<String>,
|
||||
shallow_depth: Option<u32>,
|
||||
shallow_since: Option<String>,
|
||||
sparse: Option<bool>,
|
||||
sparse_paths: Option<Vec<String>>,
|
||||
) -> Result<RepositoryBundle, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let clone_options = CloneRunOptions {
|
||||
branch,
|
||||
blobless: blobless.unwrap_or(false),
|
||||
custom_flags: custom_flags.unwrap_or_default(),
|
||||
shallow_depth,
|
||||
shallow_since,
|
||||
sparse: sparse.unwrap_or(false),
|
||||
sparse_paths: sparse_paths.unwrap_or_default(),
|
||||
};
|
||||
clone_repository_core(
|
||||
&remote_url,
|
||||
&parent_path,
|
||||
@@ -641,6 +677,7 @@ pub async fn clone_repository(
|
||||
username.as_deref(),
|
||||
password.as_deref(),
|
||||
commit_limit,
|
||||
&clone_options,
|
||||
)
|
||||
})
|
||||
.await
|
||||
@@ -2824,6 +2861,14 @@ fn push_args_with_http_1_1(push_args: Vec<OsString>) -> Vec<OsString> {
|
||||
// ── Credential storage (OS keychain) ────────────────────────────────────────
|
||||
|
||||
const CRED_SERVICE: &str = "tauri_git_lite";
|
||||
static CREDENTIAL_STORE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
|
||||
fn credential_store_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
CREDENTIAL_STORE_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StoredCredential {
|
||||
@@ -2841,17 +2886,141 @@ fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
|
||||
keyring::Entry::new(CRED_SERVICE, key).map_err(|err| format!("Keychain unavailable: {err}"))
|
||||
}
|
||||
|
||||
pub(crate) fn load_stored_credential(key: &str) -> Result<Option<StoredCredential>, String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
fn secret_tool_load(key: &str) -> Result<Option<String>, String> {
|
||||
let output = match Command::new("secret-tool")
|
||||
.args(["lookup", "service", CRED_SERVICE, "username", key])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
{
|
||||
Ok(output) => output,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return keyring_load(key),
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"Could not start the system keychain helper: {error}"
|
||||
));
|
||||
}
|
||||
};
|
||||
if output.status.success() {
|
||||
return String::from_utf8(output.stdout)
|
||||
.map(|value| Some(value.trim_end_matches(['\r', '\n']).to_string()))
|
||||
.map_err(|error| format!("System keychain returned unreadable data: {error}"));
|
||||
}
|
||||
if output.status.code() == Some(1) && output.stderr.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if detail.is_empty() {
|
||||
"System keychain lookup failed.".to_string()
|
||||
} else {
|
||||
format!("System keychain lookup failed: {detail}")
|
||||
})
|
||||
}
|
||||
|
||||
fn keyring_load(key: &str) -> Result<Option<String>, String> {
|
||||
let entry = cred_entry(key)?;
|
||||
match entry.get_password() {
|
||||
Ok(json) => serde_json::from_str::<StoredCredential>(&json)
|
||||
.map(Some)
|
||||
.map_err(|err| format!("Stored credentials unreadable: {err}")),
|
||||
Ok(json) => Ok(Some(json)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(err) => Err(format!("Keychain access failed: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn secret_tool_save(key: &str, value: &str) -> Result<(), String> {
|
||||
let mut child = match Command::new("secret-tool")
|
||||
.args([
|
||||
"store",
|
||||
"--label=Gitty credentials",
|
||||
"service",
|
||||
CRED_SERVICE,
|
||||
"username",
|
||||
key,
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return cred_entry(key)?
|
||||
.set_password(value)
|
||||
.map_err(|err| format!("Saving to keychain failed: {err}"));
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"Could not start the system keychain helper: {error}"
|
||||
));
|
||||
}
|
||||
};
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "Could not open the system keychain helper input.".to_string())?
|
||||
.write_all(value.as_bytes())
|
||||
.map_err(|error| format!("Could not pass credentials to the system keychain: {error}"))?;
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|error| format!("System keychain helper failed: {error}"))?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if detail.is_empty() {
|
||||
"Saving to the system keychain failed.".to_string()
|
||||
} else {
|
||||
format!("Saving to the system keychain failed: {detail}")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn secret_tool_delete(key: &str) -> Result<(), String> {
|
||||
let output = match Command::new("secret-tool")
|
||||
.args(["clear", "service", CRED_SERVICE, "username", key])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
{
|
||||
Ok(output) => output,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return match cred_entry(key)?.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(err) => Err(format!("Deleting from keychain failed: {err}")),
|
||||
};
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"Could not start the system keychain helper: {error}"
|
||||
));
|
||||
}
|
||||
};
|
||||
if output.status.success() || output.status.code() == Some(1) {
|
||||
Ok(())
|
||||
} else {
|
||||
let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if detail.is_empty() {
|
||||
"Deleting from the system keychain failed.".to_string()
|
||||
} else {
|
||||
format!("Deleting from the system keychain failed: {detail}")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load_stored_credential(key: &str) -> Result<Option<StoredCredential>, String> {
|
||||
let _guard = credential_store_lock();
|
||||
#[cfg(target_os = "linux")]
|
||||
let stored = secret_tool_load(key)?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let stored = keyring_load(key)?;
|
||||
stored
|
||||
.map(|json| {
|
||||
serde_json::from_str::<StoredCredential>(&json)
|
||||
.map_err(|err| format!("Stored credentials unreadable: {err}"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Returns the remote URL used for auth key derivation (upstream remote of the
|
||||
/// current branch, falling back to `origin`, then the first configured remote).
|
||||
#[tauri::command(async)]
|
||||
@@ -3047,6 +3216,8 @@ pub fn cred_save(
|
||||
password: String,
|
||||
mode: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let _guard = credential_store_lock();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let entry = cred_entry(&key)?;
|
||||
let mode = match mode.as_deref() {
|
||||
Some("token") => Some("token".to_string()),
|
||||
@@ -3059,6 +3230,9 @@ pub fn cred_save(
|
||||
};
|
||||
let json = serde_json::to_string(&cred)
|
||||
.map_err(|err| format!("Could not serialize credentials: {err}"))?;
|
||||
#[cfg(target_os = "linux")]
|
||||
return secret_tool_save(&key, &json);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
entry
|
||||
.set_password(&json)
|
||||
.map_err(|err| format!("Saving to keychain failed: {err}"))
|
||||
@@ -3066,7 +3240,12 @@ pub fn cred_save(
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn cred_delete(key: String) -> Result<(), String> {
|
||||
let _guard = credential_store_lock();
|
||||
#[cfg(target_os = "linux")]
|
||||
return secret_tool_delete(&key);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let entry = cred_entry(&key)?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
match entry.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(err) => Err(format!("Deleting from keychain failed: {err}")),
|
||||
@@ -3360,6 +3539,181 @@ pub fn restore_reflog_entry(
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
fn bisect_in_progress(repo: &Path) -> Result<bool, String> {
|
||||
Ok(git_dir_for_repo(repo)?.join("BISECT_START").is_file())
|
||||
}
|
||||
|
||||
fn bisect_commit_for_ref(repo: &Path, revision: &str) -> Result<BisectCommit, String> {
|
||||
let output = run_git(
|
||||
repo,
|
||||
[
|
||||
"log",
|
||||
"-1",
|
||||
"--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI",
|
||||
revision,
|
||||
],
|
||||
)?;
|
||||
let text = String::from_utf8_lossy(&output);
|
||||
let fields = text.trim().splitn(5, '\x1f').collect::<Vec<_>>();
|
||||
if fields.len() != 5 {
|
||||
return Err("Git returned an unexpected bisect commit record.".to_string());
|
||||
}
|
||||
Ok(BisectCommit {
|
||||
hash: fields[0].to_string(),
|
||||
short_hash: fields[1].to_string(),
|
||||
summary: fields[2].to_string(),
|
||||
author_name: fields[3].to_string(),
|
||||
date: fields[4].to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_bisect_progress(message: &str) -> (Option<u32>, Option<u32>) {
|
||||
let Some(line) = message.lines().find(|line| line.contains("Bisecting:")) else {
|
||||
return (None, None);
|
||||
};
|
||||
let revisions = line.split("Bisecting:").nth(1).and_then(|tail| {
|
||||
tail.split_whitespace()
|
||||
.find_map(|word| word.parse::<u32>().ok())
|
||||
});
|
||||
let steps = line.split("roughly").nth(1).and_then(|tail| {
|
||||
tail.split_whitespace()
|
||||
.find_map(|word| word.parse::<u32>().ok())
|
||||
});
|
||||
(revisions, steps)
|
||||
}
|
||||
|
||||
fn parse_bisect_culprit(message: &str) -> Option<String> {
|
||||
for line in message.lines() {
|
||||
if line.contains(" is the first ") && line.contains("bad") && line.contains(" commit") {
|
||||
return line.split_whitespace().next().map(ToString::to_string);
|
||||
}
|
||||
if line.contains("first") && line.contains("bad") && line.contains("commit:") {
|
||||
let start = line.find('[')? + 1;
|
||||
let end = line[start..].find(']')? + start;
|
||||
return Some(line[start..end].to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn bisect_state_for_repo(repo: &Path, message: String) -> Result<BisectState, String> {
|
||||
let active = bisect_in_progress(repo)?;
|
||||
if !active {
|
||||
return Ok(BisectState {
|
||||
active: false,
|
||||
finished: false,
|
||||
current: None,
|
||||
culprit: None,
|
||||
remaining_revisions: None,
|
||||
remaining_steps: None,
|
||||
message,
|
||||
});
|
||||
}
|
||||
let log = run_git(repo, ["bisect", "log"])
|
||||
.map(|output| String::from_utf8_lossy(&output).into_owned())
|
||||
.unwrap_or_default();
|
||||
let culprit_hash = parse_bisect_culprit(&message).or_else(|| parse_bisect_culprit(&log));
|
||||
let current = bisect_commit_for_ref(repo, "HEAD")?;
|
||||
let culprit = culprit_hash
|
||||
.as_deref()
|
||||
.map(|hash| bisect_commit_for_ref(repo, hash))
|
||||
.transpose()?;
|
||||
let (remaining_revisions, remaining_steps) = parse_bisect_progress(&message);
|
||||
Ok(BisectState {
|
||||
active,
|
||||
finished: culprit.is_some(),
|
||||
current: Some(current),
|
||||
culprit,
|
||||
remaining_revisions,
|
||||
remaining_steps,
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_bisect_state(path: String) -> Result<BisectState, String> {
|
||||
run_git_task("Could not inspect Git bisect", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
bisect_state_for_repo(&repo, String::new())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_bisect(path: String, good: String, bad: String) -> Result<BisectState, String> {
|
||||
run_git_task("Could not start Git bisect", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
if bisect_in_progress(&repo)? {
|
||||
return Err("A Git bisect session is already active.".to_string());
|
||||
}
|
||||
let status = status_for_repo(&repo)?;
|
||||
if !status.clean {
|
||||
return Err(
|
||||
"Commit or stash working tree changes before starting Git bisect.".to_string(),
|
||||
);
|
||||
}
|
||||
if status.rebase_in_progress || status.cherry_pick_in_progress || status.merge_in_progress {
|
||||
return Err("Finish the current Git operation before starting Git bisect.".to_string());
|
||||
}
|
||||
let good_hash = verify_commit(&repo, &good)?;
|
||||
let bad_hash = verify_commit(&repo, &bad)?;
|
||||
if good_hash == bad_hash {
|
||||
return Err("Good and bad must reference different commits.".to_string());
|
||||
}
|
||||
let ancestry = git_command()
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args([
|
||||
"merge-base",
|
||||
"--is-ancestor",
|
||||
good_hash.as_str(),
|
||||
bad_hash.as_str(),
|
||||
])
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||
if !ancestry.status.success() {
|
||||
return Err("The good commit must be an ancestor of the bad commit.".to_string());
|
||||
}
|
||||
let output = run_git(
|
||||
&repo,
|
||||
["bisect", "start", bad_hash.as_str(), good_hash.as_str()],
|
||||
)?;
|
||||
bisect_state_for_repo(&repo, String::from_utf8_lossy(&output).trim().to_string())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mark_bisect(path: String, verdict: String) -> Result<BisectState, String> {
|
||||
run_git_task("Could not continue Git bisect", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
if !bisect_in_progress(&repo)? {
|
||||
return Err("No Git bisect session is active.".to_string());
|
||||
}
|
||||
let verdict = match verdict.as_str() {
|
||||
"good" => "good",
|
||||
"bad" => "bad",
|
||||
"skip" => "skip",
|
||||
_ => return Err("Bisect verdict must be good, bad, or skip.".to_string()),
|
||||
};
|
||||
let output = run_git(&repo, ["bisect", verdict])?;
|
||||
bisect_state_for_repo(&repo, String::from_utf8_lossy(&output).trim().to_string())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reset_bisect(path: String) -> Result<GitStatus, String> {
|
||||
run_git_task("Could not stop Git bisect", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
if bisect_in_progress(&repo)? {
|
||||
run_git(&repo, ["bisect", "reset"])?;
|
||||
}
|
||||
status_for_repo(&repo)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn interactive_rebase_commits_for_repo(
|
||||
repo: &Path,
|
||||
base: &str,
|
||||
@@ -5405,8 +5759,20 @@ fn repository_files_with_status(
|
||||
) -> Result<Vec<GitRepositoryFile>, String> {
|
||||
let mut files = BTreeMap::<String, GitRepositoryFile>::new();
|
||||
|
||||
let tracked_output = run_git(repo, ["ls-files", "-z", "--cached", "--deleted"])?;
|
||||
for path in parse_nul_paths(&tracked_output) {
|
||||
let tracked_output = run_git(repo, ["ls-files", "-z", "-t", "--cached", "--deleted"])?;
|
||||
for entry in tracked_output
|
||||
.split(|byte| *byte == 0)
|
||||
.filter(|entry| !entry.is_empty())
|
||||
{
|
||||
let (tag, path_bytes) = if entry.len() >= 2 && entry[1] == b' ' {
|
||||
(entry[0], &entry[2..])
|
||||
} else {
|
||||
(b'H', entry)
|
||||
};
|
||||
if tag == b'S' {
|
||||
continue;
|
||||
}
|
||||
let path = String::from_utf8_lossy(path_bytes).into_owned();
|
||||
let status = status_for_file(&status.files, &path);
|
||||
files.insert(
|
||||
path.clone(),
|
||||
@@ -5434,6 +5800,17 @@ fn repository_files_with_status(
|
||||
Ok(files.into_values().collect())
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct CloneRunOptions {
|
||||
branch: Option<String>,
|
||||
blobless: bool,
|
||||
custom_flags: String,
|
||||
shallow_depth: Option<u32>,
|
||||
shallow_since: Option<String>,
|
||||
sparse: bool,
|
||||
sparse_paths: Vec<String>,
|
||||
}
|
||||
|
||||
fn clone_repository_core(
|
||||
remote_url: &str,
|
||||
parent_path: &str,
|
||||
@@ -5441,9 +5818,16 @@ fn clone_repository_core(
|
||||
username: Option<&str>,
|
||||
password: Option<&str>,
|
||||
commit_limit: Option<u32>,
|
||||
clone_options: &CloneRunOptions,
|
||||
) -> Result<RepositoryBundle, String> {
|
||||
let target = clone_target_path(remote_url, parent_path, directory_name)?;
|
||||
run_git_clone(remote_url.trim(), &target, username, password)?;
|
||||
run_git_clone(
|
||||
remote_url.trim(),
|
||||
&target,
|
||||
username,
|
||||
password,
|
||||
clone_options,
|
||||
)?;
|
||||
|
||||
let repo = resolve_repo(&target.to_string_lossy())?;
|
||||
let lfs_warning = if verify_commit(&repo, "HEAD").is_ok() {
|
||||
@@ -5564,7 +5948,21 @@ fn run_git_clone(
|
||||
target: &Path,
|
||||
username: Option<&str>,
|
||||
password: Option<&str>,
|
||||
clone_options: &CloneRunOptions,
|
||||
) -> Result<(), String> {
|
||||
if matches!(clone_options.shallow_depth, Some(0)) {
|
||||
return Err("Shallow clone depth must be at least 1.".to_string());
|
||||
}
|
||||
if clone_options.shallow_depth.is_some() && clone_options.shallow_since.is_some() {
|
||||
return Err(
|
||||
"Choose either shallow clone depth or shallow clone date, not both.".to_string(),
|
||||
);
|
||||
}
|
||||
let branch = validate_clone_branch(clone_options.branch.as_deref())?;
|
||||
let shallow_since = validate_shallow_since(clone_options.shallow_since.as_deref())?;
|
||||
let custom_flags = parse_custom_clone_flags(&clone_options.custom_flags)?;
|
||||
let sparse_paths = validate_sparse_checkout_paths(&clone_options.sparse_paths)?;
|
||||
let sparse_enabled = clone_options.sparse || !sparse_paths.is_empty();
|
||||
let mut command = git_command();
|
||||
let has_explicit_credentials = matches!(
|
||||
(username, password),
|
||||
@@ -5573,8 +5971,24 @@ fn run_git_clone(
|
||||
if has_explicit_credentials {
|
||||
command.arg("-c").arg("credential.helper=");
|
||||
}
|
||||
command.arg("clone");
|
||||
if let Some(branch) = branch {
|
||||
command.arg("--branch").arg(branch);
|
||||
}
|
||||
if let Some(depth) = clone_options.shallow_depth {
|
||||
command.arg("--depth").arg(depth.to_string());
|
||||
}
|
||||
if let Some(since) = shallow_since {
|
||||
command.arg("--shallow-since").arg(since);
|
||||
}
|
||||
if clone_options.blobless {
|
||||
command.arg("--filter=blob:none");
|
||||
}
|
||||
if sparse_enabled {
|
||||
command.arg("--sparse");
|
||||
}
|
||||
command.args(custom_flags);
|
||||
command
|
||||
.arg("clone")
|
||||
.arg("--")
|
||||
.arg(remote_url)
|
||||
.arg(target)
|
||||
@@ -5601,6 +6015,9 @@ fn run_git_clone(
|
||||
let output = output?;
|
||||
|
||||
if output.status.success() {
|
||||
if !sparse_paths.is_empty() {
|
||||
configure_sparse_checkout(target, &sparse_paths)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -5612,6 +6029,104 @@ fn run_git_clone(
|
||||
Err(format!("Git clone failed: {}", details))
|
||||
}
|
||||
|
||||
fn validate_clone_branch(branch: Option<&str>) -> Result<Option<String>, String> {
|
||||
let Some(branch) = branch.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if branch.starts_with('-') || branch.chars().any(|character| character.is_control()) {
|
||||
return Err("Branch to clone contains invalid characters.".to_string());
|
||||
}
|
||||
Ok(Some(branch.to_string()))
|
||||
}
|
||||
|
||||
fn validate_shallow_since(since: Option<&str>) -> Result<Option<String>, String> {
|
||||
let Some(since) = since.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if since.starts_with('-') || since.chars().any(|character| character.is_control()) {
|
||||
return Err("Shallow clone date contains invalid characters.".to_string());
|
||||
}
|
||||
Ok(Some(since.to_string()))
|
||||
}
|
||||
|
||||
fn parse_custom_clone_flags(input: &str) -> Result<Vec<String>, String> {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let flags = shlex::split(input).ok_or_else(|| {
|
||||
"Custom clone flags contain an unclosed quote or invalid escape.".to_string()
|
||||
})?;
|
||||
const BLOCKED_LONG_FLAGS: &[&str] = &[
|
||||
"--bare",
|
||||
"--branch",
|
||||
"--config",
|
||||
"--depth",
|
||||
"--filter",
|
||||
"--mirror",
|
||||
"--no-checkout",
|
||||
"--reference",
|
||||
"--reference-if-able",
|
||||
"--separate-git-dir",
|
||||
"--shallow-exclude",
|
||||
"--shallow-since",
|
||||
"--sparse",
|
||||
"--template",
|
||||
"--upload-pack",
|
||||
];
|
||||
for flag in &flags {
|
||||
let name = flag.split_once('=').map_or(flag.as_str(), |(name, _)| name);
|
||||
if flag == "--"
|
||||
|| BLOCKED_LONG_FLAGS.contains(&name)
|
||||
|| matches!(name, "-b" | "-c" | "-n" | "-u")
|
||||
{
|
||||
return Err(format!(
|
||||
"Custom clone flag '{name}' is managed by Gitty or is not allowed."
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(flags)
|
||||
}
|
||||
|
||||
fn validate_sparse_checkout_paths(paths: &[String]) -> Result<Vec<String>, String> {
|
||||
let mut unique = BTreeSet::new();
|
||||
for raw_path in paths {
|
||||
let path = raw_path.trim();
|
||||
if path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if path.chars().any(|character| character.is_control()) {
|
||||
return Err("Sparse checkout paths contain invalid control characters.".to_string());
|
||||
}
|
||||
if Path::new(path).is_absolute()
|
||||
|| path == ".."
|
||||
|| path.starts_with("../")
|
||||
|| path.starts_with("..\\")
|
||||
{
|
||||
return Err("Sparse checkout paths must be relative to the repository.".to_string());
|
||||
}
|
||||
unique.insert(path.to_string());
|
||||
}
|
||||
if paths.len() > 0 && unique.is_empty() {
|
||||
return Err("Enter at least one sparse checkout path.".to_string());
|
||||
}
|
||||
Ok(unique.into_iter().collect())
|
||||
}
|
||||
|
||||
fn configure_sparse_checkout(target: &Path, paths: &[String]) -> Result<(), String> {
|
||||
let mut stdin = paths.join("\n");
|
||||
stdin.push('\n');
|
||||
run_git_with_stdin(
|
||||
target,
|
||||
["sparse-checkout", "set", "--no-cone", "--stdin"],
|
||||
stdin.as_bytes(),
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|error| {
|
||||
format!("Repository cloned, but sparse checkout could not be configured: {error}")
|
||||
})
|
||||
}
|
||||
|
||||
fn is_repository_folder_path(repo: &Path, path: &str) -> Result<bool, String> {
|
||||
let normalized = normalize_git_path(path);
|
||||
if repo.join(path).is_dir() {
|
||||
@@ -7795,6 +8310,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
Some(100),
|
||||
&CloneRunOptions::default(),
|
||||
)
|
||||
.expect("repository should clone");
|
||||
|
||||
@@ -7812,6 +8328,146 @@ mod tests {
|
||||
assert!(bundle.warning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "file:// clone URL differs on Windows")]
|
||||
fn clone_repository_core_supports_shallow_and_sparse_options() {
|
||||
let source = init_temp_repo("clone_options_source");
|
||||
commit_initial_file(&source.path);
|
||||
fs::create_dir_all(source.path.join("src")).expect("src directory should be created");
|
||||
fs::create_dir_all(source.path.join("docs")).expect("docs directory should be created");
|
||||
fs::write(source.path.join("src/included.txt"), b"included\n")
|
||||
.expect("included fixture should be written");
|
||||
fs::write(source.path.join("docs/excluded.txt"), b"excluded\n")
|
||||
.expect("excluded fixture should be written");
|
||||
run_git_test(
|
||||
&source.path,
|
||||
["add", "src/included.txt", "docs/excluded.txt"],
|
||||
);
|
||||
run_git_test(&source.path, ["commit", "-q", "-m", "add sparse fixtures"]);
|
||||
fs::write(source.path.join("src/included.txt"), b"latest\n")
|
||||
.expect("latest fixture should be written");
|
||||
run_git_test(&source.path, ["add", "src/included.txt"]);
|
||||
run_git_test(
|
||||
&source.path,
|
||||
["commit", "-q", "-m", "update included fixture"],
|
||||
);
|
||||
|
||||
let parent = temp_dir("clone_options_parent");
|
||||
let remote_url = format!("file://{}", source.path.display());
|
||||
let branch = git_output_test(&source.path, ["branch", "--show-current"]);
|
||||
let clone_options = CloneRunOptions {
|
||||
branch: Some(branch),
|
||||
blobless: true,
|
||||
custom_flags: "--single-branch".to_string(),
|
||||
shallow_depth: Some(1),
|
||||
shallow_since: None,
|
||||
sparse: true,
|
||||
sparse_paths: vec!["src/".to_string()],
|
||||
};
|
||||
let bundle = clone_repository_core(
|
||||
&remote_url,
|
||||
parent.path.to_str().expect("parent path should be UTF-8"),
|
||||
Some("local-copy"),
|
||||
None,
|
||||
None,
|
||||
Some(100),
|
||||
&clone_options,
|
||||
)
|
||||
.expect("repository should clone shallow and sparse");
|
||||
|
||||
let cloned_repo = parent.path.join("local-copy");
|
||||
assert!(cloned_repo.join(".git/shallow").exists());
|
||||
assert!(cloned_repo.join("src/included.txt").exists());
|
||||
assert!(!cloned_repo.join("docs/excluded.txt").exists());
|
||||
assert_eq!(bundle.commits.len(), 1);
|
||||
assert!(
|
||||
bundle
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.path == "src/included.txt")
|
||||
);
|
||||
assert!(
|
||||
!bundle
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.path == "docs/excluded.txt")
|
||||
);
|
||||
|
||||
let root_only_parent = temp_dir("clone_sparse_root_parent");
|
||||
let root_only_bundle = clone_repository_core(
|
||||
&remote_url,
|
||||
root_only_parent
|
||||
.path
|
||||
.to_str()
|
||||
.expect("root-only parent path should be UTF-8"),
|
||||
Some("local-copy"),
|
||||
None,
|
||||
None,
|
||||
Some(100),
|
||||
&CloneRunOptions {
|
||||
sparse: true,
|
||||
..CloneRunOptions::default()
|
||||
},
|
||||
)
|
||||
.expect("repository should clone sparse with root files only");
|
||||
let root_only_repo = root_only_parent.path.join("local-copy");
|
||||
assert!(root_only_repo.join("old.txt").exists());
|
||||
assert!(!root_only_repo.join("src/included.txt").exists());
|
||||
assert!(!root_only_repo.join("docs/excluded.txt").exists());
|
||||
assert!(
|
||||
root_only_bundle
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.path == "old.txt")
|
||||
);
|
||||
assert!(
|
||||
!root_only_bundle
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.path == "src/included.txt")
|
||||
);
|
||||
|
||||
let since_parent = temp_dir("clone_since_parent");
|
||||
let since_options = CloneRunOptions {
|
||||
branch: clone_options.branch.clone(),
|
||||
shallow_since: Some("2000-01-01".to_string()),
|
||||
..CloneRunOptions::default()
|
||||
};
|
||||
let since_bundle = clone_repository_core(
|
||||
&remote_url,
|
||||
since_parent
|
||||
.path
|
||||
.to_str()
|
||||
.expect("since parent path should be UTF-8"),
|
||||
Some("local-copy"),
|
||||
None,
|
||||
None,
|
||||
Some(100),
|
||||
&since_options,
|
||||
)
|
||||
.expect("repository should clone with a shallow-since date");
|
||||
assert_eq!(since_bundle.status.current_branch, clone_options.branch);
|
||||
assert!(
|
||||
since_parent
|
||||
.path
|
||||
.join("local-copy/src/included.txt")
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_clone_flags_are_parsed_without_a_shell_and_managed_flags_are_rejected() {
|
||||
assert_eq!(
|
||||
parse_custom_clone_flags("--recurse-submodules --origin 'team remote'")
|
||||
.expect("custom flags should parse"),
|
||||
vec!["--recurse-submodules", "--origin", "team remote"]
|
||||
);
|
||||
assert!(parse_custom_clone_flags("--depth 5").is_err());
|
||||
assert!(parse_custom_clone_flags("--upload-pack=/tmp/helper").is_err());
|
||||
assert!(parse_custom_clone_flags("--config core.hooksPath=/tmp/hooks").is_err());
|
||||
assert!(parse_custom_clone_flags("--recurse-submodules '").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_repository_core_supports_empty_repository() {
|
||||
let source = init_bare_temp_repo("empty_clone_source");
|
||||
@@ -7824,6 +8480,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
Some(100),
|
||||
&CloneRunOptions::default(),
|
||||
)
|
||||
.expect("empty repository should clone");
|
||||
|
||||
@@ -7882,6 +8539,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
Some(100),
|
||||
&CloneRunOptions::default(),
|
||||
)
|
||||
.expect("LFS repository should clone");
|
||||
let cloned_repo = parent.path.join("local-copy");
|
||||
@@ -9796,4 +10454,69 @@ mod tests {
|
||||
assert_eq!(git_output_test(&repo.path, ["ls-files"]), "keep.txt");
|
||||
assert!(repo.path.join("keep.txt").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_bisect_progress_and_culprit_output() {
|
||||
let message = "Bisecting: 7 revisions left to test after this (roughly 3 steps)\nabc123 is the first 'bad' commit";
|
||||
assert_eq!(parse_bisect_progress(message), (Some(7), Some(3)));
|
||||
assert_eq!(parse_bisect_culprit(message).as_deref(), Some("abc123"));
|
||||
assert_eq!(
|
||||
parse_bisect_culprit("# first 'bad' commit: [def456] broken behavior").as_deref(),
|
||||
Some("def456")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bisect_state_tracks_a_complete_session_and_reset() {
|
||||
let repo = init_temp_repo("bisect_session");
|
||||
let mut hashes = Vec::new();
|
||||
for index in 0..6 {
|
||||
fs::write(repo.path.join("value.txt"), format!("{index}\n"))
|
||||
.expect("fixture should be written");
|
||||
run_git_test(&repo.path, ["add", "value.txt"]);
|
||||
run_git_test(
|
||||
&repo.path,
|
||||
["commit", "-q", "-m", &format!("commit {index}")],
|
||||
);
|
||||
hashes.push(git_output_test(&repo.path, ["rev-parse", "HEAD"]));
|
||||
}
|
||||
|
||||
let output = run_git(
|
||||
&repo.path,
|
||||
["bisect", "start", hashes[5].as_str(), hashes[0].as_str()],
|
||||
)
|
||||
.expect("bisect should start");
|
||||
let mut state = bisect_state_for_repo(
|
||||
&repo.path,
|
||||
String::from_utf8_lossy(&output).trim().to_string(),
|
||||
)
|
||||
.expect("bisect state should load");
|
||||
assert!(state.active);
|
||||
assert!(state.current.is_some());
|
||||
|
||||
for _ in 0..8 {
|
||||
if state.finished {
|
||||
break;
|
||||
}
|
||||
let output =
|
||||
run_git(&repo.path, ["bisect", "good"]).expect("bisect verdict should succeed");
|
||||
state = bisect_state_for_repo(
|
||||
&repo.path,
|
||||
String::from_utf8_lossy(&output).trim().to_string(),
|
||||
)
|
||||
.expect("bisect state should advance");
|
||||
}
|
||||
assert!(state.finished);
|
||||
assert_eq!(
|
||||
state.culprit.as_ref().map(|commit| &commit.hash),
|
||||
Some(&hashes[5])
|
||||
);
|
||||
|
||||
run_git(&repo.path, ["bisect", "reset"]).expect("bisect should reset");
|
||||
assert!(!bisect_in_progress(&repo.path).expect("bisect state should be readable"));
|
||||
assert_eq!(
|
||||
git_output_test(&repo.path, ["rev-parse", "HEAD"]),
|
||||
hashes[5]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
mod issue_actions;
|
||||
pub use issue_actions::*;
|
||||
mod issue_comments;
|
||||
pub use issue_comments::*;
|
||||
mod boards;
|
||||
pub use boards::*;
|
||||
mod issues;
|
||||
pub use issues::*;
|
||||
|
||||
use crate::git::load_stored_credential;
|
||||
use reqwest::blocking::{Client, Response};
|
||||
use reqwest::header::{ACCEPT, USER_AGENT};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use std::time::Duration;
|
||||
|
||||
const PAGE_SIZE: usize = 100;
|
||||
mod creation;
|
||||
pub use creation::{create_integration_review_request, list_integration_repository_branches};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
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, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IntegrationRepository {
|
||||
pub id: String,
|
||||
@@ -20,6 +35,46 @@ pub struct IntegrationRepository {
|
||||
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)]
|
||||
struct GitLabProject {
|
||||
id: u64,
|
||||
@@ -118,7 +173,8 @@ fn integration_key(provider: &str, account_id: Option<&str>) -> Result<String, S
|
||||
|
||||
fn client() -> Result<Client, String> {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(25))
|
||||
.connect_timeout(Duration::from_secs(7))
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|err| format!("Could not initialize the integration client: {err}"))
|
||||
}
|
||||
@@ -350,6 +406,852 @@ fn azure_repositories(
|
||||
.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]
|
||||
pub async fn list_integration_repositories(
|
||||
provider: String,
|
||||
@@ -454,4 +1356,47 @@ mod tests {
|
||||
"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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Discover boards without changing provider data. Return partial results with explicit warnings.
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BoardReference { title: String, web_url: String, scope: String }
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BoardDirectory { boards: Vec<BoardReference>, warnings: Vec<String> }
|
||||
|
||||
impl BoardDirectory {
|
||||
fn add(&mut self, api: &BoardApi, title: String, web_url: String, scope: String) {
|
||||
if board_path(api.base, &web_url).is_ok() && !self.boards.iter().any(|b| b.web_url == web_url) {
|
||||
self.boards.push(BoardReference { title, web_url, scope });
|
||||
}
|
||||
}
|
||||
fn warn(&mut self, scope: &str, error: String) { self.warnings.push(format!("{scope}: {error}")); }
|
||||
}
|
||||
|
||||
fn rest_directory(api: &BoardApi, out: &mut BoardDirectory) -> Result<(), String> {
|
||||
if api.provider == "gitea" {
|
||||
// The deployed server schema is more useful than guessing from its version.
|
||||
if let Ok(schema) = api.get(endpoint(api.base, &["swagger.v1.json"])?) {
|
||||
if schema["paths"].as_object().is_some_and(|paths| !paths.contains_key("/repos/{owner}/{repo}/projects")) {
|
||||
return Err("This Gitea server publishes no Projects/Columns API. Automatic board discovery and import are unavailable; use the original web board.".into());
|
||||
}
|
||||
}
|
||||
let repos = api.pages(endpoint(api.base, &["api", "v1", "user", "repos"])?, &[])?;
|
||||
for repo in repos {
|
||||
if repo["has_projects"] == false { continue; }
|
||||
let full = value_string(&repo, &["full_name"]);
|
||||
let Some((owner, name)) = full.split_once('/') else { continue; };
|
||||
let result = api.pages(endpoint(api.base, &["api", "v1", "repos", owner, name, "projects"])?, &[("state".into(), "open".into())]);
|
||||
match result {
|
||||
Ok(boards) => for board in boards {
|
||||
let id = json_id(&board);
|
||||
out.add(api, value_string(&board, &["title"]), endpoint(api.base, &[owner, name, "projects", &id])?, full.clone());
|
||||
},
|
||||
Err(e) => out.warn(&full, e),
|
||||
}
|
||||
}
|
||||
match api.pages(endpoint(api.base, &["api", "v1", "user", "orgs"])?, &[]) {
|
||||
Ok(orgs) => for org in orgs {
|
||||
let name = value_string(&org, &["username"]);
|
||||
let name = if name.is_empty() { value_string(&org, &["name"]) } else { name };
|
||||
match api.pages(endpoint(api.base, &["api", "v1", "orgs", &name, "projects"])?, &[("state".into(), "open".into())]) {
|
||||
Ok(boards) => for board in boards { out.add(api, value_string(&board, &["title"]), endpoint(api.base, &["org", &name, "projects", &json_id(&board)])?, name.clone()); },
|
||||
Err(e) => out.warn(&name, e),
|
||||
}
|
||||
},
|
||||
Err(e) => out.warn("Organizations", e),
|
||||
}
|
||||
} else {
|
||||
for (scope, query) in [("projects", vec![("membership".into(), "true".into()), ("archived".into(), "false".into())]), ("groups", vec![("all_available".into(), "false".into())])] {
|
||||
let entities = match api.pages(endpoint(api.base, &["api", "v4", scope])?, &query) { Ok(v) => v, Err(e) => { out.warn(scope, e); continue; } };
|
||||
for entity in entities {
|
||||
let id = json_id(&entity);
|
||||
let name = value_string(&entity, &[if scope == "groups" { "full_path" } else { "path_with_namespace" }]);
|
||||
let base = value_string(&entity, &["web_url"]);
|
||||
match api.pages(endpoint(api.base, &["api", "v4", scope, &id, "boards"])?, &[]) {
|
||||
Ok(boards) => for board in boards {
|
||||
out.add(api, value_string(&board, &["name"]), format!("{}/-/boards/{}", base.trim_end_matches('/'), json_id(&board)), name.clone());
|
||||
},
|
||||
Err(e) => out.warn(&name, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn github_directory(api: &BoardApi, out: &mut BoardDirectory) -> Result<(), String> {
|
||||
let graphql = "https://api.github.com/graphql".to_string();
|
||||
let mut owners = vec![];
|
||||
let mut cursor = Value::Null;
|
||||
for page in 0..20 {
|
||||
let data = api.graphql(graphql.clone(), "query($cursor:String){viewer{login organizations(first:100,after:$cursor){nodes{login} pageInfo{hasNextPage endCursor}}}}", json!({"cursor":cursor}))?;
|
||||
let viewer = &data["data"]["viewer"];
|
||||
if page == 0 { owners.push(("user", value_string(viewer, &["login"]))); }
|
||||
let orgs = &viewer["organizations"];
|
||||
for org in orgs["nodes"].as_array().ok_or("Could not read GitHub organizations.")? { owners.push(("organization", value_string(org, &["login"]))); }
|
||||
if orgs["pageInfo"]["hasNextPage"] != true { break; }
|
||||
cursor = orgs["pageInfo"]["endCursor"].clone();
|
||||
if page == 19 { out.warn("GitHub", "Organization page limit reached.".into()); }
|
||||
}
|
||||
for (kind, owner) in owners {
|
||||
let query = format!("query($owner:String!,$cursor:String){{{kind}(login:$owner){{projectsV2(first:100,after:$cursor){{nodes{{title url closed}} pageInfo{{hasNextPage endCursor}}}}}}}}");
|
||||
let mut cursor = Value::Null;
|
||||
for page in 0..20 {
|
||||
let data = match api.graphql(graphql.clone(), &query, json!({"owner":owner,"cursor":cursor})) { Ok(v) => v, Err(e) => { out.warn(&owner, e); break; } };
|
||||
let projects = &data["data"][kind]["projectsV2"];
|
||||
let Some(nodes) = projects["nodes"].as_array() else { out.warn(&owner, "Project list unavailable; check read:project access.".into()); break; };
|
||||
for board in nodes { if board["closed"] != true { out.add(api, value_string(board, &["title"]), value_string(board, &["url"]), owner.clone()); } }
|
||||
if projects["pageInfo"]["hasNextPage"] != true { break; }
|
||||
cursor = projects["pageInfo"]["endCursor"].clone();
|
||||
if page == 19 { out.warn(&owner, "Project page limit reached.".into()); }
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn azure_directory(api: &BoardApi, out: &mut BoardDirectory) -> Result<(), String> {
|
||||
for page in 0..20 {
|
||||
let teams = api.read(api.request(reqwest::Method::GET, endpoint(api.base, &["_apis", "teams"])?)
|
||||
.query(&[("api-version", "7.1-preview.3".to_string()), ("$top", "100".into()), ("$skip", (page * 100).to_string())]))?;
|
||||
let teams = teams["value"].as_array().ok_or("Could not read Azure teams.")?;
|
||||
if teams.is_empty() { return Ok(()); }
|
||||
for team in teams {
|
||||
let project = value_string(team, &["projectName"]);
|
||||
let name = value_string(team, &["name"]);
|
||||
match api.read(api.request(reqwest::Method::GET, endpoint(api.base, &[&project, &name, "_apis", "work", "boards"])?)
|
||||
.query(&[("api-version", "7.1")])) {
|
||||
Ok(data) => {
|
||||
let Some(boards) = data["value"].as_array() else { out.warn(&name, "Invalid Azure board list.".into()); continue; };
|
||||
for board in boards {
|
||||
let title = value_string(board, &["name"]);
|
||||
out.add(api, title.clone(), endpoint(api.base, &[&project, "_boards", "board", "t", &name, &title])?, format!("{project} / {name}"));
|
||||
}
|
||||
},
|
||||
Err(e) => out.warn(&name, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
out.warn("Azure", "Team page limit reached.".into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_integration_boards(provider: String, base_url: String, username: String, token: String) -> Result<BoardDirectory, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
if token.trim().is_empty() { return Err("No token stored for this integration.".into()); }
|
||||
let base = normalized_base_url(&base_url)?;
|
||||
let api = BoardApi { client: Client::builder().connect_timeout(Duration::from_secs(7)).timeout(Duration::from_secs(12)).redirect(reqwest::redirect::Policy::none()).build().map_err(|e| e.to_string())?, provider: &provider, base: &base, username: &username, token: &token, started: Instant::now() };
|
||||
let mut out = BoardDirectory::default();
|
||||
let result = match provider.as_str() {
|
||||
"gitea" | "gitlab" | "gitlab-self-hosted" => rest_directory(&api, &mut out),
|
||||
"github" => github_directory(&api, &mut out),
|
||||
"azure-devops" => azure_directory(&api, &mut out),
|
||||
_ => Err("Unsupported board provider.".into()),
|
||||
};
|
||||
if let Err(e) = result { out.warn("Board discovery", e); }
|
||||
out.boards.sort_by(|a,b| (&a.scope, &a.title).cmp(&(&b.scope, &b.title)));
|
||||
Ok(out)
|
||||
}).await.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::super::tests::{mock_api, test_api};
|
||||
#[test]
|
||||
fn missing_gitea_routes_are_reported_without_querying_repositories() {
|
||||
let (base, worker) = mock_api(vec![("/swagger.v1.json ", json!({"paths":{"/repos/{owner}/{repo}":{}}}))]);
|
||||
let mut out = BoardDirectory::default();
|
||||
let error = rest_directory(&test_api(&base,"gitea"), &mut out).unwrap_err();
|
||||
assert!(error.contains("publishes no Projects/Columns API"));
|
||||
assert!(out.boards.is_empty());
|
||||
worker.join().unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn gitea_discovers_multiple_repository_boards_and_paginates() {
|
||||
let (base, worker) = mock_api(vec![
|
||||
("/swagger.v1.json ",json!({"paths":{"/repos/{owner}/{repo}/projects":{}}})),
|
||||
("/user/repos?page=1",json!([{"full_name":"team/repo"}])),
|
||||
("/user/repos?page=2",json!([])),
|
||||
("/repos/team/repo/projects?state=open&page=1",json!([{"id":1,"title":"Delivery"},{"id":2,"title":"Roadmap"}])),
|
||||
("/repos/team/repo/projects?state=open&page=2",json!([])),
|
||||
("/user/orgs?page=1",json!([])),
|
||||
]);
|
||||
let mut out = BoardDirectory::default();
|
||||
rest_directory(&test_api(&base,"gitea"), &mut out).unwrap();
|
||||
assert_eq!(out.boards.len(),2);
|
||||
assert_eq!(out.boards[0].web_url,format!("{base}/team/repo/projects/1"));
|
||||
assert!(out.warnings.is_empty());
|
||||
worker.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Persist one user-requested column move. Resolve all mutation metadata from a fresh board.
|
||||
use super::*;
|
||||
|
||||
fn gitlab_move_body(board: &IntegrationBoard, source: &BoardColumn, target: &BoardColumn) -> Result<Value, String> {
|
||||
let metadata = target.move_target.as_ref().ok_or("This board grouping does not support moving cards.")?;
|
||||
let label = value_string(metadata, &["label"]);
|
||||
// Only remove this board's grouping labels. Unrelated issue labels stay intact.
|
||||
let remove = board.columns.iter().filter(|c| target.id == "open" || c.id == source.id).filter_map(|c| c.move_target.as_ref())
|
||||
.map(|m| value_string(m, &["label"]))
|
||||
.filter(|name| !name.is_empty() && *name != label).collect::<Vec<_>>();
|
||||
if target.id != "closed" && remove.iter().any(|label| strings(metadata, "protectedLabels", "name").contains(label)) {
|
||||
return Err("This move would remove a label required by the board filter. Open the original board to change its scope.".into());
|
||||
}
|
||||
let mut body = json!({"state_event": if target.id == "closed" { "close" } else { "reopen" }});
|
||||
if target.id != "closed" {
|
||||
body["remove_labels"] = json!(remove.join(","));
|
||||
if !label.is_empty() { body["add_labels"] = json!(label); }
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
fn azure_patch(item: &Value, metadata: &Value) -> Result<Value, String> {
|
||||
let kind = value_string(item, &["fields", "System.WorkItemType"]);
|
||||
let state = value_string(&metadata["states"], &[&kind]);
|
||||
if state.is_empty() { return Err("The target column has no state mapping for this work item type.".into()); }
|
||||
let rev = item["rev"].as_u64().ok_or("Azure returned no work item revision.")?;
|
||||
let field = value_string(metadata, &["columnField"]);
|
||||
wiql_field(&field)?;
|
||||
let mut patch = vec![json!({"op":"test","path":"/rev","value":rev}),
|
||||
json!({"op":"add","path":"/fields/System.State","value":state}),
|
||||
json!({"op":"add","path":format!("/fields/{field}"),"value":metadata["name"]})];
|
||||
let done_field = value_string(metadata, &["doneField"]);
|
||||
if !done_field.is_empty() {
|
||||
wiql_field(&done_field)?;
|
||||
patch.push(json!({"op":"add","path":format!("/fields/{done_field}"),"value":metadata["done"]}));
|
||||
}
|
||||
Ok(json!(patch))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn move_integration_board_card(provider: String, base_url: String, username: String, token: String, board_url: String, card_id: String, source_column_id: String, target_column_id: String) -> Result<(), String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
if token.trim().is_empty() { return Err("No token stored for this integration.".into()); }
|
||||
let base = normalized_base_url(&base_url)?;
|
||||
let path = board_path(&base, &board_url)?;
|
||||
let api = BoardApi { client: Client::builder().connect_timeout(Duration::from_secs(7)).timeout(Duration::from_secs(12)).redirect(reqwest::redirect::Policy::none()).build().map_err(|e| e.to_string())?, provider: &provider, base: &base, username: &username, token: &token, started: Instant::now() };
|
||||
let board = match provider.as_str() {
|
||||
"github" => github_board(&api, &path, &board_url)?,
|
||||
"gitlab" | "gitlab-self-hosted" => gitlab_board(&api, &path, &board_url)?,
|
||||
"azure-devops" => azure_board(&api, &path, &board_url)?,
|
||||
_ => return Err("Moving cards is not supported for this integration.".into()),
|
||||
};
|
||||
let card = board.columns.iter().find(|c| c.id == source_column_id).and_then(|c| c.cards.iter().find(|c| c.id == card_id))
|
||||
.ok_or("The card has moved or is no longer in this board. Refresh before trying again.")?;
|
||||
let target = board.columns.iter().find(|c| c.id == target_column_id).ok_or("The target column no longer exists.")?;
|
||||
let metadata = target.move_target.as_ref().ok_or("This column does not support moving cards.")?;
|
||||
if source_column_id == target_column_id { return Ok(()); }
|
||||
match provider.as_str() {
|
||||
"github" => {
|
||||
let mut input = json!({"projectId":metadata["project"],"fieldId":metadata["field"],"itemId":card.id});
|
||||
let query = if target.id == "unassigned" {
|
||||
"mutation($input:ClearProjectV2ItemFieldValueInput!){clearProjectV2ItemFieldValue(input:$input){projectV2Item{id}}}"
|
||||
} else {
|
||||
input["value"] = json!({"singleSelectOptionId":target.id});
|
||||
"mutation($input:UpdateProjectV2ItemFieldValueInput!){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id}}}"
|
||||
};
|
||||
api.graphql("https://api.github.com/graphql".into(), query, json!({"input":input}))?;
|
||||
},
|
||||
"gitlab" | "gitlab-self-hosted" => {
|
||||
if card.repository.is_empty() { return Err("The issue's repository could not be resolved.".into()); }
|
||||
let source = board.columns.iter().find(|c| c.id == source_column_id).ok_or("Source column no longer exists.")?;
|
||||
let body = gitlab_move_body(&board, source, target)?;
|
||||
api.read(api.request(reqwest::Method::PUT, endpoint(&base, &["api","v4","projects",&card.repository,"issues",&card.number.to_string()])?).json(&body))?;
|
||||
},
|
||||
"azure-devops" => {
|
||||
let url = endpoint(&base, &["_apis","wit","workitems",&card.number.to_string()])?;
|
||||
let item = api.read(api.request(reqwest::Method::GET,url.clone()).query(&[("api-version","7.1")]))?;
|
||||
let source = board.columns.iter().find(|c| c.id == source_column_id).and_then(|c| c.move_target.as_ref()).ok_or("Source column no longer exists.")?;
|
||||
let field = value_string(source, &["columnField"]);
|
||||
let done = value_string(source, &["doneField"]);
|
||||
if item["fields"][&field] != source["name"] || (!done.is_empty() && item["fields"][&done].as_bool().unwrap_or(false) != source["done"].as_bool().unwrap_or(false)) {
|
||||
return Err("The card has moved since the board was read. Refresh before trying again.".into());
|
||||
}
|
||||
let patch = azure_patch(&item,metadata)?;
|
||||
api.read(api.request(reqwest::Method::PATCH,url).query(&[("api-version","7.1")]).header("Content-Type","application/json-patch+json").json(&patch))?;
|
||||
},
|
||||
_ => unreachable!(),
|
||||
}
|
||||
Ok(())
|
||||
}).await.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn label_move_preserves_unrelated_labels_and_close_keeps_assignments() {
|
||||
let mut a = column("1".into(),"Todo".into()); a.move_target=Some(json!({"label":"Todo"}));
|
||||
let mut b = column("2".into(),"Build".into()); b.move_target=Some(json!({"label":"Build"}));
|
||||
let mut c = column("closed".into(),"Closed".into()); c.move_target=Some(json!({"state":"close"}));
|
||||
let board=IntegrationBoard{title:String::new(),web_url:String::new(),notice:String::new(),columns:vec![a,b,c]};
|
||||
assert_eq!(gitlab_move_body(&board,&board.columns[0],&board.columns[1]).unwrap(),json!({"add_labels":"Build","remove_labels":"Todo","state_event":"reopen"}));
|
||||
assert_eq!(gitlab_move_body(&board,&board.columns[0],&board.columns[2]).unwrap(),json!({"state_event":"close"}));
|
||||
}
|
||||
#[test]
|
||||
fn azure_move_checks_revision_and_uses_team_specific_fields() {
|
||||
let item=json!({"rev":7,"fields":{"System.WorkItemType":"Bug"}});
|
||||
let metadata=json!({"columnField":"WEF_Test.Column","doneField":"WEF_Test.Done","name":"Review","done":true,"states":{"Bug":"Active"}});
|
||||
let patch=azure_patch(&item,&metadata).unwrap();
|
||||
assert_eq!(patch[0],json!({"op":"test","path":"/rev","value":7}));
|
||||
assert_eq!(patch[2]["path"],"/fields/WEF_Test.Column");
|
||||
assert_eq!(patch[3]["value"],true);
|
||||
assert!(azure_patch(&json!({"rev":1,"fields":{"System.WorkItemType":"Task"}}),&metadata).is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
use super::*;
|
||||
|
||||
fn creation_payload(provider: &str, source: &str, target: &str, title: &str, description: &str) -> Result<serde_json::Value, String> {
|
||||
if title.trim().is_empty() || source.trim().is_empty() || target.trim().is_empty() {
|
||||
return Err("Title, source branch and target branch are required.".into());
|
||||
}
|
||||
if source == target { return Err("Source and target branch must be different.".into()); }
|
||||
Ok(match provider {
|
||||
"github" | "gitea" => serde_json::json!({"head":source,"base":target,"title":title,"body":description}),
|
||||
"gitlab" | "gitlab-self-hosted" => serde_json::json!({"source_branch":source,"target_branch":target,"title":title,"description":description}),
|
||||
"azure-devops" => serde_json::json!({"sourceRefName":format!("refs/heads/{source}"),"targetRefName":format!("refs/heads/{target}"),"title":title,"description":description}),
|
||||
_ => return Err("Unsupported integration provider.".into()),
|
||||
})
|
||||
}
|
||||
|
||||
fn creation_endpoint(provider: &str, base: &str, repository: &IntegrationRepository) -> Result<reqwest::Url, String> {
|
||||
let base = if provider == "github" { github_api_base_url(base)? } else { normalized_base_url(base)? };
|
||||
let mut url = reqwest::Url::parse(&format!("{base}/")).map_err(|e| e.to_string())?;
|
||||
{
|
||||
let mut path = url.path_segments_mut().map_err(|_| "Invalid integration URL.")?;
|
||||
path.pop_if_empty();
|
||||
match provider {
|
||||
"github" | "gitea" => {
|
||||
let parts: Vec<_> = repository.full_name.split('/').collect();
|
||||
if parts.len() != 2 || parts.iter().any(|part| part.is_empty() || *part == "." || *part == "..") {
|
||||
return Err("Invalid repository name.".into());
|
||||
}
|
||||
if provider == "gitea" { path.extend(["api", "v1"]); }
|
||||
path.push("repos").extend(parts).push("pulls");
|
||||
}
|
||||
"gitlab" | "gitlab-self-hosted" => { path.extend(["api","v4","projects", &repository.id,"merge_requests"]); }
|
||||
"azure-devops" => { path.extend(["_apis","git","repositories", &repository.id,"pullrequests"]); }
|
||||
_ => return Err("Unsupported integration provider.".into()),
|
||||
}
|
||||
}
|
||||
if provider == "azure-devops" { url.query_pairs_mut().append_pair("api-version", "7.1"); }
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_integration_review_request(provider: String, base_url: String, username: String, token: String, repository: IntegrationRepository, source_branch: String, target_branch: String, title: String, description: String) -> Result<IntegrationReviewRequest, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
if token.trim().is_empty() { return Err("No token is stored for this integration.".into()); }
|
||||
let source = source_branch.trim().strip_prefix("refs/heads/").unwrap_or(source_branch.trim());
|
||||
let target = target_branch.trim().strip_prefix("refs/heads/").unwrap_or(target_branch.trim());
|
||||
let payload = creation_payload(&provider, source, target, title.trim(), &description)?;
|
||||
let endpoint = creation_endpoint(&provider, &base_url, &repository)?;
|
||||
let client = client()?;
|
||||
let request = client.post(endpoint).header(USER_AGENT, "Gitty").header(ACCEPT, "application/json");
|
||||
let request = match provider.as_str() {
|
||||
"github" => request.bearer_auth(&token),
|
||||
"gitea" => request.header("Authorization", format!("token {token}")),
|
||||
"gitlab" | "gitlab-self-hosted" => request.header("PRIVATE-TOKEN", &token),
|
||||
"azure-devops" => request.basic_auth(if username.is_empty() { "gitty" } else { &username }, Some(&token)),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
// Never automatically retry creation: a lost response can still mean the PR was created.
|
||||
let response = request.json(&payload).send().map_err(|_| "The creation response could not be received. Check the original repository before trying again.".to_string())?;
|
||||
if !response.status().is_success() { return Err(response_error(response, &provider)); }
|
||||
let mut value: serde_json::Value = response.json().map_err(|_| "The request was created, but its response could not be read. Refresh the Review Center before trying again.".to_string())?;
|
||||
let mut review = match provider.as_str() {
|
||||
"github" => {
|
||||
value["pull_request"] = serde_json::json!({});
|
||||
value["repository_url"] = serde_json::json!(format!("{}/repos/{}", github_api_base_url(&base_url)?, repository.full_name));
|
||||
parse_github_review(&value).ok_or("Could not read the created PR.")?
|
||||
}
|
||||
"gitea" => {
|
||||
value["pull_request"] = value.clone();
|
||||
value["repository"] = serde_json::json!({"id":repository.id.parse::<u64>().unwrap_or_default(),"full_name":repository.full_name});
|
||||
parse_gitea_review(&value).ok_or("Could not read the created PR.")?
|
||||
}
|
||||
"gitlab" | "gitlab-self-hosted" => parse_gitlab_review(&value, &provider),
|
||||
"azure-devops" => parse_azure_review(&value, &repository),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
review.repository_name = repository.full_name;
|
||||
review.source_branch = source.to_string();
|
||||
review.target_branch = target.to_string();
|
||||
Ok(review)
|
||||
}).await.map_err(|e| format!("Could not create review request: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn creation_payloads_and_validation() {
|
||||
for provider in ["github", "gitea", "gitlab", "gitlab-self-hosted", "azure-devops"] {
|
||||
assert!(creation_payload(provider, "main", "main", "Test", "").is_err());
|
||||
assert!(creation_payload(provider, "topic", "main", " ", "").is_err());
|
||||
}
|
||||
assert_eq!(creation_payload("github", "feature/test", "main", "Test", "Body").unwrap()["head"], "feature/test");
|
||||
assert_eq!(creation_payload("gitea", "topic", "main", "Test", "Body").unwrap()["body"], "Body");
|
||||
assert_eq!(creation_payload("gitlab-self-hosted", "topic", "main", "Test", "").unwrap()["source_branch"], "topic");
|
||||
assert_eq!(creation_payload("azure-devops", "topic", "main", "Test", "").unwrap()["targetRefName"], "refs/heads/main");
|
||||
}
|
||||
#[test]
|
||||
fn creation_urls_preserve_prefixes_and_encode_paths() {
|
||||
let repository: IntegrationRepository = serde_json::from_value(serde_json::json!({"id":"17","name":"repo","fullName":"owner/repo","description":"","cloneUrl":"","sshUrl":"","webUrl":"","updatedAt":"","private":false})).unwrap();
|
||||
assert_eq!(creation_endpoint("github", "https://github.com", &repository).unwrap().as_str(), "https://api.github.com/repos/owner/repo/pulls");
|
||||
assert_eq!(creation_endpoint("gitea", "https://git.example/sub", &repository).unwrap().path(), "/sub/api/v1/repos/owner/repo/pulls");
|
||||
assert_eq!(creation_endpoint("gitlab", "https://git.example", &repository).unwrap().path(), "/api/v4/projects/17/merge_requests");
|
||||
assert_eq!(creation_endpoint("azure-devops", "https://dev.azure.com/org", &repository).unwrap().as_str(), "https://dev.azure.com/org/_apis/git/repositories/17/pullrequests?api-version=7.1");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RepositoryBranches {
|
||||
branches: Vec<String>,
|
||||
default_branch: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_integration_repository_branches(provider: String, base_url: String, username: String, token: String, repository: IntegrationRepository) -> Result<RepositoryBranches, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
if token.trim().is_empty() { return Err("No token is stored for this integration.".into()); }
|
||||
let client = client()?;
|
||||
let mut metadata_url = creation_endpoint(&provider, &base_url, &repository)?;
|
||||
metadata_url.path_segments_mut().map_err(|_| "Invalid repository URL")?.pop();
|
||||
let get = |url: reqwest::Url| -> Result<Response, String> {
|
||||
let request = client.get(url).header(USER_AGENT, "Gitty").header(ACCEPT, "application/json");
|
||||
let request = match provider.as_str() {
|
||||
"github" => request.bearer_auth(&token),
|
||||
"gitea" => request.header("Authorization", format!("token {token}")),
|
||||
"gitlab" | "gitlab-self-hosted" => request.header("PRIVATE-TOKEN", &token),
|
||||
"azure-devops" => request.basic_auth(&username, Some(&token)),
|
||||
_ => return Err("Unsupported integration provider.".into()),
|
||||
};
|
||||
let response = request.send().map_err(|e| format!("Could not load branches: {e}"))?;
|
||||
if !response.status().is_success() { return Err(response_error(response, &provider)); }
|
||||
Ok(response)
|
||||
};
|
||||
let metadata: serde_json::Value = get(metadata_url.clone())?.json().map_err(|e| format!("Invalid repository response: {e}"))?;
|
||||
let default_branch = value_string(&metadata, &[if provider == "azure-devops" { "defaultBranch" } else { "default_branch" }]).trim_start_matches("refs/heads/").to_string();
|
||||
let mut branches = BTreeSet::new();
|
||||
let mut continuation = String::new();
|
||||
for page in 1..=1000 {
|
||||
let mut url = metadata_url.clone();
|
||||
{
|
||||
let mut path = url.path_segments_mut().map_err(|_| "Invalid repository URL")?;
|
||||
if provider.starts_with("gitlab") { path.push("repository"); }
|
||||
path.push(if provider == "azure-devops" { "refs" } else { "branches" });
|
||||
}
|
||||
if provider == "azure-devops" {
|
||||
url.query_pairs_mut().append_pair("filter", "heads/").append_pair("$top", "100");
|
||||
if !continuation.is_empty() { url.query_pairs_mut().append_pair("continuationToken", &continuation); }
|
||||
} else {
|
||||
url.query_pairs_mut().append_pair("page", &page.to_string()).append_pair(if provider == "gitea" { "limit" } else { "per_page" }, "100");
|
||||
}
|
||||
let response = get(url)?;
|
||||
let next = response.headers().get("x-ms-continuationtoken").and_then(|h| h.to_str().ok()).unwrap_or_default().to_string();
|
||||
let data: serde_json::Value = response.json().map_err(|e| format!("Invalid branch response: {e}"))?;
|
||||
let items = if provider == "azure-devops" { data.get("value") } else { Some(&data) }.and_then(serde_json::Value::as_array).ok_or("Invalid branch list.")?;
|
||||
for item in items {
|
||||
let name = value_string(item, &["name"]);
|
||||
let name = if provider == "azure-devops" { name.strip_prefix("refs/heads/").unwrap_or(&name) } else { &name };
|
||||
if !name.is_empty() { branches.insert(name.to_string()); }
|
||||
}
|
||||
if (provider == "azure-devops" && next.is_empty()) || (provider != "azure-devops" && items.len() < 100) {
|
||||
return Ok(RepositoryBranches { branches: branches.into_iter().collect(), default_branch });
|
||||
}
|
||||
if provider == "azure-devops" && next == continuation { return Err("The server repeated its branch pagination token.".into()); }
|
||||
continuation = next;
|
||||
}
|
||||
Err("The repository has too many branches to load completely.".into())
|
||||
}).await.map_err(|e| format!("Could not load branches: {e}"))?
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use super::*;
|
||||
use super::issue_comments::{comment_url, request, comment_client};
|
||||
use serde_json::{Value,json};
|
||||
fn read(response: reqwest::blocking::Response, provider: &str) -> Result<Value,String> {
|
||||
if !response.status().is_success() {return Err(response_error(response,provider));}
|
||||
response.json().map_err(|e|e.to_string())
|
||||
}
|
||||
fn completed_state(value: &Value) -> Result<String,String> {
|
||||
let states:Vec<_>=value["value"].as_array().into_iter().flatten().filter(|v|v["category"]=="Completed").filter_map(|v|v["name"].as_str()).filter(|s|!s.is_empty()).collect();
|
||||
if states.len()!=1 {return Err("No unique completed state is configured for this work item type. Open the issue in Azure to select its state.".into());}
|
||||
Ok(states[0].into())
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn close_integration_issue(provider:String,base_url:String,username:String,token:String,repository:String,number:u64) -> Result<String,String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let client=comment_client()?;
|
||||
let mut url=comment_url(&provider,&base_url,&repository,number)?;
|
||||
url.path_segments_mut().map_err(|_|"Invalid issue URL.")?.pop();
|
||||
if provider=="azure-devops" {
|
||||
url.set_query(Some("api-version=7.1"));
|
||||
let item=read(request(&client,reqwest::Method::GET,url.clone(),&provider,&username,&token)?.send().map_err(|e|e.to_string())?,&provider)?;
|
||||
let kind=value_string(&item,&["fields","System.WorkItemType"]);
|
||||
let mut states_url=reqwest::Url::parse(&normalized_base_url(&base_url)?).map_err(|e|e.to_string())?;
|
||||
states_url.path_segments_mut().map_err(|_|"Invalid Azure URL.")?.pop_if_empty().extend([&repository,"_apis","wit","workitemtypes",&kind,"states"]);
|
||||
states_url.set_query(Some("api-version=7.1"));
|
||||
let states=read(request(&client,reqwest::Method::GET,states_url,&provider,&username,&token)?.send().map_err(|e|e.to_string())?,&provider)?;
|
||||
let state=completed_state(&states)?;
|
||||
if item["fields"]["System.State"]==state {return Ok(state);}
|
||||
let rev=item["rev"].as_u64().ok_or("Azure returned no revision.")?;
|
||||
let payload=json!([{"op":"test","path":"/rev","value":rev},{"op":"add","path":"/fields/System.State","value":state}]);
|
||||
let result=read(request(&client,reqwest::Method::PATCH,url,&provider,&username,&token)?.header("Content-Type","application/json-patch+json").json(&payload).send().map_err(|e|e.to_string())?,&provider)?;
|
||||
let actual=value_string(&result,&["fields","System.State"]);
|
||||
if actual!=state {return Err("Azure did not confirm the completed state.".into());}
|
||||
Ok(actual)
|
||||
} else {
|
||||
let gitlab=provider.starts_with("gitlab");
|
||||
let method=if gitlab {reqwest::Method::PUT}else{reqwest::Method::PATCH};
|
||||
let payload=if gitlab {json!({"state_event":"close"})}else{json!({"state":"closed"})};
|
||||
let result=read(request(&client,method,url,&provider,&username,&token)?.json(&payload).send().map_err(|e|format!("Closing was not confirmed: {e}"))?,&provider)?;
|
||||
let state=value_string(&result,&["state"]);
|
||||
if state!="closed" {return Err("The provider did not confirm the closed state.".into());}
|
||||
Ok(state)
|
||||
}
|
||||
}).await.map_err(|e|e.to_string())?
|
||||
}
|
||||
#[cfg(test)] mod tests {
|
||||
use super::*;
|
||||
#[test] fn uses_custom_completed_state_and_rejects_ambiguous_configuration() {
|
||||
assert_eq!(completed_state(&json!({"value":[{"name":"Review","category":"Resolved"},{"name":"Delivered","category":"Completed"}]})).unwrap(),"Delivered");
|
||||
assert!(completed_state(&json!({"value":[]})).is_err());
|
||||
assert!(completed_state(&json!({"value":[{"name":"A","category":"Completed"},{"name":"B","category":"Completed"}]})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IssueComment { id: String, author: String, body: String, created_at: String, body_html: bool }
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IssueCommentPage { comments: Vec<IssueComment>, next_cursor: Option<String> }
|
||||
|
||||
pub(super) fn comment_url(provider: &str, base: &str, repository: &str, number: u64) -> Result<reqwest::Url, String> {
|
||||
if repository.trim().is_empty() || number == 0 { return Err("Missing issue repository or number.".into()); }
|
||||
let base = normalized_base_url(base)?;
|
||||
let mut url = reqwest::Url::parse(if provider == "github" { "https://api.github.com" } else { &base }).map_err(|e|e.to_string())?;
|
||||
let num = number.to_string();
|
||||
let mut path = url.path_segments_mut().map_err(|_|"Invalid API URL.")?;
|
||||
path.pop_if_empty();
|
||||
match provider {
|
||||
"github" | "gitea" => {
|
||||
let (owner, repo) = repository.split_once('/').filter(|(a,b)|!a.is_empty() && !b.is_empty() && !b.contains('/')).ok_or("Invalid issue repository.")?;
|
||||
if provider == "gitea" { path.extend(["api","v1"]); }
|
||||
path.extend(["repos",owner,repo,"issues",&num,"comments"]);
|
||||
},
|
||||
"gitlab" | "gitlab-self-hosted" => { path.extend(["api","v4","projects",repository,"issues",&num,"notes"]); },
|
||||
"azure-devops" => { path.extend([repository,"_apis","wit","workItems",&num,"comments"]); },
|
||||
_ => return Err("Unsupported issue provider.".into()),
|
||||
}
|
||||
drop(path);
|
||||
if provider == "azure-devops" { url.query_pairs_mut().append_pair("api-version","7.1-preview.4"); }
|
||||
Ok(url)
|
||||
}
|
||||
pub(super) fn request(client: &Client, method: reqwest::Method, url: reqwest::Url, provider: &str, username: &str, token: &str) -> Result<reqwest::blocking::RequestBuilder, String> {
|
||||
if token.trim().is_empty() { return Err("No token stored for this integration.".into()); }
|
||||
let req = client.request(method,url).header(USER_AGENT,"Gitty").header(ACCEPT,"application/json");
|
||||
Ok(match provider {
|
||||
"github" => req.bearer_auth(token),
|
||||
"gitea" => req.header("Authorization",format!("token {token}")),
|
||||
"gitlab" | "gitlab-self-hosted" => req.header("PRIVATE-TOKEN",token),
|
||||
_ => req.basic_auth(if username.is_empty(){"gitty"}else{username},Some(token)),
|
||||
})
|
||||
}
|
||||
fn parse_comment(v: &Value, provider: &str) -> Option<IssueComment> {
|
||||
if v["system"] == true || v["isDeleted"] == true { return None; }
|
||||
let azure = provider == "azure-devops";
|
||||
let id = if azure && v["id"].is_null() { json_id(&json!({"id":v["commentId"]})) } else { json_id(v) };
|
||||
Some(IssueComment { id, author: value_string(v, if azure { &["createdBy","displayName"] } else if provider.starts_with("gitlab") { &["author","username"] } else { &["user","login"] }), body:value_string(v,&[if azure{"text"}else{"body"}]), created_at:value_string(v,&[if azure{"createdDate"}else{"created_at"}]), body_html: azure && (v["format"] == "html" || v["format"] == 0) })
|
||||
}
|
||||
pub(super) fn comment_client() -> Result<Client,String> {
|
||||
Client::builder().timeout(Duration::from_secs(30)).connect_timeout(Duration::from_secs(7)).redirect(reqwest::redirect::Policy::none()).build().map_err(|e|e.to_string())
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn list_integration_issue_comments(provider:String, base_url:String, username:String, token:String, repository:String, number:u64, cursor:Option<String>) -> Result<IssueCommentPage,String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let client=comment_client()?;
|
||||
let mut url=comment_url(&provider,&base_url,&repository,number)?;
|
||||
let page=if provider!="azure-devops" { cursor.as_deref().unwrap_or("1").parse::<u32>().map_err(|_|"Invalid comment page.")?.max(1) } else {1};
|
||||
if provider=="azure-devops" {
|
||||
url.query_pairs_mut().append_pair("$top","100").append_pair("order","asc").append_pair("includeDeleted","false");
|
||||
if let Some(cursor)=cursor {url.query_pairs_mut().append_pair("continuationToken",&cursor);}
|
||||
} else {url.query_pairs_mut().append_pair("page",&page.to_string()).append_pair("per_page","100").append_pair("limit","100").append_pair("sort","asc").append_pair("order_by","created_at");}
|
||||
let response=request(&client,reqwest::Method::GET,url,&provider,&username,&token)?.send().map_err(|e|e.to_string())?;
|
||||
if !response.status().is_success(){return Err(response_error(response,&provider));}
|
||||
let linked_next=response.headers().get("link").and_then(|h|h.to_str().ok()).map(|s|s.contains("rel=\"next\""));
|
||||
let value:Value=response.json().map_err(|e|e.to_string())?;
|
||||
let items=if provider=="azure-devops" {value["comments"].as_array()}else{value.as_array()}.ok_or("Invalid comment list.")?;
|
||||
let next_cursor=if provider=="azure-devops" {value["continuationToken"].as_str().filter(|s|!s.is_empty()).map(str::to_string)}else if linked_next.unwrap_or(items.len()==100) {Some(page.saturating_add(1).to_string())}else{None};
|
||||
Ok(IssueCommentPage{comments:items.iter().filter_map(|v|parse_comment(v,&provider)).collect(),next_cursor})
|
||||
}).await.map_err(|e|e.to_string())?
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn add_integration_issue_comment(provider:String, base_url:String, username:String, token:String, repository:String, number:u64, body:String) -> Result<IssueComment,String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
if body.trim().is_empty() || body.len()>100_000 {return Err("Comment must contain between 1 and 100,000 bytes.".into());}
|
||||
let mut url=comment_url(&provider,&base_url,&repository,number)?;
|
||||
if provider=="azure-devops" {url.query_pairs_mut().append_pair("format","markdown");}
|
||||
let payload=if provider=="azure-devops"{json!({"text":body})}else{json!({"body":body})};
|
||||
let response=request(&comment_client()?,reqwest::Method::POST,url,&provider,&username,&token)?.json(&payload).send().map_err(|e|format!("Comment delivery not confirmed: {e}"))?;
|
||||
if !response.status().is_success(){return Err(response_error(response,&provider));}
|
||||
parse_comment(&response.json::<Value>().map_err(|e|e.to_string())?,&provider).ok_or("Invalid posted comment.".into())
|
||||
}).await.map_err(|e|e.to_string())?
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test] fn issue_endpoints_use_issue_notes_and_encode_project_paths() {
|
||||
assert_eq!(comment_url("gitlab","https://git.test","team/sub/repo",4).unwrap().as_str(),"https://git.test/api/v4/projects/team%2Fsub%2Frepo/issues/4/notes");
|
||||
assert_eq!(comment_url("azure-devops","https://dev.azure.com/org","My Project",4).unwrap().path(),"/org/My%20Project/_apis/wit/workItems/4/comments");
|
||||
assert!(comment_url("gitea","https://git.test","owner/repo/invalid",4).is_err());
|
||||
}
|
||||
#[test] fn comments_filter_system_entries_and_preserve_html_format() {
|
||||
assert!(parse_comment(&json!({"system":true}),"gitlab").is_none());
|
||||
let c=parse_comment(&json!({"commentId":7,"text":"<p>Hello</p>","format":"html","createdBy":{"displayName":"Alex"}}),"azure-devops").unwrap();
|
||||
assert_eq!(c.id,"7"); assert!(c.body_html); assert_eq!(c.author,"Alex");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IntegrationIssue {
|
||||
id: String,
|
||||
number: u64,
|
||||
title: String,
|
||||
description: String,
|
||||
description_html: bool,
|
||||
repository_name: String,
|
||||
author: String,
|
||||
state: String,
|
||||
labels: Vec<String>,
|
||||
assignees: Vec<String>,
|
||||
web_url: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IssuePage {
|
||||
issues: Vec<IntegrationIssue>,
|
||||
next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_issue(value: &serde_json::Value, provider: &str) -> Option<IntegrationIssue> {
|
||||
if value.get("pull_request").is_some_and(|v| !v.is_null()) {
|
||||
return None;
|
||||
}
|
||||
let gitlab = provider.starts_with("gitlab");
|
||||
let number = value_u64(value, if gitlab { "iid" } else { "number" });
|
||||
if number == 0 {
|
||||
return None;
|
||||
}
|
||||
let reference = value_string(value, &["references", "full"]);
|
||||
let repository_name = if gitlab {
|
||||
reference
|
||||
.rsplit_once('#')
|
||||
.map(|(repo, _)| repo.to_string())
|
||||
.unwrap_or_else(|| value_u64(value, "project_id").to_string())
|
||||
} else {
|
||||
let name = value_string(value, &["repository", "full_name"]);
|
||||
if name.is_empty() {
|
||||
github_repository_name(&value_string(value, &["repository_url"]))
|
||||
} else {
|
||||
name
|
||||
}
|
||||
};
|
||||
Some(IntegrationIssue {
|
||||
id: format!("{provider}:{}", value_u64(value, "id")),
|
||||
number,
|
||||
title: value_string(value, &["title"]),
|
||||
description: value_string(value, &[if gitlab { "description" } else { "body" }]),
|
||||
description_html: false,
|
||||
repository_name,
|
||||
author: value_string(
|
||||
value,
|
||||
if gitlab {
|
||||
&["author", "username"]
|
||||
} else {
|
||||
&["user", "login"]
|
||||
},
|
||||
),
|
||||
state: match value_string(value, &["state"]).as_str() {
|
||||
"opened" => "open".into(),
|
||||
state => state.to_string(),
|
||||
},
|
||||
labels: value
|
||||
.get("labels")
|
||||
.and_then(|v| v.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| {
|
||||
v.as_str()
|
||||
.or_else(|| v.get("name").and_then(|n| n.as_str()))
|
||||
.map(str::to_string)
|
||||
})
|
||||
.collect(),
|
||||
assignees: review_names(
|
||||
value,
|
||||
"assignees",
|
||||
if gitlab { "username" } else { "login" },
|
||||
),
|
||||
web_url: value_string(value, &[if gitlab { "web_url" } else { "html_url" }]),
|
||||
updated_at: value_string(value, &["updated_at"]),
|
||||
})
|
||||
}
|
||||
|
||||
fn json_response(
|
||||
request: reqwest::blocking::RequestBuilder,
|
||||
provider: &str,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let response = request
|
||||
.send()
|
||||
.map_err(|err| format!("Could not reach {provider}: {err}"))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(response_error(response, provider));
|
||||
}
|
||||
response
|
||||
.json()
|
||||
.map_err(|err| format!("Invalid {provider} issue response: {err}"))
|
||||
}
|
||||
|
||||
fn azure_issue(value: &serde_json::Value, base: &str) -> IntegrationIssue {
|
||||
let field = |name: &str| value_string(value, &["fields", name]);
|
||||
let number = value_u64(value, "id");
|
||||
let assigned = value_string(value, &["fields", "System.AssignedTo", "displayName"]);
|
||||
IntegrationIssue {
|
||||
id: format!("azure-devops:{number}"),
|
||||
number,
|
||||
title: field("System.Title"),
|
||||
description: field("System.Description"),
|
||||
description_html: true,
|
||||
repository_name: field("System.TeamProject"),
|
||||
author: value_string(value, &["fields", "System.CreatedBy", "displayName"]),
|
||||
state: field("System.State"),
|
||||
labels: field("System.Tags")
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
assignees: if assigned.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
vec![assigned]
|
||||
},
|
||||
web_url: format!("{base}/_workitems/edit/{number}"),
|
||||
updated_at: field("System.ChangedDate"),
|
||||
}
|
||||
}
|
||||
|
||||
fn azure_issues(
|
||||
client: &Client,
|
||||
base: &str,
|
||||
username: &str,
|
||||
token: &str,
|
||||
cursor: Option<u64>,
|
||||
) -> Result<IssuePage, String> {
|
||||
let condition = cursor
|
||||
.map(|id| format!("[System.Id] < {id}"))
|
||||
.unwrap_or_else(|| "[System.Id] > 0".into());
|
||||
let payload = json_response(client.post(format!("{base}/_apis/wit/wiql"))
|
||||
.basic_auth(username, Some(token)).query(&[("api-version", "7.1"), ("$top", "101")])
|
||||
.json(&serde_json::json!({"query": format!("SELECT [System.Id] FROM WorkItems WHERE {condition} ORDER BY [System.Id] DESC")})), "Azure DevOps")?;
|
||||
let ids: Vec<u64> = payload
|
||||
.get("workItems")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or("Azure DevOps returned no work item list.")?
|
||||
.iter()
|
||||
.filter_map(|v| v.get("id").and_then(|id| id.as_u64()))
|
||||
.collect();
|
||||
let next_cursor = if ids.len() > 100 {
|
||||
Some(ids[99].to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Ok(IssuePage {
|
||||
issues: vec![],
|
||||
next_cursor,
|
||||
});
|
||||
}
|
||||
let ids = ids
|
||||
.iter()
|
||||
.take(100)
|
||||
.map(u64::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let payload = json_response(
|
||||
client
|
||||
.get(format!("{base}/_apis/wit/workitems"))
|
||||
.basic_auth(username, Some(token))
|
||||
.query(&[("api-version", "7.1"), ("ids", ids.as_str())]),
|
||||
"Azure DevOps",
|
||||
)?;
|
||||
let mut issues: Vec<_> = payload
|
||||
.get("value")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or("Azure DevOps returned no work item details.")?
|
||||
.iter()
|
||||
.map(|v| azure_issue(v, base))
|
||||
.collect();
|
||||
issues.sort_by_key(|issue| std::cmp::Reverse(issue.number));
|
||||
Ok(IssuePage {
|
||||
issues,
|
||||
next_cursor,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_integration_issues(
|
||||
provider: String,
|
||||
base_url: String,
|
||||
username: String,
|
||||
token: String,
|
||||
cursor: Option<String>,
|
||||
) -> Result<IssuePage, 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.".into());
|
||||
}
|
||||
let cursor = cursor
|
||||
.map(|c| {
|
||||
c.parse::<u64>()
|
||||
.map_err(|_| "Invalid issue cursor.".to_string())
|
||||
})
|
||||
.transpose()?;
|
||||
let base = normalized_base_url(&base_url)?;
|
||||
let client = client()?;
|
||||
if provider == "azure-devops" {
|
||||
return azure_issues(
|
||||
&client,
|
||||
&base,
|
||||
if username.is_empty() {
|
||||
"gitty"
|
||||
} else {
|
||||
&username
|
||||
},
|
||||
&token,
|
||||
cursor,
|
||||
);
|
||||
}
|
||||
let page = cursor.unwrap_or(1).max(1).to_string();
|
||||
let request = match provider.as_str() {
|
||||
"github" => client
|
||||
.get(format!("{}/issues", github_api_base_url(&base)?))
|
||||
.bearer_auth(&token)
|
||||
.query(&[
|
||||
("filter", "all"),
|
||||
("state", "all"),
|
||||
("sort", "created"),
|
||||
("direction", "desc"),
|
||||
("per_page", "100"),
|
||||
("page", page.as_str()),
|
||||
]),
|
||||
"gitlab" | "gitlab-self-hosted" => client
|
||||
.get(format!("{base}/api/v4/issues"))
|
||||
.header("PRIVATE-TOKEN", &token)
|
||||
.query(&[
|
||||
("scope", "all"),
|
||||
("state", "all"),
|
||||
("order_by", "created_at"),
|
||||
("sort", "desc"),
|
||||
("per_page", "100"),
|
||||
("page", page.as_str()),
|
||||
]),
|
||||
"gitea" => client
|
||||
.get(format!("{base}/api/v1/repos/issues/search"))
|
||||
.header("Authorization", format!("token {token}"))
|
||||
.query(&[
|
||||
("type", "issues"),
|
||||
("state", "all"),
|
||||
("limit", "100"),
|
||||
("page", page.as_str()),
|
||||
]),
|
||||
_ => return Err("Unsupported integration provider.".into()),
|
||||
}
|
||||
.header(USER_AGENT, "Gitty")
|
||||
.header(ACCEPT, "application/json");
|
||||
let response = request
|
||||
.send()
|
||||
.map_err(|err| format!("Could not reach {provider}: {err}"))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(response_error(response, &provider));
|
||||
}
|
||||
let linked_next = response
|
||||
.headers()
|
||||
.get("link")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|v| v.contains("rel=\"next\""));
|
||||
let values: Vec<serde_json::Value> = response
|
||||
.json()
|
||||
.map_err(|err| format!("Invalid issue list: {err}"))?;
|
||||
let next_cursor = if linked_next.unwrap_or(values.len() == 100) {
|
||||
Some(cursor.unwrap_or(1).max(1).saturating_add(1).to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(IssuePage {
|
||||
issues: values
|
||||
.iter()
|
||||
.filter_map(|v| parse_issue(v, &provider))
|
||||
.collect(),
|
||||
next_cursor,
|
||||
})
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "The issue API did not respond within 35 seconds.".to_string())?
|
||||
.map_err(|err| format!("Could not load issues: {err}"))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn excludes_pull_requests_and_maps_github_issues() {
|
||||
assert!(parse_issue(&json!({"number": 2, "pull_request": {}}), "github").is_none());
|
||||
let issue = parse_issue(&json!({"id": 4, "number": 2, "repository_url": "https://api.github.com/repos/team/app", "labels": [{"name":"bug"}], "state":"open"}), "github").unwrap();
|
||||
assert_eq!(issue.repository_name, "team/app");
|
||||
assert_eq!(issue.labels, ["bug"]);
|
||||
}
|
||||
#[test]
|
||||
fn maps_both_gitlab_variants_and_gitea() {
|
||||
for provider in ["gitlab", "gitlab-self-hosted"] {
|
||||
let issue = parse_issue(&json!({"id": 8,"iid": 3,"references":{"full":"team/app#3"},"state":"opened","labels":["bug"]}), provider).unwrap();
|
||||
assert_eq!(issue.repository_name, "team/app");
|
||||
assert_eq!(issue.state, "open");
|
||||
}
|
||||
let issue = parse_issue(
|
||||
&json!({"id":9,"number":4,"repository":{"full_name":"team/app"},"pull_request":null}),
|
||||
"gitea",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(issue.repository_name, "team/app");
|
||||
}
|
||||
#[test]
|
||||
fn preserves_custom_azure_states_and_project_identity() {
|
||||
let issue = azure_issue(
|
||||
&json!({"id":42,"fields":{"System.State":"Ready for QA","System.TeamProject":"Project","System.Tags":"bug; urgent"}}),
|
||||
"https://dev.azure.com/org",
|
||||
);
|
||||
assert_eq!(issue.state, "Ready for QA");
|
||||
assert_eq!(issue.repository_name, "Project");
|
||||
assert_eq!(issue.labels, ["bug", "urgent"]);
|
||||
assert_eq!(
|
||||
issue.web_url,
|
||||
"https://dev.azure.com/org/_workitems/edit/42"
|
||||
);
|
||||
}
|
||||
}
|
||||
+38
-15
@@ -17,22 +17,27 @@ use git::{
|
||||
commit_ai_review, commit_ai_split, compare_commits, compare_file_to_head,
|
||||
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
|
||||
delete_branch, delete_commit_note, delete_remote_branch, delete_remote_branches, delete_tag,
|
||||
diff_file_against_working_tree, fetch, fetch_commit_notes, get_commit_note, get_file_blame,
|
||||
get_file_patch, get_remote_url, get_status, git_lfs_install, git_lfs_prune, git_lfs_pull,
|
||||
git_lfs_status, git_lfs_track, git_lfs_untrack, init_repository, last_commit_message,
|
||||
list_branches, list_commits, list_file_history, list_interactive_rebase_commits, list_reflog,
|
||||
list_remotes, list_repository_files, list_stashes, list_tags, list_worktrees, lock_worktree,
|
||||
merge_abort, merge_branch, 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, rebase_abort, rebase_branch, rebase_continue,
|
||||
remove_remote, remove_worktree, rename_branch, rename_remote_branch, repair_worktree,
|
||||
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
|
||||
restore_reflog_entry, restore_to_commit, revert_commit, run_sequence_editor_if_requested,
|
||||
search_code_introductions, set_branch_upstream, set_commit_note, stage_files,
|
||||
start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit,
|
||||
unlock_worktree, unstage_files, untrack_paths, update_remote,
|
||||
diff_file_against_working_tree, fetch, fetch_commit_notes, get_bisect_state, get_commit_note,
|
||||
get_file_blame, get_file_patch, get_remote_url, get_status, git_lfs_install, git_lfs_prune,
|
||||
git_lfs_pull, git_lfs_status, git_lfs_track, git_lfs_untrack, init_repository,
|
||||
last_commit_message, list_branches, list_commits, list_file_history,
|
||||
list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
|
||||
list_stashes, list_tags, list_worktrees, lock_worktree, mark_bisect, merge_abort, merge_branch,
|
||||
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,
|
||||
rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, rename_branch,
|
||||
rename_remote_branch, repair_worktree, reset_bisect, resolve_conflict, resolve_conflict_side,
|
||||
restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
|
||||
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
|
||||
set_branch_upstream, set_commit_note, stage_files, start_bisect, start_interactive_rebase,
|
||||
stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree,
|
||||
unstage_files, untrack_paths, update_remote,
|
||||
};
|
||||
use integrations::{
|
||||
create_integration_review_request, list_integration_repository_branches,
|
||||
add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser,
|
||||
run_integration_review_action, list_integration_issues, get_integration_board, list_integration_boards, move_integration_board_card, list_integration_issue_comments, add_integration_issue_comment, close_integration_issue,
|
||||
};
|
||||
use integrations::list_integration_repositories;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use tauri::{Emitter, Manager};
|
||||
@@ -411,6 +416,10 @@ async fn main() {
|
||||
start_interactive_rebase,
|
||||
list_reflog,
|
||||
restore_reflog_entry,
|
||||
get_bisect_state,
|
||||
start_bisect,
|
||||
mark_bisect,
|
||||
reset_bisect,
|
||||
list_repository_files,
|
||||
open_repository_bundle,
|
||||
list_file_history,
|
||||
@@ -429,7 +438,21 @@ async fn main() {
|
||||
cred_load,
|
||||
cred_save,
|
||||
cred_delete,
|
||||
list_integration_repository_branches,
|
||||
create_integration_review_request,
|
||||
list_integration_repositories,
|
||||
list_integration_review_requests,
|
||||
list_integration_issues,
|
||||
get_integration_board,
|
||||
list_integration_boards,
|
||||
move_integration_board_card,
|
||||
list_integration_issue_comments,
|
||||
add_integration_issue_comment,
|
||||
close_integration_issue,
|
||||
get_integration_review_details,
|
||||
add_integration_review_comment,
|
||||
run_integration_review_action,
|
||||
open_in_browser,
|
||||
set_sync_badge,
|
||||
close_splashscreen,
|
||||
set_telemetry_enabled,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Gitty",
|
||||
"version": "2026.8.8",
|
||||
"version": "2026.9.3",
|
||||
"identifier": "com.gitty",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run prepare:lfs && npm run dev",
|
||||
|
||||
+461
-202
@@ -6,10 +6,16 @@
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
||||
import { AlertCircle, Cherry, CloudOff, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte";
|
||||
import { beginFrontendShutdown } from "./lib/telemetry";
|
||||
import { beginFrontendShutdown, resumeFrontend } from "./lib/telemetry";
|
||||
|
||||
import TitleBar from "./lib/TitleBar.svelte";
|
||||
import RepoToolbar from "./lib/RepoToolbar.svelte";
|
||||
import PullRequestStatusLoader from "./lib/components/PullRequestStatusLoader.svelte";
|
||||
import type { PullRequestBadgeState } from "./lib/pullRequestBadges";
|
||||
let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
|
||||
import IssueCenter from "./lib/components/IssueCenter.svelte";
|
||||
import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte";
|
||||
import ReviewCenter from "./lib/components/ReviewCenter.svelte";
|
||||
import RepoTabs from "./lib/RepoTabs.svelte";
|
||||
import AiReviewDialog from "./lib/components/AiReviewDialog.svelte";
|
||||
import AiCommitSplitDialog from "./lib/components/AiCommitSplitDialog.svelte";
|
||||
@@ -26,6 +32,9 @@
|
||||
import DiscardConfirmDialog from "./lib/components/DiscardConfirmDialog.svelte";
|
||||
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
|
||||
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
|
||||
import InitRepositoryDialog from "./lib/components/InitRepositoryDialog.svelte";
|
||||
import MergeProgressNotice from "./lib/components/MergeProgressNotice.svelte";
|
||||
import MergeBranchDialog from "./lib/components/MergeBranchDialog.svelte";
|
||||
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
|
||||
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
|
||||
import ReflogDialog from "./lib/components/ReflogDialog.svelte";
|
||||
@@ -65,6 +74,7 @@
|
||||
compareFileToParent,
|
||||
fetchCommitNotes,
|
||||
fetchRemote,
|
||||
getBisectState,
|
||||
getCommitNote,
|
||||
getFileBlame,
|
||||
getGitLfsStatus,
|
||||
@@ -82,6 +92,7 @@
|
||||
listReflog,
|
||||
listRepositoryFiles,
|
||||
mergeBranch,
|
||||
markBisect,
|
||||
mergeAbort,
|
||||
mergeContinue,
|
||||
openRepoInExplorer,
|
||||
@@ -99,6 +110,7 @@
|
||||
removeRemote,
|
||||
removeWorktree,
|
||||
repairWorktree,
|
||||
resetBisect,
|
||||
revertCommit,
|
||||
setBranchUpstream,
|
||||
setCommitNote,
|
||||
@@ -125,6 +137,7 @@
|
||||
restoreFiles,
|
||||
restoreToCommit,
|
||||
searchCodeIntroductions,
|
||||
startBisect,
|
||||
startInteractiveRebase,
|
||||
setSyncBadge,
|
||||
stageFiles,
|
||||
@@ -148,6 +161,8 @@
|
||||
AppLanguage,
|
||||
AppTheme,
|
||||
AnalyticsSettings,
|
||||
BisectState,
|
||||
CloneOptions,
|
||||
CustomThemeColors,
|
||||
ConflictFile,
|
||||
DetectedExternalTool,
|
||||
@@ -158,6 +173,7 @@
|
||||
GitIntegrationSecretUpdate,
|
||||
GitIntegrationSettings,
|
||||
GitIntegrationProvider,
|
||||
IntegrationReviewRequest,
|
||||
GitBlameLine,
|
||||
GitBranch as GitBranchInfo,
|
||||
GitCommit,
|
||||
@@ -167,6 +183,7 @@
|
||||
GitFileStatus,
|
||||
GitIgnoreKind,
|
||||
GitLfsStatus,
|
||||
MergeStrategy,
|
||||
GitRepositoryFile,
|
||||
GitRemote,
|
||||
PullStrategy,
|
||||
@@ -190,6 +207,7 @@
|
||||
resolveDetectedExternalToolPrograms,
|
||||
} from "./lib/externalTools";
|
||||
import {
|
||||
configuredIntegrationSources,
|
||||
defaultGitIntegrationSettings,
|
||||
integrationCredentialKey,
|
||||
normaliseGitIntegrationSettings,
|
||||
@@ -205,7 +223,8 @@
|
||||
import { setTelemetryEnabled, tracedInvoke } from "./lib/telemetry";
|
||||
|
||||
type UpdateToastState = "available" | "downloading" | "installed" | "error";
|
||||
type AppView = "management" | "repository";
|
||||
type AppView = "management" | "review-center" | "issues" | "repository";
|
||||
type ReviewConflictPhase = "idle" | "preparing" | "conflicts" | "ready-to-continue" | "ready-to-push" | "complete" | "error";
|
||||
type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
|
||||
type CredentialMode = "credentials" | "token";
|
||||
type PendingDiscard =
|
||||
@@ -233,6 +252,7 @@
|
||||
remoteUrl: string;
|
||||
parentPath: string;
|
||||
directoryName: string;
|
||||
cloneOptions?: CloneOptions;
|
||||
}
|
||||
|
||||
interface ErrorAutoHideState {
|
||||
@@ -302,6 +322,14 @@
|
||||
let unlistenStartupRepository: (() => void) | undefined;
|
||||
let activeRepoPath = "";
|
||||
let activeView: AppView = "management";
|
||||
let reviewCenterInitialQuery = "";
|
||||
let reviewCenterInitialSourceId = "";
|
||||
let reviewConflictRequestId = "";
|
||||
let reviewConflictPhase: ReviewConflictPhase = "idle";
|
||||
let reviewConflictMessage = "";
|
||||
let reviewConflictRepoPath = "";
|
||||
let reviewConflictRemote = "";
|
||||
let reviewConflictPushRequested = false;
|
||||
let repoTabs: RepoTab[] = [];
|
||||
let repoTabContextMenu: RepoTabContextMenu | null = null;
|
||||
let recentRepoPaths: string[] = [];
|
||||
@@ -309,8 +337,14 @@
|
||||
// Last-seen branch/ahead/behind/changed for repos that are known (recent/favorites)
|
||||
// but not currently open as a tab — keyed by normalized path (repoKey).
|
||||
let repoStatusCache: Record<string, RepoTab> = {};
|
||||
let repoSearch = "";
|
||||
$: dashboardRepos = uniqueRepoPaths([...repoTabs.map(tab => tab.path), ...recentRepoPaths, ...favoriteRepoPaths])
|
||||
.map(path => ({ ...repoRowFromPath(path, repoTabs, repoStatusCache),
|
||||
isOpen: repoTabs.some(tab => sameRepoPath(tab.path, path)),
|
||||
known: Boolean(repoStatusCache[repoKey(path)]),
|
||||
recent: recentRepoPaths.some(recent => sameRepoPath(recent, path)),
|
||||
favorite: favoriteRepoPaths.some(favorite => sameRepoPath(favorite, path)) }));
|
||||
let cloneDialogOpen = false;
|
||||
let initRepositoryDialogOpen = false;
|
||||
let pullStrategy: PullStrategy = (localStorage.getItem("gitlite.pullStrategy") as PullStrategy) || "merge";
|
||||
let selectedRemote = localStorage.getItem("gitlite.selectedRemote") || "";
|
||||
let remoteActionForceWithLease = false;
|
||||
@@ -387,6 +421,7 @@
|
||||
let comparisonFromLabel = "";
|
||||
let comparisonToLabel = "";
|
||||
let newBranchCommit: GitCommit | null = null;
|
||||
let mergeBranchTarget: GitBranchInfo | null = null;
|
||||
let renameBranchTarget: GitBranchInfo | null = null;
|
||||
let deleteBranchTarget: GitBranchInfo | null = null;
|
||||
let deleteBranchForce = false;
|
||||
@@ -410,6 +445,10 @@
|
||||
let reflogEntries: ReflogEntry[] = [];
|
||||
let reflogLoading = false;
|
||||
let reflogError = "";
|
||||
let bisectOpen = false;
|
||||
let bisectState: BisectState | null = null;
|
||||
let bisectLoading = false;
|
||||
let bisectError = "";
|
||||
let selectedDiffPath = "";
|
||||
let diffHighlightQuery = "";
|
||||
let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null;
|
||||
@@ -446,6 +485,7 @@
|
||||
let credDialogUsername = "";
|
||||
let credDialogMode: CredentialMode = "credentials";
|
||||
const rejectedCredentialKeys = new Set<string>();
|
||||
const credentialCache = new Map<string, StoredCredential | null>();
|
||||
let lastStatusFingerprint = "";
|
||||
const AUTO_REFRESH_INTERVAL = 4000;
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
|
||||
@@ -545,15 +585,6 @@
|
||||
) as Record<string, string>;
|
||||
$: remoteBranches = branches.filter((b) => b.remote);
|
||||
$: currentBranchIsLocalOnly = Boolean(status?.current_branch) && !status?.upstream;
|
||||
$: repoSearchTerm = repoSearch.trim().toLowerCase();
|
||||
$: openRepoRows = repoTabs.filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
|
||||
$: recentRepoRows = recentRepoPaths
|
||||
.filter((path) => !repoTabs.some((tab) => sameRepoPath(tab.path, path)))
|
||||
.map((path) => repoRowFromPath(path, repoTabs, repoStatusCache))
|
||||
.filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
|
||||
$: favoriteRepoRows = favoriteRepoPaths
|
||||
.map((path) => repoRowFromPath(path, repoTabs, repoStatusCache))
|
||||
.filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
|
||||
$: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed);
|
||||
$: allLeftPanelsCollapsed = branchPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed;
|
||||
$: editorToolName = externalToolDisplayName("editor", externalToolsSettings.editor, detectedExternalTools);
|
||||
@@ -568,6 +599,8 @@
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
resumeFrontend();
|
||||
|
||||
onMount(() => {
|
||||
themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)");
|
||||
themeMediaQuery.addEventListener("change", handleSystemThemeChange);
|
||||
@@ -597,7 +630,6 @@
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
handleAppShutdown();
|
||||
themeMediaQuery?.removeEventListener("change", handleSystemThemeChange);
|
||||
window.removeEventListener("beforeunload", handleAppShutdown);
|
||||
window.removeEventListener("pagehide", handleAppShutdown);
|
||||
@@ -719,7 +751,7 @@
|
||||
if (pendingStartupCloneRequest) {
|
||||
const request = pendingStartupCloneRequest;
|
||||
pendingStartupCloneRequest = null;
|
||||
await cloneRepo(request.remoteUrl, request.parentPath, request.directoryName);
|
||||
await cloneRepo(request.remoteUrl, request.parentPath, request.directoryName, undefined, undefined, undefined, false, "credentials", request.cloneOptions);
|
||||
return;
|
||||
}
|
||||
const path = pendingStartupRepoPath;
|
||||
@@ -980,7 +1012,9 @@
|
||||
backgroundRepoStatusIndex += 1;
|
||||
|
||||
try {
|
||||
if (fetchFirst) await fetchRemote(path);
|
||||
if (fetchFirst) {
|
||||
await fetchRemote(path);
|
||||
}
|
||||
const nextStatus = await getStatus(path);
|
||||
updateRepoManagementStatus(path, nextStatus);
|
||||
} catch {
|
||||
@@ -993,7 +1027,7 @@
|
||||
}
|
||||
|
||||
async function autoRefreshTick() {
|
||||
if (appShuttingDown || !autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || gitLfsDialogOpen || newBranchCommit || commitNoteTarget || globalSearchOpen || helpOpen) return;
|
||||
if (appShuttingDown || !autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || bisectOpen || worktreeDialogOpen || gitLfsDialogOpen || newBranchCommit || commitNoteTarget || globalSearchOpen || helpOpen) return;
|
||||
const path = activeRepoPath;
|
||||
autoRefreshInFlight = true;
|
||||
try {
|
||||
@@ -1234,12 +1268,14 @@
|
||||
const providerConfig = integrationsToSave.providers[update.provider];
|
||||
if (update.removeToken) {
|
||||
await credDelete(key);
|
||||
credentialCache.set(key, null);
|
||||
if (azureOrganization) azureOrganization.tokenStored = false;
|
||||
else providerConfig.tokenStored = false;
|
||||
} else if (update.token) {
|
||||
const fallbackUsername = update.provider === "github" ? "x-access-token" : "oauth2";
|
||||
const username = (azureOrganization?.username ?? providerConfig.username).trim() || fallbackUsername;
|
||||
await credSave(key, username, update.token, "token");
|
||||
credentialCache.set(key, { username, password: update.token, mode: "token" });
|
||||
if (azureOrganization) azureOrganization.tokenStored = true;
|
||||
else providerConfig.tokenStored = true;
|
||||
}
|
||||
@@ -1598,13 +1634,6 @@
|
||||
};
|
||||
}
|
||||
|
||||
function repoMatchesSearch(repo: RepoTab, searchTerm = repoSearchTerm): boolean {
|
||||
if (!searchTerm) return true;
|
||||
return repo.name.toLowerCase().includes(searchTerm)
|
||||
|| repo.path.toLowerCase().includes(searchTerm)
|
||||
|| (repo.branch ?? "").toLowerCase().includes(searchTerm);
|
||||
}
|
||||
|
||||
function loadRepoLists() {
|
||||
try {
|
||||
const cacheValue = JSON.parse(localStorage.getItem(REPO_STATUS_CACHE_KEY) ?? "{}") as unknown;
|
||||
@@ -2462,12 +2491,13 @@
|
||||
key?: string | null,
|
||||
fromStore = false,
|
||||
credentialMode: CredentialMode = "credentials",
|
||||
cloneOptions: CloneOptions = { branch: null, blobless: false, customFlags: "", shallowDepth: null, shallowSince: null, sparse: false, sparsePaths: [] },
|
||||
) {
|
||||
if (isBusy) return;
|
||||
if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; }
|
||||
if (!parentPath) { errorMessage = "Select a destination folder."; return; }
|
||||
|
||||
const request: CloneRequest = { remoteUrl, parentPath, directoryName };
|
||||
const request: CloneRequest = { remoteUrl, parentPath, directoryName, cloneOptions };
|
||||
pendingClone = request;
|
||||
const credentialKey = key === undefined ? orgKeyFromUrl(remoteUrl) : key;
|
||||
|
||||
@@ -2483,7 +2513,7 @@
|
||||
credDialogOpen = true;
|
||||
return;
|
||||
}
|
||||
await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true, storedMode);
|
||||
await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true, storedMode, cloneOptions);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -2499,6 +2529,7 @@
|
||||
username,
|
||||
password,
|
||||
COMMIT_HISTORY_PAGE_SIZE + 1,
|
||||
cloneOptions,
|
||||
);
|
||||
resetRepositoryState(false);
|
||||
await applyRepositoryBundle(bundle.status.repo_path, bundle);
|
||||
@@ -2555,9 +2586,9 @@
|
||||
trackEvent("clone_dialog_opened");
|
||||
}
|
||||
|
||||
function cloneFromDialog(remoteUrl: string, parentPath: string, directoryName: string, provider?: GitIntegrationProvider, accountId?: string) {
|
||||
function cloneFromDialog(remoteUrl: string, parentPath: string, directoryName: string, cloneOptions: CloneOptions, provider?: GitIntegrationProvider, accountId?: string) {
|
||||
const credentialKey = provider ? integrationCredentialKey(provider, accountId) : undefined;
|
||||
void cloneRepo(remoteUrl, parentPath, directoryName, undefined, undefined, credentialKey, false, provider ? "token" : "credentials");
|
||||
void cloneRepo(remoteUrl, parentPath, directoryName, undefined, undefined, credentialKey, false, provider ? "token" : "credentials", cloneOptions);
|
||||
}
|
||||
|
||||
function openRepoManagement() {
|
||||
@@ -2571,6 +2602,189 @@
|
||||
void backgroundRepoStatusTick(false);
|
||||
}
|
||||
|
||||
function openReviewCenter(repository = "", sourceId = "") {
|
||||
if (isBusy) return;
|
||||
closeRepoTabContextMenu();
|
||||
reviewCenterInitialQuery = repository;
|
||||
reviewCenterInitialSourceId = sourceId;
|
||||
activeView = "review-center";
|
||||
trackEvent("review_center_opened", { integrations: Object.values(gitIntegrationSettings.providers).filter((provider) => provider.enabled && provider.tokenStored).length });
|
||||
}
|
||||
|
||||
function reviewRemoteIdentity(value: string): { host: string; repository: string } | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
let host = "";
|
||||
let pathname = "";
|
||||
const scp = trimmed.match(/^[^@\s]+@([^:\s]+):(.+)$/);
|
||||
if (scp) {
|
||||
host = scp[1];
|
||||
pathname = scp[2];
|
||||
} else {
|
||||
try {
|
||||
const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
|
||||
host = url.hostname;
|
||||
pathname = url.pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const parts = pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").split("/").filter(Boolean);
|
||||
const gitIndex = parts.findIndex((part) => part.toLocaleLowerCase() === "_git");
|
||||
const repository = gitIndex > 0 && parts[gitIndex + 1]
|
||||
? `${parts[gitIndex - 1]}/${parts[gitIndex + 1]}`
|
||||
: parts[0]?.toLocaleLowerCase() === "v3" && parts.length >= 4
|
||||
? `${parts[2]}/${parts[3]}`
|
||||
: parts.join("/");
|
||||
return { host: host.toLocaleLowerCase().replace(/^ssh\./, ""), repository: repository.toLocaleLowerCase() };
|
||||
}
|
||||
|
||||
function reviewRepositoryMatches(remoteRepository: string, requestRepository: string): boolean {
|
||||
const request = requestRepository.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLocaleLowerCase();
|
||||
return Boolean(request) && (remoteRepository === request || remoteRepository.endsWith(`/${request}`) || request.endsWith(`/${remoteRepository}`));
|
||||
}
|
||||
|
||||
async function findReviewConflictRepository(request: IntegrationReviewRequest, sourceId: string): Promise<{ path: string; remote: GitRemote } | null> {
|
||||
const source = configuredIntegrationSources(gitIntegrationSettings).find((candidate) => candidate.id === sourceId);
|
||||
if (!source) return null;
|
||||
let sourceHost = "";
|
||||
try { sourceHost = new URL(source.baseUrl).hostname.toLocaleLowerCase().replace(/^ssh\./, ""); }
|
||||
catch { return null; }
|
||||
for (const repo of dashboardRepos) {
|
||||
try {
|
||||
const remotes = (await listRemotes(repo.path)).sort((left, right) => Number(right.name === "origin") - Number(left.name === "origin"));
|
||||
const remote = remotes.find((candidate) => {
|
||||
const identity = reviewRemoteIdentity(candidate.fetch_url);
|
||||
return identity?.host === sourceHost && reviewRepositoryMatches(identity.repository, request.repositoryName);
|
||||
});
|
||||
if (remote) return { path: repo.path, remote };
|
||||
} catch {
|
||||
// Invalid or unavailable dashboard repositories are ignored while matching.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setReviewConflictError(message: string) {
|
||||
reviewConflictPhase = "error";
|
||||
reviewConflictMessage = message;
|
||||
activeView = "review-center";
|
||||
}
|
||||
|
||||
async function startReviewConflictResolution(request: IntegrationReviewRequest, sourceId: string) {
|
||||
if (isBusy || !request.sourceBranch || !request.targetBranch) return;
|
||||
reviewConflictRequestId = request.id;
|
||||
reviewConflictPhase = "preparing";
|
||||
reviewConflictMessage = appLanguage === "de" ? "Lokales Repository und Branches werden vorbereitet …" : "Preparing the local repository and branches …";
|
||||
reviewConflictRepoPath = "";
|
||||
reviewConflictRemote = "";
|
||||
reviewConflictPushRequested = false;
|
||||
try {
|
||||
const match = await findReviewConflictRepository(request, sourceId);
|
||||
if (!match) throw new Error(appLanguage === "de" ? "Kein passendes lokales Repository gefunden. Öffne oder klone das Repository zuerst im Dashboard." : "No matching local repository was found. Open or clone the repository from the dashboard first.");
|
||||
await openRepo(match.path);
|
||||
activeView = "review-center";
|
||||
if (!sameRepoPath(activeRepoPath, match.path) || !status) throw new Error(errorMessage || (appLanguage === "de" ? "Das lokale Repository konnte nicht geöffnet werden." : "The local repository could not be opened."));
|
||||
if (status.merge_in_progress) throw new Error(appLanguage === "de" ? "In diesem Repository läuft bereits ein Merge. Schließe ihn zuerst ab oder brich ihn ab." : "A merge is already in progress in this repository. Complete or abort it first.");
|
||||
if (!status.clean) throw new Error(appLanguage === "de" ? "Der Working Tree enthält Änderungen. Committe oder stashe sie, bevor du den PR-Konflikt löst." : "The working tree contains changes. Commit or stash them before resolving the PR conflict.");
|
||||
|
||||
const source = configuredIntegrationSources(gitIntegrationSettings).find((candidate) => candidate.id === sourceId);
|
||||
if (!source) throw new Error(appLanguage === "de" ? "Die Integration ist nicht mehr verfügbar." : "The integration is no longer available.");
|
||||
const credential = await loadStoredCredential(integrationCredentialKey(source.provider, source.accountId));
|
||||
if (!credential?.password) throw new Error(appLanguage === "de" ? "Für diese Integration ist kein Token gespeichert." : "No token is stored for this integration.");
|
||||
|
||||
operation = appLanguage === "de" ? "PR-Konflikt wird vorbereitet" : "Preparing PR conflict resolution";
|
||||
errorMessage = "";
|
||||
reviewConflictRepoPath = match.path;
|
||||
reviewConflictRemote = match.remote.name;
|
||||
selectedRemote = match.remote.name;
|
||||
applyStatus(await fetchRemote(match.path, credential.username, credential.password, false, match.remote.name));
|
||||
const availableBranches = await listBranches(match.path);
|
||||
const localSource = availableBranches.find((branch) => !branch.remote && branch.name === request.sourceBranch);
|
||||
const remoteSource = availableBranches.find((branch) => branch.remote && branch.name === `${match.remote.name}/${request.sourceBranch}`);
|
||||
const sourceBranch = localSource?.name ?? remoteSource?.name;
|
||||
if (!sourceBranch) throw new Error(appLanguage === "de" ? `Der Quellbranch „${request.sourceBranch}“ wurde lokal und auf ${match.remote.name} nicht gefunden.` : `The source branch “${request.sourceBranch}” was not found locally or on ${match.remote.name}.`);
|
||||
if (status?.current_branch !== request.sourceBranch) applyStatus(await checkoutBranch(match.path, sourceBranch));
|
||||
if (remoteSource) applyStatus(await mergeBranch(match.path, remoteSource.name, "ff-only"));
|
||||
const targetBranch = availableBranches.some((branch) => branch.remote && branch.name === `${match.remote.name}/${request.targetBranch}`)
|
||||
? `${match.remote.name}/${request.targetBranch}`
|
||||
: request.targetBranch;
|
||||
applyStatus(await mergeBranch(match.path, targetBranch, "default"));
|
||||
await refreshRepositoryViews(match.path);
|
||||
activeView = "review-center";
|
||||
operation = "";
|
||||
if (statusHasConflicts(status)) {
|
||||
reviewConflictPhase = "conflicts";
|
||||
reviewConflictMessage = appLanguage === "de" ? "Wähle für jede Konfliktstelle die gewünschte Version." : "Choose the version to keep for each conflict.";
|
||||
await tick();
|
||||
await openReviewResolveDialog();
|
||||
} else {
|
||||
reviewConflictPhase = "ready-to-push";
|
||||
reviewConflictMessage = appLanguage === "de" ? "Der Zielbranch wurde ohne Dateikonflikte übernommen. Der Quellbranch kann jetzt gepusht werden." : "The target branch was merged without file conflicts. The source branch is ready to push.";
|
||||
await tick();
|
||||
await pushReviewConflictResolution();
|
||||
}
|
||||
trackEvent("review_conflict_resolution_started");
|
||||
} catch (error) {
|
||||
operation = "";
|
||||
setReviewConflictError(errorToMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function reopenReviewConflictResolver() {
|
||||
if (reviewConflictPhase !== "conflicts" || !hasConflicts) return;
|
||||
await openReviewResolveDialog();
|
||||
}
|
||||
|
||||
async function continueReviewConflictMerge() {
|
||||
if (!reviewConflictRequestId || hasConflicts || !mergeInProgress || isBusy) return;
|
||||
reviewConflictPhase = "ready-to-continue";
|
||||
reviewConflictMessage = appLanguage === "de" ? "Der Merge-Commit wird erstellt …" : "Creating the merge commit …";
|
||||
await continueMerge();
|
||||
activeView = "review-center";
|
||||
await tick();
|
||||
if (!errorMessage && !mergeInProgress) {
|
||||
reviewConflictPhase = "ready-to-push";
|
||||
reviewConflictMessage = appLanguage === "de" ? "Der Merge ist abgeschlossen. Pushe den Quellbranch, damit der PR aktualisiert wird." : "The merge is complete. Push the source branch to update the pull request.";
|
||||
await tick();
|
||||
await pushReviewConflictResolution();
|
||||
} else if (errorMessage) {
|
||||
reviewConflictMessage = appLanguage === "de" ? `Der Merge-Commit konnte nicht erstellt werden: ${errorMessage}` : `The merge commit could not be created: ${errorMessage}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function abortReviewConflictResolution() {
|
||||
if (mergeInProgress) await abortMerge();
|
||||
if (mergeInProgress) return;
|
||||
resolveDialogOpen = false;
|
||||
reviewConflictPhase = "idle";
|
||||
reviewConflictRequestId = "";
|
||||
reviewConflictMessage = "";
|
||||
reviewConflictPushRequested = false;
|
||||
activeView = "review-center";
|
||||
}
|
||||
|
||||
async function pushReviewConflictResolution() {
|
||||
if (reviewConflictPhase !== "ready-to-push" || !reviewConflictRepoPath || isBusy) return;
|
||||
selectedRemote = reviewConflictRemote;
|
||||
reviewConflictPushRequested = true;
|
||||
await pushRepo();
|
||||
activeView = "review-center";
|
||||
completeReviewConflictPushIfReady();
|
||||
}
|
||||
|
||||
$: if (reviewConflictPhase === "conflicts" && reviewConflictRequestId && mergeInProgress && !hasConflicts) {
|
||||
reviewConflictPhase = "ready-to-continue";
|
||||
reviewConflictMessage = appLanguage === "de" ? "Alle Dateien sind gelöst. Schließe jetzt den lokalen Merge ab." : "All files are resolved. Complete the local merge now.";
|
||||
}
|
||||
|
||||
function completeReviewConflictPushIfReady() {
|
||||
if (reviewConflictPhase !== "ready-to-push" || !reviewConflictPushRequested || !reviewConflictRepoPath || !sameRepoPath(activeRepoPath, reviewConflictRepoPath) || errorMessage || credDialogOpen || mergeInProgress || (status?.ahead ?? 0) !== 0) return;
|
||||
reviewConflictPhase = "complete";
|
||||
reviewConflictMessage = appLanguage === "de" ? "Der gelöste Branch wurde gepusht. Der Anbieter prüft den Merge-Status erneut." : "The resolved branch was pushed. The provider is checking the merge status again.";
|
||||
trackEvent("review_conflict_resolution_pushed");
|
||||
}
|
||||
|
||||
async function selectRepoTab(path: string) {
|
||||
if (isBusy) return;
|
||||
if (activeView === "repository" && sameRepoPath(activeRepoPath, path)) return;
|
||||
@@ -2605,7 +2819,11 @@
|
||||
await closeRepoTab(path);
|
||||
}
|
||||
|
||||
async function closeRepoTab(path: string, event?: MouseEvent) {
|
||||
async function closeDashboardRepository(path: string) {
|
||||
await closeRepoTab(path, undefined, true);
|
||||
}
|
||||
|
||||
async function closeRepoTab(path: string, event?: MouseEvent, keepManagement = false) {
|
||||
event?.stopPropagation();
|
||||
if (isBusy) return;
|
||||
closeRepoTabContextMenu();
|
||||
@@ -2622,6 +2840,12 @@
|
||||
});
|
||||
|
||||
if (!wasActive) return;
|
||||
if (keepManagement) {
|
||||
repoOpenRequestId += 1;
|
||||
resetRepositoryState(true);
|
||||
activeView = "management";
|
||||
return;
|
||||
}
|
||||
if (next) {
|
||||
// Switch identity immediately. Otherwise a status/fetch request for the
|
||||
// just-closed repository can still finish while it remains active and
|
||||
@@ -3201,11 +3425,15 @@
|
||||
|
||||
async function merge(branch: GitBranchInfo) {
|
||||
if (!activeRepoPath || branch.current) return;
|
||||
const strategy = (window.prompt("Merge strategy: default, squash, ff-only, or no-ff", "default") ?? "").trim();
|
||||
if (!strategy) return;
|
||||
if (!["default", "squash", "ff-only", "no-ff"].includes(strategy)) { errorMessage = "Unknown merge strategy."; return; }
|
||||
mergeBranchTarget = branch;
|
||||
}
|
||||
|
||||
async function confirmMerge(strategy: MergeStrategy) {
|
||||
const branch = mergeBranchTarget;
|
||||
if (!activeRepoPath || !branch || branch.current) return;
|
||||
await runOperation(`Merging ${branch.name}`, async () => {
|
||||
applyStatus(await mergeBranch(activeRepoPath, branch.name, strategy as import("./lib/types").MergeStrategy));
|
||||
applyStatus(await mergeBranch(activeRepoPath, branch.name, strategy));
|
||||
mergeBranchTarget = null;
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("branch_merged", {
|
||||
remote: branch.remote ? 1 : 0,
|
||||
@@ -3310,6 +3538,57 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function openBisect() {
|
||||
if (!hasRepository || isBusy) return;
|
||||
bisectOpen = true;
|
||||
bisectState = null;
|
||||
bisectError = "";
|
||||
bisectLoading = true;
|
||||
try {
|
||||
bisectState = await getBisectState(activeRepoPath);
|
||||
trackEvent("bisect_opened", { active: bisectState.active ? 1 : 0 });
|
||||
} catch (error) {
|
||||
bisectError = errorToMessage(error);
|
||||
} finally {
|
||||
bisectLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function beginBisect(good: string, bad: string) {
|
||||
if (!activeRepoPath || isBusy) return;
|
||||
bisectError = "";
|
||||
await runOperation("Starting Git bisect", async () => {
|
||||
bisectState = await startBisect(activeRepoPath, good, bad);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("bisect_started");
|
||||
});
|
||||
if (bisectOpen && errorMessage) bisectError = errorMessage;
|
||||
}
|
||||
|
||||
async function continueBisect(verdict: "good" | "bad" | "skip") {
|
||||
if (!activeRepoPath || isBusy) return;
|
||||
bisectError = "";
|
||||
await runOperation("Continuing Git bisect", async () => {
|
||||
bisectState = await markBisect(activeRepoPath, verdict);
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("bisect_commit_marked", { verdict, finished: bisectState.finished ? 1 : 0 });
|
||||
});
|
||||
if (bisectOpen && errorMessage) bisectError = errorMessage;
|
||||
}
|
||||
|
||||
async function stopBisect() {
|
||||
if (!activeRepoPath || isBusy) return;
|
||||
bisectError = "";
|
||||
await runOperation("Stopping Git bisect", async () => {
|
||||
applyStatus(await resetBisect(activeRepoPath));
|
||||
bisectOpen = false;
|
||||
bisectState = null;
|
||||
await refreshRepositoryViews(activeRepoPath);
|
||||
trackEvent("bisect_stopped");
|
||||
});
|
||||
if (bisectOpen && errorMessage) bisectError = errorMessage;
|
||||
}
|
||||
|
||||
async function previewReflogEntry(entry: ReflogEntry) {
|
||||
if (!activeRepoPath || isBusy) return;
|
||||
await runOperation("Previewing reflog entry", async () => {
|
||||
@@ -3578,8 +3857,34 @@
|
||||
|
||||
async function loadStoredCredential(key: string | null): Promise<StoredCredential | null> {
|
||||
if (!key) return null;
|
||||
if (credentialCache.has(key)) return credentialCache.get(key) ?? null;
|
||||
const repoHost = key.startsWith("integration:") ? "" : key.split("/", 1)[0].toLowerCase();
|
||||
const integrationAliases = repoHost
|
||||
? configuredIntegrationSources(gitIntegrationSettings)
|
||||
.filter((source) => {
|
||||
try { return new URL(source.baseUrl).host.toLowerCase() === repoHost; }
|
||||
catch { return false; }
|
||||
})
|
||||
.map((source) => integrationCredentialKey(source.provider, source.accountId))
|
||||
: [];
|
||||
const candidates = [...new Set([...integrationAliases, key])];
|
||||
try {
|
||||
return await credLoad(key);
|
||||
for (const candidate of candidates) {
|
||||
const cached = credentialCache.get(candidate);
|
||||
if (cached) {
|
||||
credentialCache.set(key, cached);
|
||||
return cached;
|
||||
}
|
||||
if (credentialCache.has(candidate)) continue;
|
||||
const credential = await credLoad(candidate);
|
||||
credentialCache.set(candidate, credential);
|
||||
if (credential) {
|
||||
credentialCache.set(key, credential);
|
||||
return credential;
|
||||
}
|
||||
}
|
||||
credentialCache.set(key, null);
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -3788,6 +4093,7 @@
|
||||
}
|
||||
|
||||
handleRemoteResult("push", key, fromStore, username, mode);
|
||||
completeReviewConflictPushIfReady();
|
||||
}
|
||||
|
||||
async function doActualRemoteRename(
|
||||
@@ -3856,6 +4162,7 @@
|
||||
if (save && key) {
|
||||
try {
|
||||
await credSave(key, username, password, mode);
|
||||
credentialCache.set(key, { username, password, mode });
|
||||
} catch (error) {
|
||||
credDialogError = errorToMessage(error);
|
||||
return;
|
||||
@@ -3877,6 +4184,7 @@
|
||||
key,
|
||||
false,
|
||||
mode,
|
||||
pendingClone.cloneOptions,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3921,10 +4229,17 @@
|
||||
}
|
||||
|
||||
async function initializeRepository() {
|
||||
const selected = await openDialog({ directory: true, multiple: false, title: "Choose an empty or existing folder" });
|
||||
if (typeof selected !== "string") return;
|
||||
const branch = window.prompt("Initial branch name", "main")?.trim(); if (!branch) return;
|
||||
await runOperation("Initializing repository", async () => { await initRepository(selected, branch); await openRepo(selected); });
|
||||
initRepositoryDialogOpen = true;
|
||||
}
|
||||
|
||||
async function confirmInitializeRepository(path: string, branch: string) {
|
||||
const selected = path.trim();
|
||||
if (!selected) return;
|
||||
await runOperation("Initializing repository", async () => {
|
||||
await initRepository(selected, branch);
|
||||
initRepositoryDialogOpen = false;
|
||||
await openRepo(selected);
|
||||
});
|
||||
}
|
||||
|
||||
async function revertHistoryCommit(commit: GitCommit) {
|
||||
@@ -4915,6 +5230,19 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function openReviewResolveDialog() {
|
||||
if (!hasConflicts || isBusy) return;
|
||||
const first = conflictedFiles[0].path;
|
||||
await runOperation("Loading conflicts", async () => {
|
||||
preparedResolutions = {};
|
||||
resolveDialogOpen = true;
|
||||
await loadConflict(first);
|
||||
trackEvent("review_conflict_editor_opened", {
|
||||
conflicts: conflictedFiles.length,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function selectConflictFile(path: string) {
|
||||
if (path === conflictTarget || isBusy) return;
|
||||
const openExternally = externalToolsSettings.mergeOpenMode === "external";
|
||||
@@ -4958,6 +5286,7 @@
|
||||
async function applyPreparedResolutions() {
|
||||
if (!activeRepoPath || isBusy || Object.keys(preparedResolutions).length === 0) return;
|
||||
const entries = Object.entries(preparedResolutions);
|
||||
let completeReviewMerge = false;
|
||||
await runOperation(`Resolving ${entries.length} ${entries.length === 1 ? "file" : "files"}`, async () => {
|
||||
let nextStatus: GitStatus | null = null;
|
||||
for (const [file, prepared] of entries) {
|
||||
@@ -4976,6 +5305,12 @@
|
||||
resolveDialogOpen = false;
|
||||
conflict = null;
|
||||
conflictTarget = "";
|
||||
completeReviewMerge = Boolean(
|
||||
reviewConflictRequestId
|
||||
&& reviewConflictRepoPath
|
||||
&& sameRepoPath(activeRepoPath, reviewConflictRepoPath)
|
||||
&& nextStatus?.merge_in_progress,
|
||||
);
|
||||
} else {
|
||||
await loadConflict(remaining[0].path);
|
||||
}
|
||||
@@ -4984,6 +5319,12 @@
|
||||
remaining: remaining.length,
|
||||
});
|
||||
});
|
||||
if (completeReviewMerge && !errorMessage) {
|
||||
reviewConflictPhase = "ready-to-continue";
|
||||
activeView = "review-center";
|
||||
await tick();
|
||||
await continueReviewConflictMerge();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event handlers ─────────────────────────────────────────────────────────
|
||||
@@ -5018,6 +5359,7 @@
|
||||
else if (event.key === "Escape" && gitLfsDialogOpen) closeGitLfsDialog();
|
||||
else if (event.key === "Escape" && interactiveRebaseOpen && !isBusy) interactiveRebaseOpen = false;
|
||||
else if (event.key === "Escape" && reflogOpen && !isBusy) reflogOpen = false;
|
||||
else if (event.key === "Escape" && bisectOpen && !isBusy) bisectOpen = false;
|
||||
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
|
||||
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
|
||||
}
|
||||
@@ -5054,6 +5396,13 @@
|
||||
{isBusy}
|
||||
language={appLanguage}
|
||||
onOpenManagement={openRepoManagement}
|
||||
onOpenRepositories={() => {
|
||||
const path = repoTabs.find(tab => sameRepoPath(tab.path, activeRepoPath))?.path ?? repoTabs[0]?.path;
|
||||
if (path) return selectRepoTab(path);
|
||||
return chooseRepositoryFolder();
|
||||
}}
|
||||
onOpenReviewCenter={() => openReviewCenter()}
|
||||
onOpenIssues={() => { activeView = "issues"; }}
|
||||
isActive={(path) => activeView === "repository" && sameRepoPath(activeRepoPath, path)}
|
||||
onSelect={selectRepoTab}
|
||||
onClose={closeRepoTab}
|
||||
@@ -5083,6 +5432,7 @@
|
||||
onCompare={openCompareSelect}
|
||||
onInteractiveRebase={openInteractiveRebase}
|
||||
onReflog={openReflog}
|
||||
onBisect={openBisect}
|
||||
onOpenInEditor={openActiveRepoInEditor}
|
||||
onOpenTerminal={openActiveRepoTerminal}
|
||||
onOpenInExplorer={openActiveRepoFileManager}
|
||||
@@ -5161,13 +5511,7 @@
|
||||
{/if}
|
||||
|
||||
{#if workspaceActive && mergeInProgress}
|
||||
<section class="notice conflict" role="status">
|
||||
<GitMerge size={17} aria-hidden="true" />
|
||||
<span>{hasConflicts ? "Merge in progress. Resolve all conflicts, then continue." : "Merge is ready to be completed."}</span>
|
||||
{#if hasConflicts}<button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>{/if}
|
||||
<button type="button" onclick={continueMerge} disabled={isBusy || hasConflicts}>Continue</button>
|
||||
<button type="button" onclick={abortMerge} disabled={isBusy}>Abort</button>
|
||||
</section>
|
||||
<MergeProgressNotice conflictCount={conflictedFiles.length} {isBusy} language={appLanguage} onResolve={openResolveDialog} onContinue={continueMerge} onAbort={abortMerge} />
|
||||
{:else if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress}
|
||||
<section class="notice conflict" role="alert">
|
||||
<GitMerge size={17} aria-hidden="true" />
|
||||
@@ -5214,165 +5558,41 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<PullRequestStatusLoader
|
||||
repos={dashboardRepos} language={appLanguage}
|
||||
integrations={gitIntegrationSettings} loadCredential={loadStoredCredential}
|
||||
bind:pullRequestBadges
|
||||
/>
|
||||
{#if activeView === "management"}
|
||||
<section class="repo-management" aria-label="Repository Management">
|
||||
<div class="repo-management-head">
|
||||
<div>
|
||||
<span class="eyebrow">Repository Management</span>
|
||||
<h1>Repositories</h1>
|
||||
</div>
|
||||
<div class="repo-management-actions">
|
||||
<button class="btn-primary" type="button" onclick={openCloneDialog} disabled={isBusy}>
|
||||
<Download size={15} aria-hidden="true" />
|
||||
Clone
|
||||
</button>
|
||||
<button class="btn-secondary" type="button" onclick={initializeRepository} disabled={isBusy}>
|
||||
<Plus size={15} aria-hidden="true" /> Init
|
||||
</button>
|
||||
<button class="btn-secondary" type="button" onclick={chooseRepositoryFolder} disabled={isBusy}>
|
||||
<FolderOpen size={15} aria-hidden="true" />
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="repo-management-tools">
|
||||
<div class="repo-search">
|
||||
<Search size={15} aria-hidden="true" />
|
||||
<input
|
||||
bind:value={repoSearch}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="Search repositories"
|
||||
aria-label="Search repositories"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="repo-sections">
|
||||
<section class="repo-section">
|
||||
<header>
|
||||
<h2>Open repositories</h2>
|
||||
<span>{openRepoRows.length}</span>
|
||||
</header>
|
||||
{#if openRepoRows.length === 0}
|
||||
<div class="repo-empty">No open repositories.</div>
|
||||
{:else}
|
||||
<div class="repo-table">
|
||||
{#each openRepoRows as repo (repo.path)}
|
||||
<div class="repo-row">
|
||||
<button class="repo-row-main" type="button" onclick={() => selectRepoTab(repo.path)} disabled={isBusy}>
|
||||
<span class="repo-row-name">{repo.name}</span>
|
||||
<span class="repo-row-path">{repo.path}</span>
|
||||
<span class="repo-row-meta">
|
||||
{#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{/if}
|
||||
{#if repo.ahead > 0}<em class="ahead">↑ {repo.ahead}</em>{/if}
|
||||
{#if repo.behind > 0}<em class="behind">↓ {repo.behind}</em>{/if}
|
||||
{#if repo.changed > 0}<em>{repo.changed} changed</em>{/if}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="repo-row-icon repo-row-favorite"
|
||||
class:active={isFavoriteRepo(repo.path)}
|
||||
type="button"
|
||||
onclick={(event) => toggleFavoriteRepo(repo.path, event)}
|
||||
disabled={isBusy}
|
||||
title={isFavoriteRepo(repo.path) ? "Remove from favorites" : "Add to favorites"}
|
||||
aria-label={isFavoriteRepo(repo.path) ? `Remove ${repo.name} from favorites` : `Add ${repo.name} to favorites`}
|
||||
>
|
||||
<Star size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button class="repo-row-icon" type="button" onclick={(event) => closeRepoTab(repo.path, event)} disabled={isBusy} title="Close tab" aria-label={`Close ${repo.name}`}>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="repo-section">
|
||||
<header>
|
||||
<h2>Recent repositories</h2>
|
||||
<span>{recentRepoRows.length}</span>
|
||||
</header>
|
||||
{#if recentRepoRows.length === 0}
|
||||
<div class="repo-empty">No recent repositories.</div>
|
||||
{:else}
|
||||
<div class="repo-table">
|
||||
{#each recentRepoRows as repo (repo.path)}
|
||||
<div class="repo-row">
|
||||
<button class="repo-row-main" type="button" onclick={() => openRepo(repo.path)} disabled={isBusy}>
|
||||
<span class="repo-row-name">{repo.name}</span>
|
||||
<span class="repo-row-path">{repo.path}</span>
|
||||
<span class="repo-row-meta">
|
||||
{#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{:else}<em class="quiet">recent</em>{/if}
|
||||
{#if repo.ahead > 0}<em class="ahead">↑ {repo.ahead}</em>{/if}
|
||||
{#if repo.behind > 0}<em class="behind">↓ {repo.behind}</em>{/if}
|
||||
{#if repo.changed > 0}<em>{repo.changed} changed</em>{/if}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="repo-row-icon repo-row-favorite"
|
||||
class:active={isFavoriteRepo(repo.path)}
|
||||
type="button"
|
||||
onclick={(event) => toggleFavoriteRepo(repo.path, event)}
|
||||
disabled={isBusy}
|
||||
title={isFavoriteRepo(repo.path) ? "Remove from favorites" : "Add to favorites"}
|
||||
aria-label={isFavoriteRepo(repo.path) ? `Remove ${repo.name} from favorites` : `Add ${repo.name} to favorites`}
|
||||
>
|
||||
<Star size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromRecent(repo.path, event)} disabled={isBusy} title="Remove from recent" aria-label={`Remove ${repo.name}`}>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="repo-section">
|
||||
<header>
|
||||
<h2>Favorites</h2>
|
||||
<span>{favoriteRepoRows.length}</span>
|
||||
</header>
|
||||
{#if favoriteRepoRows.length === 0}
|
||||
<div class="repo-empty">Mark repositories with the star to keep them here.</div>
|
||||
{:else}
|
||||
<div class="repo-table">
|
||||
{#each favoriteRepoRows as repo (repo.path)}
|
||||
<div class="repo-row">
|
||||
<button class="repo-row-main" type="button" onclick={() => openRepo(repo.path)} disabled={isBusy}>
|
||||
<span class="repo-row-name">{repo.name}</span>
|
||||
<span class="repo-row-path">{repo.path}</span>
|
||||
<span class="repo-row-meta">
|
||||
{#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{:else}<em class="quiet">favorite</em>{/if}
|
||||
{#if repo.ahead > 0}<em class="ahead">↑ {repo.ahead}</em>{/if}
|
||||
{#if repo.behind > 0}<em class="behind">↓ {repo.behind}</em>{/if}
|
||||
{#if repo.changed > 0}<em>{repo.changed} changed</em>{/if}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="repo-row-icon repo-row-favorite active"
|
||||
type="button"
|
||||
onclick={(event) => toggleFavoriteRepo(repo.path, event)}
|
||||
disabled={isBusy}
|
||||
title="Remove from favorites"
|
||||
aria-label={`Remove ${repo.name} from favorites`}
|
||||
>
|
||||
<Star size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove" aria-label={`Remove ${repo.name}`}>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<RepositoryDashboard
|
||||
repos={dashboardRepos} language={appLanguage} {isBusy}
|
||||
{pullRequestBadges}
|
||||
onOpen={selectRepoTab} onAdd={chooseRepositoryFolder} onClone={openCloneDialog}
|
||||
onInit={initializeRepository}
|
||||
onFavorite={toggleFavoriteRepo} onClose={closeDashboardRepository}
|
||||
onRemoveRecent={removeRepoFromRecent}
|
||||
onOpenPullRequests={(repository, sourceId) => openReviewCenter(repository, sourceId)}
|
||||
/>
|
||||
{:else if activeView === "issues"}
|
||||
<IssueCenter language={appLanguage} integrations={gitIntegrationSettings} loadCredential={loadStoredCredential} onOpenSettings={openAppSettings} />
|
||||
{:else if activeView === "review-center"}
|
||||
<ReviewCenter
|
||||
localRepositoryPath={activeRepoPath}
|
||||
language={appLanguage}
|
||||
integrations={gitIntegrationSettings}
|
||||
initialQuery={reviewCenterInitialQuery}
|
||||
initialSourceId={reviewCenterInitialSourceId}
|
||||
localResolutionRequestId={reviewConflictRequestId}
|
||||
localResolutionPhase={reviewConflictPhase}
|
||||
localResolutionMessage={reviewConflictMessage}
|
||||
loadCredential={loadStoredCredential}
|
||||
onOpenSettings={() => { appSettingsOpen = true; }}
|
||||
onStartLocalResolution={startReviewConflictResolution}
|
||||
onOpenLocalResolver={reopenReviewConflictResolver}
|
||||
onContinueLocalResolution={continueReviewConflictMerge}
|
||||
onAbortLocalResolution={abortReviewConflictResolution}
|
||||
onPushLocalResolution={pushReviewConflictResolution}
|
||||
/>
|
||||
{:else}
|
||||
<!-- Workspace -->
|
||||
<section
|
||||
@@ -5707,6 +5927,7 @@
|
||||
onOpenSearch={openGlobalSearchDialog}
|
||||
onOpenCompare={openCompareSelect}
|
||||
onOpenReflog={openReflog}
|
||||
onOpenBisect={openBisect}
|
||||
onOpenInteractiveRebase={openInteractiveRebase}
|
||||
onOpenWorktrees={openWorktreeDialog}
|
||||
onOpenSyncSettings={openSyncOptions}
|
||||
@@ -5912,6 +6133,17 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if mergeBranchTarget}
|
||||
<MergeBranchDialog
|
||||
branch={mergeBranchTarget}
|
||||
currentBranch={status?.current_branch ?? ""}
|
||||
{isBusy}
|
||||
language={appLanguage}
|
||||
onMerge={confirmMerge}
|
||||
onClose={() => { if (!isBusy) mergeBranchTarget = null; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if commitNoteTarget}
|
||||
<CommitNoteDialog
|
||||
commit={commitNoteTarget}
|
||||
@@ -5996,6 +6228,23 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if bisectOpen}
|
||||
{#await import("./lib/components/BisectDialog.svelte") then module}
|
||||
<module.default
|
||||
language={appLanguage}
|
||||
{bisectState}
|
||||
isLoading={bisectLoading}
|
||||
{isBusy}
|
||||
{operation}
|
||||
error={bisectError}
|
||||
onStart={beginBisect}
|
||||
onMark={continueBisect}
|
||||
onReset={stopBisect}
|
||||
onClose={() => { if (!isBusy) bisectOpen = false; }}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
<!-- Compare: pick two branches or commits to diff -->
|
||||
{#if compareSelectOpen}
|
||||
<CompareSelectDialog
|
||||
@@ -6085,6 +6334,16 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Initialize repository dialog -->
|
||||
{#if initRepositoryDialogOpen}
|
||||
<InitRepositoryDialog
|
||||
isBusy={operation === "Initializing repository"}
|
||||
language={appLanguage}
|
||||
onInit={confirmInitializeRepository}
|
||||
onClose={() => { if (!isBusy) initRepositoryDialogOpen = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Full-screen overlay while a repository is being opened -->
|
||||
{#if openingRepo}
|
||||
<RepoLoadingOverlay repoName={repoDisplayName} />
|
||||
|
||||
+164
-34
@@ -26,10 +26,13 @@
|
||||
--color-bar-text: #f0f1f2;
|
||||
--color-bar-muted: #8b8f94;
|
||||
|
||||
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-sync-ahead: #e0a040;
|
||||
--color-sync-behind: #7aacff;
|
||||
--app-color-scheme: dark;
|
||||
--app-bg: #222328;
|
||||
--app-button-bg: #32363d;
|
||||
@@ -149,7 +152,7 @@
|
||||
overflow: hidden;
|
||||
color: var(--color-ink);
|
||||
background: var(--app-bg);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -743,8 +746,8 @@
|
||||
|
||||
.tb-sync-group { display: inline-flex; align-items: center; gap: 4px; min-width: max-content; }
|
||||
.tb-sync { display: inline-flex; align-items: center; padding: 1px 6px; border-radius: 999px; font-size: 11px; font-weight: 800; white-space: nowrap; flex-shrink: 0; }
|
||||
.tb-sync.ahead { color: #e0a040; background: rgba(224,160,64,0.13); }
|
||||
.tb-sync.behind { color: #7aacff; background: rgba(122,172,255,0.13); }
|
||||
.tb-sync.ahead { color: var(--color-sync-ahead); background: color-mix(in srgb, var(--color-sync-ahead) 13%, transparent); }
|
||||
.tb-sync.behind { color: var(--color-sync-behind); background: color-mix(in srgb, var(--color-sync-behind) 13%, transparent); }
|
||||
|
||||
.tb-no-repo { color: var(--color-bar-muted); font-size: 12px; font-style: italic; }
|
||||
|
||||
@@ -842,8 +845,8 @@
|
||||
font-size: 9.5px;
|
||||
font-weight: 850;
|
||||
}
|
||||
.repo-action-count.behind { color: #7aacff; }
|
||||
.repo-action-count.ahead { color: #e0a040; }
|
||||
.repo-action-count.behind { color: var(--color-sync-behind); }
|
||||
.repo-action-count.ahead { color: var(--color-sync-ahead); }
|
||||
.repo-action.sync-primary.publish-local {
|
||||
color: #f0bd6b;
|
||||
background: linear-gradient(180deg, rgba(224,160,64,.1), rgba(224,160,64,.045));
|
||||
@@ -1305,8 +1308,8 @@
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
}
|
||||
.repo-row-meta .ahead { color: #e0a040; background: rgba(224,160,64,0.13); }
|
||||
.repo-row-meta .behind { color: #7aacff; background: rgba(122,172,255,0.13); }
|
||||
.repo-row-meta .ahead { color: var(--color-sync-ahead); background: color-mix(in srgb, var(--color-sync-ahead) 13%, transparent); }
|
||||
.repo-row-meta .behind { color: var(--color-sync-behind); background: color-mix(in srgb, var(--color-sync-behind) 13%, transparent); }
|
||||
.repo-row-icon {
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
@@ -1823,8 +1826,8 @@
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sync-stats strong:first-of-type { color: #e0a040; background: rgba(224,160,64,0.13); }
|
||||
.sync-stats strong:last-of-type { color: #7aacff; background: rgba(122,172,255,0.13); }
|
||||
.sync-stats strong:first-of-type { color: var(--color-sync-ahead); background: color-mix(in srgb, var(--color-sync-ahead) 13%, transparent); }
|
||||
.sync-stats strong:last-of-type { color: var(--color-sync-behind); background: color-mix(in srgb, var(--color-sync-behind) 13%, transparent); }
|
||||
.sync-stats span { color: var(--color-ink-dim); background: rgba(94,110,156,0.13); }
|
||||
.sync-stats .sync-local-only {
|
||||
display: inline-flex;
|
||||
@@ -3362,12 +3365,13 @@
|
||||
opacity: 0.08;
|
||||
}
|
||||
.graph-svg path.graph-segment-ahead {
|
||||
stroke: #e0a040;
|
||||
filter: drop-shadow(0 0 3px rgba(224,160,64,0.24));
|
||||
stroke: var(--color-sync-ahead);
|
||||
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--color-sync-ahead) 24%, transparent));
|
||||
}
|
||||
.graph-svg path.graph-segment-behind {
|
||||
stroke: var(--color-sync-behind);
|
||||
stroke-dasharray: 4 4;
|
||||
filter: drop-shadow(0 0 3px rgba(122,172,255,0.2));
|
||||
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--color-sync-behind) 20%, transparent));
|
||||
}
|
||||
.graph-svg path.graph-ref-connector {
|
||||
opacity: 0.35;
|
||||
@@ -3391,12 +3395,12 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
.graph-dot.ahead {
|
||||
background: #e0a040;
|
||||
box-shadow: 0 0 0 1px rgba(224,160,64,0.42), 0 0 10px rgba(224,160,64,0.16);
|
||||
background: var(--color-sync-ahead);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-sync-ahead) 42%, transparent), 0 0 10px color-mix(in srgb, var(--color-sync-ahead) 16%, transparent);
|
||||
}
|
||||
.graph-dot.behind {
|
||||
background: var(--dot-color, #5a8cf8);
|
||||
box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8), 0 0 10px rgba(122,172,255,0.18);
|
||||
background: var(--color-sync-behind);
|
||||
box-shadow: 0 0 0 1px var(--color-sync-behind), 0 0 10px color-mix(in srgb, var(--color-sync-behind) 18%, transparent);
|
||||
}
|
||||
.graph-dot.tip { width: 14px; height: 14px; box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8); }
|
||||
.graph-dot.merge {
|
||||
@@ -3455,10 +3459,10 @@
|
||||
}
|
||||
.graph-row + .graph-row .commit-body { border-top: 1px solid rgba(226,232,240,0.075); }
|
||||
.graph-row.graph-ahead-row .commit-body {
|
||||
box-shadow: inset 3px 0 0 rgba(224,160,64,0.72);
|
||||
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--color-sync-ahead) 72%, transparent);
|
||||
}
|
||||
.graph-row.graph-behind-row .commit-body {
|
||||
box-shadow: inset 3px 0 0 rgba(122,172,255,0.72);
|
||||
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--color-sync-behind) 72%, transparent);
|
||||
}
|
||||
.graph-row:hover .commit-body { background: rgba(30,39,57,0.72); }
|
||||
.graph-row:hover .graph-svg path { opacity: 1; stroke-width: 2.65; }
|
||||
@@ -3878,6 +3882,20 @@
|
||||
max-height: calc(100vh - 32px);
|
||||
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 {
|
||||
display: block;
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
@@ -4172,6 +4190,69 @@
|
||||
gap: 14px;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -6662,8 +6743,8 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
||||
.workspace-status-spacer { flex: 1 1 auto; }
|
||||
.workspace-health > span { width: 8px; height: 8px; border-radius: 50%; background: #d9891b; }
|
||||
.workspace-health.clean > span { background: #2da44e; }
|
||||
.workspace-statusbar .ahead { color: #d9891b; }
|
||||
.workspace-statusbar .behind { color: var(--color-primary); }
|
||||
.workspace-statusbar .ahead { color: var(--color-sync-ahead); }
|
||||
.workspace-statusbar .behind { color: var(--color-sync-behind); }
|
||||
.workspace-statusbar .workspace-local-only {
|
||||
padding: 2px 6px;
|
||||
border: 1px dashed rgba(224,160,64,.42);
|
||||
@@ -7716,11 +7797,11 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
||||
}
|
||||
:root[data-theme="light"] .workspace-branch svg { color: #0b63e6; }
|
||||
:root[data-theme="light"] .workspace-statusbar .ahead {
|
||||
color: #9a5200;
|
||||
color: var(--color-sync-ahead);
|
||||
font-weight: 800;
|
||||
}
|
||||
:root[data-theme="light"] .workspace-statusbar .behind {
|
||||
color: #0755c8;
|
||||
color: var(--color-sync-behind);
|
||||
font-weight: 800;
|
||||
}
|
||||
:root[data-theme="light"] .repo-action.sync-primary.publish-local,
|
||||
@@ -8843,7 +8924,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
||||
.repo-tab {
|
||||
min-height: 35px;
|
||||
height: 35px;
|
||||
padding: 0 31px 0 15px;
|
||||
padding: 0 34px 0 15px;
|
||||
gap: 6px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 650;
|
||||
@@ -8869,19 +8950,21 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
||||
|
||||
.repo-tab-close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 6px;
|
||||
width: 19px;
|
||||
min-width: 19px;
|
||||
max-width: 19px;
|
||||
height: 19px;
|
||||
min-height: 19px;
|
||||
max-height: 19px;
|
||||
top: 6px;
|
||||
right: 5px;
|
||||
width: 23px;
|
||||
min-width: 23px;
|
||||
max-width: 23px;
|
||||
height: 23px;
|
||||
min-height: 23px;
|
||||
max-height: 23px;
|
||||
margin: 0;
|
||||
border-radius: 2px;
|
||||
border-radius: 3px;
|
||||
color: var(--color-ink-faint);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
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; }
|
||||
@@ -8889,8 +8972,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-close:hover:not(:disabled),
|
||||
.repo-tab-close:focus-visible:not(:disabled) {
|
||||
color: #e1848b;
|
||||
background: transparent;
|
||||
color: #ffffff;
|
||||
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 {
|
||||
@@ -8939,3 +9030,42 @@ 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 {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Shared sync colors keep every indicator consistent in the light theme. */
|
||||
:root[data-theme="light"] {
|
||||
--color-sync-ahead: #9a5200;
|
||||
--color-sync-behind: #0755c8;
|
||||
}
|
||||
|
||||
+49
-56
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Folder, GitBranch, Plus, X } from "@lucide/svelte";
|
||||
import { Columns3, Database, Folder, House, GitPullRequest, Plus, X } from "@lucide/svelte";
|
||||
|
||||
interface RepositoryTabItem {
|
||||
path: string;
|
||||
@@ -7,11 +7,14 @@
|
||||
branch: string | null;
|
||||
}
|
||||
|
||||
export let activeView: "management" | "repository" = "management";
|
||||
export let activeView: "management" | "review-center" | "issues" | "repository" = "management";
|
||||
export let repoTabs: RepositoryTabItem[] = [];
|
||||
export let isBusy: boolean = false;
|
||||
export let language: "en" | "de" = "en";
|
||||
export let onOpenManagement: () => void = () => {};
|
||||
export let onOpenRepositories: () => void | Promise<void> = () => {};
|
||||
export let onOpenIssues: () => void = () => {};
|
||||
export let onOpenReviewCenter: () => void = () => {};
|
||||
export let isActive: (path: string) => boolean = () => false;
|
||||
export let onSelect: (path: string) => void | Promise<void> = () => {};
|
||||
export let onClose: (path: string, event: MouseEvent) => void | Promise<void> = () => {};
|
||||
@@ -19,58 +22,48 @@
|
||||
export let onAdd: () => void | Promise<void> = () => {};
|
||||
</script>
|
||||
|
||||
<header class="repo-tabbar" aria-label={language === "de" ? "Repository-Reiter" : "Repository tabs"}>
|
||||
<button
|
||||
class="repo-tab management"
|
||||
class:active={activeView === "management"}
|
||||
type="button"
|
||||
onclick={onOpenManagement}
|
||||
disabled={isBusy}
|
||||
title={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
|
||||
>
|
||||
<Folder size={15} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div class="repo-tabs-scroll">
|
||||
{#each repoTabs as repo (repo.path)}
|
||||
<div
|
||||
class="repo-tab-wrap"
|
||||
class:active={isActive(repo.path)}
|
||||
role="presentation"
|
||||
oncontextmenu={(event) => onContextMenu(repo.path, event)}
|
||||
>
|
||||
<button
|
||||
class="repo-tab"
|
||||
type="button"
|
||||
onclick={() => onSelect(repo.path)}
|
||||
disabled={isBusy}
|
||||
title={repo.path}
|
||||
>
|
||||
<GitBranch size={13} aria-hidden="true" />
|
||||
<span>{repo.name}</span>
|
||||
</button>
|
||||
<button
|
||||
class="repo-tab-close"
|
||||
type="button"
|
||||
onclick={(event) => onClose(repo.path, event)}
|
||||
disabled={isBusy}
|
||||
aria-label={language === "de" ? `${repo.name} schließen` : `Close ${repo.name}`}
|
||||
title={language === "de" ? "Repository-Tab schließen" : "Close repository tab"}
|
||||
>
|
||||
<X size={11} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="repo-tab-add"
|
||||
type="button"
|
||||
onclick={onAdd}
|
||||
disabled={isBusy}
|
||||
title={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"}
|
||||
aria-label={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"}
|
||||
>
|
||||
<Plus size={15} aria-hidden="true" />
|
||||
</button>
|
||||
<header class="workspace-navigation">
|
||||
<nav class="global-navigation" aria-label={language === "de" ? "Hauptnavigation" : "Main navigation"}>
|
||||
<button type="button" class:active={activeView === "management"} aria-current={activeView === "management" ? "page" : undefined} disabled={isBusy} onclick={onOpenManagement}><House size={17}/><span>Dashboard</span></button>
|
||||
<button type="button" class:active={activeView === "repository"} aria-current={activeView === "repository" ? "page" : undefined} disabled={isBusy} onclick={onOpenRepositories}><Database size={17}/><span>Repositories</span></button>
|
||||
<button type="button" class:active={activeView === "review-center"} aria-current={activeView === "review-center" ? "page" : undefined} disabled={isBusy} onclick={onOpenReviewCenter}><GitPullRequest size={17}/><span>Pull Requests</span></button>
|
||||
<button type="button" class:active={activeView === "issues"} aria-current={activeView === "issues" ? "page" : undefined} disabled={isBusy} onclick={onOpenIssues}><Columns3 size={17}/><span>Issues & Boards</span></button>
|
||||
</nav>
|
||||
{#if activeView === "repository"}
|
||||
<nav class="repository-navigation" aria-label={language === "de" ? "Geöffnete Repositories" : "Open repositories"}>
|
||||
{#each repoTabs as repo (repo.path)}
|
||||
<div class="repository-tab" class:active={isActive(repo.path)} role="presentation" oncontextmenu={(event) => onContextMenu(repo.path, event)}>
|
||||
<button class="repository-select" type="button" onclick={() => onSelect(repo.path)} disabled={isBusy} title={repo.path} aria-current={isActive(repo.path) ? "page" : undefined}><Folder size={16}/><span>{repo.name}</span></button>
|
||||
<button class="repository-close" type="button" onclick={(event) => onClose(repo.path, event)} disabled={isBusy} aria-label={language === "de" ? `${repo.name} schließen` : `Close ${repo.name}`}><X size={13}/></button>
|
||||
</div>
|
||||
{/each}
|
||||
<button class="repository-add" type="button" onclick={onAdd} disabled={isBusy} title={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"} aria-label={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"}><Plus size={18}/></button>
|
||||
</nav>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.workspace-navigation{flex:0 0 auto;min-width:0;background:var(--app-bg);color:var(--color-ink-muted);font-family:var(--font-sans);border-bottom:1px solid var(--color-border-subtle)}
|
||||
.global-navigation{display:flex;align-items:stretch;gap:2px;min-height:38px;padding:0 5px;overflow-x:auto;scrollbar-width:thin;border-bottom:1px solid var(--color-border-subtle)}
|
||||
button{font:inherit;cursor:pointer;color:inherit;background:transparent;border:0;box-shadow:none}
|
||||
button:disabled{opacity:.45;cursor:default}
|
||||
button:focus-visible{outline:2px solid var(--color-accent);outline-offset:-3px}
|
||||
.global-navigation button{position:relative;display:flex;flex:0 0 auto;align-items:center;gap:7px;padding:0 10px;min-height:38px;font-size:13px;white-space:nowrap}
|
||||
.global-navigation button:hover:not(:disabled){background:var(--color-surface-hover);color:var(--color-ink)}
|
||||
.global-navigation button.active{color:var(--color-ink);font-weight:600}
|
||||
.global-navigation button.active::after{position:absolute;content:"";height:2px;bottom:0;left:12px;right:12px;background:var(--color-accent)}
|
||||
.global-navigation button.active>:global(svg){color:var(--color-accent)}
|
||||
.repository-navigation{display:flex;align-items:stretch;gap:4px;min-height:34px;padding:4px 5px 0;overflow-x:auto;scrollbar-width:thin;background:color-mix(in srgb,var(--color-surface) 45%,var(--app-bg))}
|
||||
.repository-tab{display:flex;flex:0 0 auto;align-items:center;min-width:100px;max-width:250px;border:1px solid var(--color-border);border-bottom:0;border-radius:5px 5px 0 0;background:var(--app-bg)}
|
||||
.repository-tab.active{background:var(--color-surface-raised);border-color:var(--color-border-input);color:var(--color-ink)}
|
||||
.repository-tab:hover{background:var(--color-surface-hover)}
|
||||
.repository-select{display:flex;flex:1;min-width:0;align-items:center;gap:7px;min-height:29px;padding:0 9px;font-size:12px;text-align:left}
|
||||
.repository-select span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.repository-select>:global(svg){flex-shrink:0}
|
||||
.repository-tab.active .repository-select{font-weight:600}
|
||||
.repository-close{display:grid;place-items:center;flex:0 0 24px;height:24px;margin-right:2px;color:var(--color-ink-dim)}
|
||||
.repository-close:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-hover)}
|
||||
.repository-add{display:grid;place-items:center;flex:0 0 32px;min-height:29px;border:1px solid var(--color-border);border-bottom:0;border-radius:5px 5px 0 0}
|
||||
.repository-add:hover:not(:disabled){color:var(--color-accent);background:var(--color-surface-hover)}
|
||||
@media(max-width:640px){.global-navigation{gap:0;padding:0 4px}.global-navigation button{gap:7px;padding:0 10px;font-size:12px;min-height:38px}.global-navigation{min-height:38px}}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Box,
|
||||
Bug,
|
||||
ChevronDown,
|
||||
Code2,
|
||||
CloudDownload,
|
||||
@@ -36,6 +37,7 @@
|
||||
export let onCompare: () => void = () => {};
|
||||
export let onInteractiveRebase: () => void = () => {};
|
||||
export let onReflog: () => void = () => {};
|
||||
export let onBisect: () => void = () => {};
|
||||
export let onOpenInExplorer: () => void = () => {};
|
||||
export let onOpenInEditor: () => void = () => {};
|
||||
export let onOpenTerminal: () => void = () => {};
|
||||
@@ -208,6 +210,13 @@
|
||||
<small>{isGerman ? "Commits ordnen und zusammenfassen" : "Reorder and combine commits"}</small>
|
||||
</span>
|
||||
</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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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 { listIntegrationRepositories } from "../git";
|
||||
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";
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
error: string;
|
||||
language: AppLanguage;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,15 @@
|
||||
let repositoryScrollbarDragScrollTop = 0;
|
||||
let repositoryScrollbarFrame: number | undefined;
|
||||
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 configuredSources = $derived(configuredIntegrationSources(integrations));
|
||||
@@ -55,9 +64,24 @@
|
||||
if (!query) return activeRepositories;
|
||||
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 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(() => {
|
||||
const nextError = error || browseError;
|
||||
@@ -230,10 +254,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
function showIntegrations() {
|
||||
function selectIntegrationSource(integrationSource: GitIntegrationSource) {
|
||||
source = "integrations";
|
||||
const nextSource = configuredSources.find((candidate) => candidate.id === selectedSourceId) ?? configuredSources[0];
|
||||
if (nextSource) void loadRepositories(nextSource);
|
||||
void loadRepositories(integrationSource);
|
||||
}
|
||||
|
||||
function showUrlInput() {
|
||||
@@ -249,39 +272,114 @@
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
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>
|
||||
|
||||
<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">
|
||||
<header class="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>
|
||||
<header class="dialog-header clone-dialog-header">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<form class="clone-dialog-form" onsubmit={submit}>
|
||||
<div class="clone-source-tabs" 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} />URL</button>
|
||||
<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>
|
||||
<div class="clone-dialog-layout">
|
||||
<aside class="clone-source-nav" aria-label={isGerman ? "Repository-Quellen" : "Repository sources"}>
|
||||
<div class="clone-source-heading">{isGerman ? "Quelle" : "Source"}</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"}
|
||||
<label class="clone-dialog-field">
|
||||
<span>{isGerman ? "Remote-URL" : "Remote 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"}>
|
||||
<section class="clone-dialog-content">
|
||||
<div class="clone-dialog-title">
|
||||
<span>{source === "integrations" ? (activeSource?.label ?? "Integration") : "URL"}</span>
|
||||
<h3>{isGerman ? "Repository klonen" : "Clone a Repo"}</h3>
|
||||
</div>
|
||||
|
||||
<div class="clone-target-grid">
|
||||
<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}
|
||||
<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}
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
{: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>
|
||||
{: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}
|
||||
{#each filteredRepositories as repository (repository.id)}
|
||||
<button type="button" class="repository-option" class:selected={selectedRepositoryId === repository.id} onclick={() => selectRepository(repository)}>
|
||||
@@ -331,38 +442,68 @@
|
||||
</div>
|
||||
{#if selectedRepository}<div class="selected-repository"><span>{isGerman ? "Ausgewählt" : "Selected"}</span><strong>{selectedRepository.fullName}</strong><code>{selectedRepository.cloneUrl}</code></div>{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if visibleError}<div class="clone-dialog-error" role="alert">{visibleError}</div>{/if}
|
||||
</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>
|
||||
|
||||
{#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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clone-repository-dialog { width: min(760px, calc(100vw - 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-source-tabs button { min-height: 36px; border-color: transparent; color: var(--color-ink-dim); background: transparent; font-size: 11px; font-weight: 800; }
|
||||
.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-source-tabs button.active :global(svg) { color: var(--color-accent); }
|
||||
.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; }
|
||||
.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); }
|
||||
.integration-provider-tabs { display: flex; gap: 5px; overflow-x: auto; }
|
||||
.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; }
|
||||
.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-repository-dialog { width: min(900px, calc(100vw - 32px)); height: min(660px, calc(100vh - 32px)); }
|
||||
.clone-dialog-header { min-height: 52px; padding: 0 16px 0 20px; }
|
||||
.clone-dialog-header h2 { margin: 0; color: var(--color-ink); font-size: 15px; font-weight: 650; }
|
||||
.clone-dialog-form { grid-template-rows: minmax(0, 1fr) auto; gap: 0; height: calc(100% - 53px); padding: 0; }
|
||||
.clone-dialog-layout { display: grid; grid-template-columns: 205px minmax(0, 1fr); min-height: 0; }
|
||||
.clone-source-nav { min-width: 0; padding: 12px 0; border-right: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
||||
.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; }
|
||||
.clone-source-list { display: grid; gap: 2px; }
|
||||
.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; }
|
||||
.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 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 input { height: 34px; padding-left: 31px; font-size: 11px; }
|
||||
.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 { min-height: 160px; max-height: 248px; padding-right: 8px; overflow: auto; scrollbar-width: none; border-radius: inherit; background: transparent; }
|
||||
.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: 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-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; }
|
||||
@@ -371,28 +512,57 @@
|
||||
.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: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: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-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.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: 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 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 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-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 strong, .integration-empty strong { color: var(--color-ink); font-size: 11px; }
|
||||
.repository-state-error strong { color: #e86060; }
|
||||
.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 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 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 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; }
|
||||
@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-target-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(150px, .72fr); gap: 10px; }
|
||||
.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>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
ArrowDownToLine, ArrowUpFromLine, Boxes, CircleHelp, FileCode, GitBranch,
|
||||
ArrowDownToLine, ArrowUpFromLine, Boxes, Bug, CircleHelp, FileCode, GitBranch,
|
||||
GitCompare, History, RefreshCw, Search, Settings, SlidersHorizontal, Sparkles,
|
||||
} from "@lucide/svelte";
|
||||
import type { AppLanguage, GitBranch as GitBranchInfo, GitCommit, GitRepositoryFile } from "../types";
|
||||
|
||||
type ItemKind = "fetch" | "pull" | "push" | "refresh" | "search" | "compare" | "reflog" | "rebase" | "worktrees" | "sync" | "settings" | "ai-settings" | "help" | "branch" | "file" | "commit";
|
||||
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 Props {
|
||||
language: AppLanguage; hasRepository: boolean; isBusy: boolean;
|
||||
@@ -17,7 +17,7 @@
|
||||
onSelectCommit: (commit: GitCommit) => void | Promise<void>;
|
||||
onFetch: () => void | Promise<void>; onPull: () => void | Promise<void>; onPush: () => void | Promise<void>;
|
||||
onRefresh: () => void | Promise<void>; onOpenSearch: () => void; onOpenCompare: () => void;
|
||||
onOpenReflog: () => void | Promise<void>; onOpenInteractiveRebase: () => void;
|
||||
onOpenReflog: () => void | Promise<void>; onOpenInteractiveRebase: () => void; onOpenBisect: () => void | Promise<void>;
|
||||
onOpenWorktrees: () => void | Promise<void>; onOpenSyncSettings: () => void | Promise<void>;
|
||||
onOpenSettings: () => void; onOpenAiSettings: () => void; onOpenHelp: () => void;
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
language = "en", hasRepository = false, isBusy = false, branches = [], files = [], commits = [], onClose = () => {},
|
||||
onCheckoutBranch = () => {}, onOpenFile = () => {}, onSelectCommit = () => {}, onFetch = () => {}, onPull = () => {},
|
||||
onPush = () => {}, onRefresh = () => {}, onOpenSearch = () => {}, onOpenCompare = () => {}, onOpenReflog = () => {},
|
||||
onOpenInteractiveRebase = () => {}, onOpenWorktrees = () => {}, onOpenSyncSettings = () => {}, onOpenSettings = () => {},
|
||||
onOpenInteractiveRebase = () => {}, onOpenBisect = () => {}, onOpenWorktrees = () => {}, onOpenSyncSettings = () => {}, onOpenSettings = () => {},
|
||||
onOpenAiSettings = () => {}, onOpenHelp = () => {},
|
||||
}: 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("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("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("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),
|
||||
@@ -118,6 +119,7 @@
|
||||
{:else if item.kind === "compare"}<GitCompare size={16} />
|
||||
{:else if item.kind === "reflog" || item.kind === "commit"}<History size={16} />
|
||||
{:else if item.kind === "rebase" || item.kind === "branch"}<GitBranch size={16} />
|
||||
{:else if item.kind === "bisect"}<Bug size={16} />
|
||||
{:else if item.kind === "worktrees"}<Boxes size={16} />
|
||||
{:else if item.kind === "sync"}<SlidersHorizontal size={16} />
|
||||
{:else if item.kind === "settings"}<Settings size={16} />
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Heading1, Heading2, Heading3, Bold, Italic, Strikethrough, Quote, Code2, Link, List, ListOrdered, ListTodo, Table2, AtSign, MessageSquare, CaseSensitive, WrapText, Send } from "@lucide/svelte";
|
||||
import { openInBrowser } from "../git";
|
||||
export let value = "";
|
||||
export let language: "de" | "en" = "en";
|
||||
export let disabled = false;
|
||||
export let busy = false;
|
||||
export let onSend: () => void | Promise<void>;
|
||||
let textarea: HTMLTextAreaElement;
|
||||
let preview = false;
|
||||
let monospace = false;
|
||||
let wrap = true;
|
||||
let linkError = "";
|
||||
$: de = language === "de";
|
||||
$: rendered = preview ? DOMPurify.sanitize(marked.parse(value, { async: false, gfm: true, breaks: true }), {
|
||||
ALLOWED_TAGS: ["p","br","strong","em","del","s","blockquote","pre","code","h1","h2","h3","h4","h5","h6","ul","ol","li","a","table","thead","tbody","tr","th","td","hr","input"],
|
||||
ALLOWED_ATTR: ["href","title","type","checked","disabled","align"],
|
||||
}) : "";
|
||||
$: tools = [
|
||||
{icon:Heading1,label:de?"Überschrift 1":"Heading 1",prefix:"# ",line:true},
|
||||
{icon:Heading2,label:de?"Überschrift 2":"Heading 2",prefix:"## ",line:true},
|
||||
{icon:Heading3,label:de?"Überschrift 3":"Heading 3",prefix:"### ",line:true},
|
||||
{icon:Bold,label:de?"Fett":"Bold",prefix:"**",suffix:"**",group:true},
|
||||
{icon:Italic,label:de?"Kursiv":"Italic",prefix:"_",suffix:"_"},
|
||||
{icon:Strikethrough,label:de?"Durchgestrichen":"Strikethrough",prefix:"~~",suffix:"~~"},
|
||||
{icon:Quote,label:de?"Zitat":"Quote",prefix:"> ",line:true,group:true},
|
||||
{icon:Code2,label:"Code",prefix:"`",suffix:"`"},
|
||||
{icon:Link,label:"Link",prefix:"[",suffix:"](https://example.com)"},
|
||||
{icon:List,label:de?"Aufzählung":"Bullet list",prefix:"- ",line:true,group:true},
|
||||
{icon:ListOrdered,label:de?"Nummerierte Liste":"Numbered list",prefix:"1. ",line:true},
|
||||
{icon:ListTodo,label:de?"Aufgabenliste":"Task list",prefix:"- [ ] ",line:true},
|
||||
{icon:Table2,label:de?"Tabelle":"Table",prefix:"\n| A | B |\n| --- | --- |\n| | |\n"},
|
||||
{icon:AtSign,label:de?"Erwähnung einfügen":"Insert mention",prefix:"@",suffix:"",group:true},
|
||||
{icon:MessageSquare,label:de?"Issue-/PR-Verweis einfügen":"Insert issue/PR reference",prefix:"#",suffix:""},
|
||||
];
|
||||
async function format(prefix: string, suffix = "", line = false) {
|
||||
if (disabled || busy) return;
|
||||
preview = false;
|
||||
await tick();
|
||||
let start = textarea.selectionStart, end = textarea.selectionEnd;
|
||||
if (line) {
|
||||
start = value.lastIndexOf("\n", start - 1) + 1;
|
||||
const selected = value.slice(start, end);
|
||||
const replacement = selected.split("\n").map((text, i) => `${prefix === "1. " ? `${i + 1}. ` : prefix}${text}`).join("\n");
|
||||
value = value.slice(0,start) + replacement + value.slice(end);
|
||||
await tick(); textarea.focus(); textarea.setSelectionRange(start, start+replacement.length);
|
||||
} else {
|
||||
const selected = value.slice(start,end);
|
||||
// Multi-line code selections become fenced blocks.
|
||||
if (prefix === "`" && selected.includes("\n")) { prefix = "\n```\n"; suffix = "\n```\n"; }
|
||||
value = value.slice(0,start)+prefix+selected+suffix+value.slice(end);
|
||||
await tick(); textarea.focus(); textarea.setSelectionRange(start+prefix.length,start+prefix.length+selected.length);
|
||||
}
|
||||
}
|
||||
function keyboard(event: KeyboardEvent) {
|
||||
if (!(event.ctrlKey || event.metaKey)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "b" || key === "i" || key === "k") { event.preventDefault(); void format(key === "b" ? "**" : key === "i" ? "_" : "[",key === "b" ? "**" : key === "i" ? "_" : "](https://example.com)"); }
|
||||
if (key === "enter") { event.preventDefault(); if (value.trim() && !busy && !disabled) void onSend(); }
|
||||
}
|
||||
function previewLinks(node: HTMLElement) {
|
||||
node.addEventListener("click", previewClick);
|
||||
return { destroy() { node.removeEventListener("click", previewClick); } };
|
||||
}
|
||||
async function previewClick(event: MouseEvent) {
|
||||
const anchor = event.target instanceof Element ? event.target.closest("a") : null;
|
||||
if (!anchor) return;
|
||||
event.preventDefault();
|
||||
const href = anchor.getAttribute("href") ?? "";
|
||||
if (!/^https?:\/\//i.test(href)) return;
|
||||
try { await openInBrowser(href); } catch (cause) { linkError = String(cause); }
|
||||
}
|
||||
</script>
|
||||
<div class="md-editor">
|
||||
<div class="md-tabs" role="group" aria-label={de ? "Editor-Ansicht" : "Editor view"}>
|
||||
<button type="button" class:active={!preview} aria-pressed={!preview} onclick={() => { preview = false; }}>{de ? "Schreiben" : "Write"}</button>
|
||||
<button type="button" class:active={preview} aria-pressed={preview} onclick={() => { preview = true; }}>{de ? "Vorschau" : "Preview"}</button>
|
||||
</div>
|
||||
<div class="md-toolbar" role="group" aria-label={de ? "Markdown-Formatierung" : "Markdown formatting"}>
|
||||
{#each tools as tool}<button type="button" class:group-start={tool.group} title={tool.label} aria-label={tool.label} disabled={disabled || busy || preview} onmousedown={event => event.preventDefault()} onclick={() => format(tool.prefix,tool.suffix,tool.line)}><svelte:component this={tool.icon} size={15} strokeWidth={1.7} /></button>{/each}
|
||||
<span class="md-spacer"></span>
|
||||
<button type="button" title={de ? "Monospace-Schrift" : "Monospace font"} aria-label={de ? "Monospace-Schrift" : "Monospace font"} aria-pressed={monospace} onclick={() => { monospace = !monospace; }}><CaseSensitive size={16} /></button>
|
||||
<button type="button" title={de ? "Zeilenumbruch" : "Line wrap"} aria-label={de ? "Zeilenumbruch" : "Line wrap"} aria-pressed={wrap} onclick={() => { wrap = !wrap; }}><WrapText size={16} /></button>
|
||||
</div>
|
||||
{#if preview}
|
||||
<!-- Rendered Markdown is restricted to safe content tags and sanitized before insertion. -->
|
||||
<div class="md-preview" role="region" aria-label={de ? "Kommentarvorschau" : "Comment preview"} use:previewLinks>
|
||||
{#if value.trim()}{@html rendered}{:else}<span>{de ? "Noch nichts zum Anzeigen." : "Nothing to preview yet."}</span>{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<textarea bind:this={textarea} bind:value class:monospace wrap={wrap ? "soft" : "off"} maxlength="100000" rows="5" disabled={disabled || busy} aria-label={de ? "Kommentar schreiben" : "Write comment"} placeholder={de ? "Kommentar hinzufügen …" : "Leave a comment …"} onkeydown={keyboard}></textarea>
|
||||
{/if}
|
||||
{#if linkError}<small role="alert">{linkError}</small>{/if}
|
||||
<div class="md-footer"><small>Markdown <span>· Ctrl/⌘ + Enter</span></small><button class="md-send" type="button" disabled={!value.trim() || disabled || busy} onclick={() => onSend()}><Send size={13} />{busy ? (de ? "Wird gesendet …" : "Sending …") : (de ? "Kommentar senden" : "Post comment")}</button></div>
|
||||
</div>
|
||||
<style>
|
||||
.md-editor{width:100%;min-width:0;color:var(--color-ink);font:400 12px/1.5 var(--font-sans)}
|
||||
.md-editor button{display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:0;padding:0;border:0;border-radius:0;color:var(--color-ink-muted);background:transparent;font:inherit;cursor:pointer;box-shadow:none}
|
||||
.md-editor button:disabled{opacity:.4;cursor:default}.md-editor button:focus-visible{outline:2px solid var(--color-accent);outline-offset:1px}.md-editor button:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-hover)}
|
||||
.md-tabs{display:flex;border-bottom:1px solid var(--color-border);gap:1px}.md-tabs button{position:relative;height:36px;padding:0 12px;border:1px solid transparent;border-bottom:0;font-size:12px}.md-tabs button.active{border-color:var(--color-border);color:var(--color-ink);background:var(--app-bg);font-weight:600}.md-tabs button.active:after{content:"";position:absolute;bottom:-1px;left:0;right:0;height:1px;background:var(--app-bg)}
|
||||
.md-toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:2px;padding:10px 2px;row-gap:6px}.md-toolbar button{width:25px;height:25px;flex-shrink:0}.md-toolbar .group-start{border-left:1px solid var(--color-border);margin-left:4px;padding-left:5px;width:30px}.md-spacer{flex:1}.md-toolbar button[aria-pressed=true]{color:var(--color-accent)}
|
||||
.md-editor textarea,.md-preview{box-sizing:border-box;width:100%;min-height:132px;max-height:300px;margin:0;padding:11px 12px;border:1px solid var(--color-border-input);border-radius:3px;color:var(--color-ink);background:var(--app-input-bg);font:400 12px/1.6 var(--font-sans);overflow:auto;resize:vertical}.md-editor textarea:focus{outline:none;box-shadow:none;border-color:var(--color-accent)}.md-editor textarea.monospace{font-family:var(--font-mono)}.md-editor textarea::placeholder{color:var(--color-ink-faint)}.md-preview{overflow-wrap:anywhere;resize:none}.md-preview>span{color:var(--color-ink-faint)}
|
||||
.md-preview :global(p){margin:0 0 10px;white-space:normal}.md-preview :global(h1),.md-preview :global(h2),.md-preview :global(h3){margin:12px 0 8px;line-height:1.3}.md-preview :global(h1){font-size:22px}.md-preview :global(h2){font-size:18px}.md-preview :global(h3){font-size:15px}.md-preview :global(ul){list-style:disc;padding-left:24px}.md-preview :global(ol){list-style:decimal;padding-left:24px}.md-preview :global(blockquote){border-left:3px solid var(--color-accent);padding-left:12px;margin:10px 0;color:var(--color-ink-muted)}.md-preview :global(pre){overflow:auto;background:var(--color-surface);padding:10px}.md-preview :global(code){font-family:var(--font-mono)}.md-preview :global(table){border-collapse:collapse;max-width:100%;margin:10px 0}.md-preview :global(th),.md-preview :global(td){border:1px solid var(--color-border);padding:5px 9px;font:inherit}.md-preview :global(a){color:var(--color-accent);text-decoration:underline}.md-preview :global(input[type=checkbox]){width:auto;margin-right:6px}
|
||||
.md-footer{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:9px}.md-footer small{font-size:10px;color:var(--color-ink-faint)}.md-editor .md-send{height:32px;padding:0 10px;border:1px solid var(--color-accent);color:var(--color-accent);font-size:11px;white-space:nowrap}.md-editor .md-send:hover:not(:disabled){background:color-mix(in srgb,var(--color-accent) 12%,transparent)}
|
||||
@media(max-width:420px){.md-footer small span{display:none}}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import SelectMenu from "./SelectMenu.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { GitPullRequest, X, LoaderCircle, ArrowRight } from "@lucide/svelte";
|
||||
import { createIntegrationReviewRequest, listIntegrationRepositories, listIntegrationRepositoryBranches, listRemotes, listBranches } from "../git";
|
||||
import { integrationCredentialKey } from "../integrations";
|
||||
import type { GitIntegrationSource, GitIntegrationRepository, IntegrationReviewRequest, StoredCredential } from "../types";
|
||||
|
||||
let { source, de, localRepositoryPath = "", loadCredential, onClose, onCreated }: {
|
||||
source: GitIntegrationSource; de: boolean; localRepositoryPath?: string;
|
||||
loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||
onClose: () => void; onCreated: (request: IntegrationReviewRequest) => void;
|
||||
} = $props();
|
||||
let dialog: HTMLDialogElement;
|
||||
let repositories = $state<GitIntegrationRepository[]>([]);
|
||||
let repositoryId = $state("");
|
||||
let sourceBranch = $state("");
|
||||
let targetBranch = $state("");
|
||||
let branches = $state<string[]>([]);
|
||||
let defaultBranch = $state("");
|
||||
let branchesLoading = $state(false);
|
||||
let branchError = $state("");
|
||||
let branchGeneration = 0;
|
||||
const branchOptions = $derived(branches.map(name => ({value:name,label:name,group:name === defaultBranch ? (de ? "Standardbranch" : "Default branch") : undefined})));
|
||||
$effect(() => {
|
||||
const repository = repositories.find(item => item.id === repositoryId);
|
||||
void loadRepositoryBranches(repository);
|
||||
});
|
||||
async function loadRepositoryBranches(repository?: GitIntegrationRepository) {
|
||||
const generation = ++branchGeneration;
|
||||
branches = []; sourceBranch = ""; targetBranch = ""; defaultBranch = ""; branchError = "";
|
||||
branchesLoading = !!repository;
|
||||
if (!repository) return;
|
||||
try {
|
||||
const credential = await loadCredential(integrationCredentialKey(source.provider, source.accountId));
|
||||
if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
|
||||
const result = await listIntegrationRepositoryBranches(source.provider, source.baseUrl, credential.username, credential.password, repository);
|
||||
if (generation !== branchGeneration) return;
|
||||
branches = result.branches;
|
||||
defaultBranch = result.defaultBranch;
|
||||
targetBranch = branches.includes(defaultBranch) ? defaultBranch : "";
|
||||
// Only suggest a local branch when its remote matches this exact repository.
|
||||
if (localRepositoryPath) {
|
||||
try {
|
||||
const remotes = await listRemotes(localRepositoryPath);
|
||||
const clean = (url: string) => url.trim().replace(/\.git\/?$/, "").replace(/\/$/, "");
|
||||
const matches = remotes.some(remote => [repository.cloneUrl, repository.sshUrl].filter(Boolean).some(url => clean(url) === clean(remote.fetch_url) || clean(url) === clean(remote.push_url)));
|
||||
if (matches) {
|
||||
const localBranches = await listBranches(localRepositoryPath);
|
||||
if (generation !== branchGeneration) return;
|
||||
const current = localBranches.find(branch => branch.current)?.name;
|
||||
if (current && branches.includes(current) && current !== targetBranch) sourceBranch = current;
|
||||
}
|
||||
} catch { /* Remote branch selection remains available without local metadata. */ }
|
||||
}
|
||||
if (generation !== branchGeneration) return;
|
||||
if (!sourceBranch && branches.length === 2 && targetBranch) sourceBranch = branches.find(name => name !== targetBranch) ?? "";
|
||||
} catch (cause) {
|
||||
if (generation === branchGeneration) branchError = String(cause);
|
||||
} finally { if (generation === branchGeneration) branchesLoading = false; }
|
||||
}
|
||||
let title = $state("");
|
||||
let description = $state("");
|
||||
let loading = $state(true);
|
||||
let busy = $state(false);
|
||||
let error = $state("");
|
||||
const mr = $derived(source.provider.startsWith("gitlab"));
|
||||
const heading = $derived(de ? (mr ? "Merge Request erstellen" : "Pull Request erstellen") : (mr ? "Create merge request" : "Create pull request"));
|
||||
const normalizeBranch = (branch: string) => branch.trim().replace(/^refs\/heads\//, "");
|
||||
const sameBranch = $derived(!!sourceBranch.trim() && normalizeBranch(sourceBranch) === normalizeBranch(targetBranch));
|
||||
const valid = $derived(!branchesLoading && !branchError && branches.includes(sourceBranch) && branches.includes(targetBranch) && repositoryId && title.trim() && sourceBranch.trim() && targetBranch.trim() && !sameBranch);
|
||||
|
||||
onMount(() => { dialog.showModal(); void loadRepositories(); });
|
||||
async function loadRepositories() {
|
||||
loading = true; error = "";
|
||||
try {
|
||||
repositories = (await listIntegrationRepositories(source.provider, source.baseUrl, source.accountId)).sort((a,b) => a.fullName.localeCompare(b.fullName));
|
||||
if (repositories.length === 1) repositoryId = repositories[0].id;
|
||||
} catch (cause) { error = String(cause); }
|
||||
finally { loading = false; }
|
||||
}
|
||||
async function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (busy || !valid) return;
|
||||
const repository = repositories.find(item => item.id === repositoryId);
|
||||
if (!repository) return;
|
||||
busy = true; error = "";
|
||||
try {
|
||||
const credential = await loadCredential(integrationCredentialKey(source.provider, source.accountId));
|
||||
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
|
||||
const request = await createIntegrationReviewRequest(source.provider, source.baseUrl, credential.username, credential.password, repository, normalizeBranch(sourceBranch), normalizeBranch(targetBranch), title.trim(), description);
|
||||
onCreated(request);
|
||||
} catch (cause) { error = cause instanceof Error ? cause.message : String(cause); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} aria-labelledby="create-review-title" oncancel={(event) => { event.preventDefault(); if (!busy) onClose(); }} onclick={(event) => { if (event.target === dialog && !busy) { const rect = dialog.getBoundingClientRect(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) onClose(); } }}>
|
||||
<form onsubmit={submit}>
|
||||
<header><div class="heading-icon"><GitPullRequest size={19} /></div><div><h2 id="create-review-title">{heading}</h2><p>{source.label}</p></div><button class="close" type="button" aria-label={de ? "Schließen" : "Close"} disabled={busy} onclick={onClose}><X size={18}/></button></header>
|
||||
<div class="body">
|
||||
{#if error}<div class="error" role="alert">{error}{#if !repositories.length && !loading}<button type="button" onclick={loadRepositories}>{de ? "Erneut laden" : "Retry"}</button>{/if}</div>{/if}
|
||||
<div class="repository-field"><div class="field-heading"><span>Repository</span><small>{loading ? "…" : `${repositories.length} ${de ? "verfügbar" : "available"}`}</small></div>
|
||||
<SelectMenu value={repositoryId} options={repositories.map(repository => ({value:repository.id,label:repository.fullName}))} disabled={loading || busy} ariaLabel="Repository" placeholder={loading ? (de ? "Repositories werden geladen …" : "Loading repositories…") : (de ? "Repository auswählen" : "Select repository")} searchable searchPlaceholder={de ? "Repositories durchsuchen …" : "Search repositories…"} emptyText={de ? "Keine passenden Repositories" : "No matching repositories"} onChange={value => repositoryId = value}/>
|
||||
</div>
|
||||
{#if !loading && !error && !repositories.length}<p>{de ? "Keine Repositories für diese Integration gefunden." : "No repositories found for this integration."}</p>{/if}
|
||||
<div class="branches">
|
||||
<div class="repository-field"><span>{de ? "Quellbranch" : "Source branch"}</span><SelectMenu value={sourceBranch} options={branchOptions.map(option => ({...option,disabled:option.value === targetBranch}))} disabled={busy || branchesLoading || !repositoryId} ariaLabel={de ? "Quellbranch" : "Source branch"} placeholder={branchesLoading ? (de ? "Lädt …" : "Loading…") : (de ? "Quellbranch auswählen" : "Select source branch")} searchable searchPlaceholder={de ? "Branches durchsuchen …" : "Search branches…"} emptyText={de ? "Keine Branches gefunden" : "No branches found"} onChange={value => sourceBranch = value}/></div>
|
||||
<ArrowRight size={16}/>
|
||||
<div class="repository-field"><span>{de ? "Zielbranch" : "Target branch"}</span><SelectMenu value={targetBranch} options={branchOptions.map(option => ({...option,disabled:option.value === sourceBranch}))} disabled={busy || branchesLoading || !repositoryId} ariaLabel={de ? "Zielbranch" : "Target branch"} placeholder={branchesLoading ? (de ? "Lädt …" : "Loading…") : (de ? "Zielbranch auswählen" : "Select target branch")} searchable searchPlaceholder={de ? "Branches durchsuchen …" : "Search branches…"} emptyText={de ? "Keine Branches gefunden" : "No branches found"} onChange={value => targetBranch = value}/></div>
|
||||
</div>
|
||||
{#if branchError}<div class="error" role="alert">{branchError}<button type="button" onclick={() => loadRepositoryBranches(repositories.find(item => item.id === repositoryId))}>{de ? "Erneut laden" : "Retry"}</button></div>{:else if repositoryId && !branchesLoading && !branches.length}<p>{de ? "Dieses Repository hat noch keine Branches." : "This repository has no branches yet."}</p>{/if}
|
||||
{#if sameBranch}<p class="validation">{de ? "Quell- und Zielbranch müssen unterschiedlich sein." : "Source and target branches must be different."}</p>{/if}
|
||||
<p class="hint">{de ? "Beide Branches müssen bereits in diesem Repository gepusht sein." : "Both branches must already be pushed to this repository."}</p>
|
||||
<label>{de ? "Titel" : "Title"}<input bind:value={title} disabled={busy} placeholder={de ? "Was ändert sich?" : "What is changing?"} required /></label>
|
||||
<label>{de ? "Beschreibung" : "Description"}<textarea bind:value={description} disabled={busy} rows="7" placeholder={de ? "Beschreibe deine Änderungen … (Markdown unterstützt)" : "Describe your changes… (Markdown supported)"}></textarea></label>
|
||||
</div>
|
||||
<footer><button type="button" disabled={busy} onclick={onClose}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit" disabled={busy || loading || !valid}>{#if busy}<LoaderCircle class="spin" size={15}/>{:else}<GitPullRequest size={15}/>{/if}{busy ? (de ? "Wird erstellt …" : "Creating…") : heading}</button></footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.repository-field{min-width:0;display:grid;gap:9px}.field-heading{display:flex;align-items:center;justify-content:space-between;font-weight:600}.field-heading small{font-size:10px;font-weight:400;color:var(--color-ink-faint)}
|
||||
dialog{margin:auto;width:min(640px,calc(100vw - 40px));max-height:calc(100vh - 48px);padding:0;border:1px solid var(--color-border-subtle);border-radius:14px;background:var(--app-dialog-bg);color:var(--color-ink);box-shadow:var(--app-dialog-shadow);font-family:inherit;font-size:12px;overflow:auto}dialog::backdrop{background:#0007;backdrop-filter:blur(3px)}header{display:flex;align-items:center;gap:12px;padding:22px 26px;background:var(--app-dialog-chrome);border-bottom:1px solid var(--color-border-subtle)}h2{margin:0;font-size:16px;font-weight:650}p{margin:5px 0 0;color:var(--color-ink-muted)}.heading-icon{display:grid;place-items:center;width:38px;height:38px;border-radius:10px;background:color-mix(in srgb,var(--color-accent) 12%,transparent);color:var(--color-accent)}button,input,textarea{font:inherit}button{display:inline-flex;justify-content:center;align-items:center;gap:8px;border:1px solid var(--color-border-subtle);border-radius:7px;padding:9px 13px;background:var(--color-surface);color:var(--color-ink);cursor:pointer}button:disabled{opacity:.5;cursor:default}.close{margin-left:auto;border:0;padding:6px}.body{display:grid;gap:20px;padding:24px 26px}label{display:grid;gap:8px;font-weight:600;min-width:0}input,textarea{box-sizing:border-box;width:100%;padding:10px 11px;border:1px solid var(--color-border-subtle);border-radius:7px;background:var(--app-bg);color:var(--color-ink);font-weight:400}input:focus,textarea:focus{outline:2px solid var(--color-accent);outline-offset:1px}textarea{resize:vertical;min-height:110px;line-height:1.6}.branches{padding:16px;background:color-mix(in srgb,var(--color-accent) 3%,var(--app-bg));border:1px solid var(--color-border-subtle);display:grid;grid-template-columns:minmax(0,1fr) 16px minmax(0,1fr);gap:12px;align-items:end}.branches>:global(svg){margin-bottom:12px;color:var(--color-accent)}.hint{margin-top:-8px;font-size:11px;line-height:1.5}.error,.validation{color:var(--color-danger,#e76767);line-height:1.5}.error{padding:12px;border-radius:7px;background:color-mix(in srgb,var(--color-danger,#e76767) 9%,transparent);overflow-wrap:anywhere}.error button{margin-left:8px}footer{display:flex;justify-content:flex-end;gap:9px;padding:16px 26px;background:var(--app-dialog-chrome);border-top:1px solid var(--color-border-subtle)}.primary{background:var(--color-accent);border-color:var(--color-accent);color:white}.primary:enabled:hover{filter:brightness(1.08)}:global(.spin){animation:rotate 1s linear infinite}@keyframes rotate{to{transform:rotate(360deg)}}
|
||||
</style>
|
||||
@@ -2116,7 +2116,8 @@
|
||||
.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 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-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,280 @@
|
||||
<script context="module" lang="ts">
|
||||
const boardCache = new Map<string, IntegrationBoard>();
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { Columns3, ExternalLink, Link2, RefreshCw, Search, X } from "@lucide/svelte";
|
||||
import SelectMenu from "./SelectMenu.svelte";
|
||||
import IssueLabels from "./IssueLabels.svelte";
|
||||
import IssueAssignees from "./IssueAssignees.svelte";
|
||||
import { issueTone } from "../issuePresentation";
|
||||
import { getIntegrationBoard, moveIntegrationBoardCard, listIntegrationBoards, openInBrowser } from "../git";
|
||||
import { integrationCredentialKey } from "../integrations";
|
||||
import type { GitIntegrationSource, IntegrationBoard, IntegrationBoardCard, IntegrationBoardReference, StoredCredential } from "../types";
|
||||
|
||||
export let source: GitIntegrationSource;
|
||||
export let language: "de" | "en" = "en";
|
||||
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||
const preferenceKey = `gitty.board.v1:${JSON.stringify([source.id, source.baseUrl])}`;
|
||||
const directoryKey = `${preferenceKey}:directory`;
|
||||
let savedBoards: IntegrationBoardReference[] = [];
|
||||
let discovering = false;
|
||||
let discoveryWarnings: string[] = [];
|
||||
let destroyed = false;
|
||||
let connectOpen = true;
|
||||
let boardUrl = "";
|
||||
let activeUrl = "";
|
||||
let board: IntegrationBoard | null = null;
|
||||
let loading = false;
|
||||
let moving = false;
|
||||
let dragged: { cardId: string; columnId: string } | null = null;
|
||||
let dropColumn = "";
|
||||
let moveMessage = "";
|
||||
$: canMove = board?.columns.some(column => !!column.moveTarget) ?? false;
|
||||
let error = "";
|
||||
let query = "";
|
||||
let repositoryFilter = "";
|
||||
let selected: IntegrationBoardCard | null = null;
|
||||
let selectedColumnId = "";
|
||||
let generation = 0;
|
||||
$: de = language === "de";
|
||||
$: normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
$: repositoryOptions = [{ value: "", label: de ? "Alle Repositories" : "All repositories" }, ...[...new Set(board?.columns.flatMap(column => column.cards.map(card => card.repository)).filter(Boolean) ?? [])].sort().map(value => ({ value, label: value }))];
|
||||
$: columns = board?.columns.map(column => ({ ...column, cards: column.cards.filter(card => (!repositoryFilter || card.repository === repositoryFilter) &&
|
||||
`${card.title} ${card.number} ${card.repository} ${card.labels.join(" ")} ${card.assignees.join(" ")} ${card.lane}`.toLocaleLowerCase().includes(normalizedQuery)) })) ?? [];
|
||||
$: placeholder = source.provider === "github" ? "https://github.com/orgs/team/projects/1/views/1"
|
||||
: source.provider.startsWith("gitlab") ? `${source.baseUrl}/group/project/-/boards/1`
|
||||
: source.provider === "gitea" ? `${source.baseUrl}/owner/repo/projects/1`
|
||||
: `${source.baseUrl}/Project/_boards/board/t/Team/Stories`;
|
||||
|
||||
onMount(() => {
|
||||
try { boardUrl = localStorage.getItem(preferenceKey) ?? ""; } catch { /* Optional preference. */ }
|
||||
try {
|
||||
const saved: unknown = JSON.parse(localStorage.getItem(directoryKey) ?? "[]");
|
||||
if (Array.isArray(saved)) savedBoards = saved.filter(item => item && typeof item.title === "string" && typeof item.webUrl === "string" && typeof item.scope === "string" && validBoardUrl(item.webUrl));
|
||||
} catch { /* Invalid optional preference. */ }
|
||||
if (boardUrl && validBoardUrl(boardUrl)) rememberBoard({ title: boardUrl, webUrl: boardUrl, scope: "" });
|
||||
void discoverBoards();
|
||||
if (boardUrl) { connectOpen = false; void loadBoard(boardUrl); }
|
||||
const timer = window.setInterval(() => { if (activeUrl && !loading && !moving) void loadBoard(activeUrl); }, 180_000);
|
||||
return () => window.clearInterval(timer);
|
||||
});
|
||||
onDestroy(() => { generation++; destroyed = true; });
|
||||
|
||||
function validBoardUrl(url: string) {
|
||||
try { const parsed = new URL(url); return parsed.origin === new URL(source.baseUrl).origin && !parsed.username && !parsed.password; } catch { return false; }
|
||||
}
|
||||
|
||||
function rememberBoard(item: IntegrationBoardReference) {
|
||||
if (!validBoardUrl(item.webUrl)) return;
|
||||
savedBoards = [...savedBoards.filter(board => board.webUrl !== item.webUrl), item];
|
||||
try { localStorage.setItem(directoryKey, JSON.stringify(savedBoards)); } catch { /* Optional preference. */ }
|
||||
}
|
||||
|
||||
function boardError(message: string) {
|
||||
if (de && message.includes("publishes no Projects/Columns API")) return "Dieser Gitea-Server bietet keine Projects-/Columns-API an. Boards sind im Browser verfügbar, können aber mit dem API-Token weder automatisch gefunden noch importiert werden. Bitte das Original-Board öffnen.";
|
||||
if (de && message.includes("Gitea returned 404")) return "Gitea meldet 404. Bitte Board-Link und Token-Zugriff prüfen. Möglicherweise unterstützt der Server die Projects-/Columns-API nicht.";
|
||||
return message;
|
||||
}
|
||||
|
||||
async function discoverBoards() {
|
||||
if (discovering) return;
|
||||
discovering = true;
|
||||
discoveryWarnings = [];
|
||||
try {
|
||||
const credential = await timeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000);
|
||||
if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
|
||||
const directory = await timeout(listIntegrationBoards(source.provider, source.baseUrl, credential.username, credential.password), 75_000);
|
||||
if (destroyed) return;
|
||||
for (const item of directory.boards) rememberBoard(item);
|
||||
discoveryWarnings = directory.warnings.map(boardError);
|
||||
} catch (cause) {
|
||||
if (!destroyed) discoveryWarnings = [boardError(String(cause))];
|
||||
} finally { if (!destroyed) discovering = false; }
|
||||
}
|
||||
|
||||
async function loadBoard(url: string, collapseEditor = false) {
|
||||
if (moving) return;
|
||||
const requestedUrl = url.trim();
|
||||
if (!requestedUrl) return;
|
||||
const requestGeneration = ++generation;
|
||||
if (requestedUrl !== activeUrl) {
|
||||
selected = null;
|
||||
repositoryFilter = "";
|
||||
board = boardCache.get(`${preferenceKey}:${requestedUrl}`) ?? null;
|
||||
}
|
||||
activeUrl = requestedUrl;
|
||||
loading = true;
|
||||
error = "";
|
||||
try {
|
||||
const parsed = new URL(requestedUrl);
|
||||
if (parsed.origin !== new URL(source.baseUrl).origin || parsed.username || parsed.password) {
|
||||
throw new Error(de ? "Der Board-Link muss zur ausgewählten Integration gehören." : "The board link must belong to the selected integration.");
|
||||
}
|
||||
if (!savedBoards.some(item => item.webUrl === requestedUrl)) rememberBoard({ title: requestedUrl, webUrl: requestedUrl, scope: "" });
|
||||
const credential = await timeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000);
|
||||
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token stored for this integration.");
|
||||
const result = await timeout(getIntegrationBoard(source.provider, source.baseUrl, credential.username, credential.password, requestedUrl), 70_000);
|
||||
if (requestGeneration !== generation) return;
|
||||
board = result;
|
||||
rememberBoard({ title: result.title, webUrl: requestedUrl, scope: savedBoards.find(item => item.webUrl === requestedUrl)?.scope ?? "" });
|
||||
if (collapseEditor && boardUrl.trim() === requestedUrl) connectOpen = false;
|
||||
boardCache.set(`${preferenceKey}:${requestedUrl}`, result);
|
||||
selected = selected ? result.columns.flatMap(column => column.cards).find(card => card.id === selected!.id) ?? null : null;
|
||||
try { localStorage.setItem(preferenceKey, requestedUrl); } catch { /* Keep the board in memory. */ }
|
||||
} catch (cause) {
|
||||
if (requestGeneration === generation) error = boardError(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
if (requestGeneration === generation) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function moveCard(cardId: string, from: string, to: string) {
|
||||
if (moving || loading || from === to || !board?.columns.find(column => column.id === to)?.moveTarget) return;
|
||||
const previousBoard = board;
|
||||
const card = previousBoard.columns.find(column => column.id === from)?.cards.find(card => card.id === cardId);
|
||||
if (!card) return;
|
||||
const previousSelectedColumn = selected?.id === cardId ? selectedColumnId : from;
|
||||
const url = activeUrl;
|
||||
const requestGeneration = generation;
|
||||
// Keep the original arrays intact for an exact rollback, including card order.
|
||||
board = {
|
||||
...previousBoard,
|
||||
columns: previousBoard.columns.map(column => {
|
||||
const remove = column.id === from || (source.provider.startsWith("gitlab") && (to === "open" || to === "closed"));
|
||||
const cards = remove ? column.cards.filter(item => item.id !== cardId) : column.cards;
|
||||
return { ...column, cards: column.id === to && !cards.some(item => item.id === cardId) ? [...cards, card] : cards };
|
||||
}),
|
||||
};
|
||||
if (selected?.id === cardId) selectedColumnId = to;
|
||||
moving = true;
|
||||
dragged = null;
|
||||
dropColumn = "";
|
||||
error = "";
|
||||
moveMessage = de ? "Verschiebung wird gespeichert …" : "Saving move …";
|
||||
try {
|
||||
const credential = await timeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000);
|
||||
if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
|
||||
await moveIntegrationBoardCard(source.provider, source.baseUrl, credential.username, credential.password, url, cardId, from, to);
|
||||
// A successful mutation invalidates the cached snapshot, even if reloading fails.
|
||||
boardCache.delete(`${preferenceKey}:${url}`);
|
||||
if (destroyed || generation !== requestGeneration) return;
|
||||
boardCache.set(`${preferenceKey}:${url}`, board!);
|
||||
moveMessage = de ? "Verschoben. Board wird aktualisiert …" : "Moved. Refreshing board …";
|
||||
moving = false;
|
||||
if (selected?.id === cardId) selectedColumnId = to;
|
||||
await loadBoard(url);
|
||||
if (!destroyed) moveMessage = error ? (de ? "Verschoben; der angezeigte Stand konnte noch nicht aktualisiert werden." : "Moved; the displayed board could not be refreshed yet.") : (de ? "Karte verschoben." : "Card moved.");
|
||||
} catch (cause) {
|
||||
if (!destroyed && generation === requestGeneration) {
|
||||
board = previousBoard;
|
||||
if (selected?.id === cardId) selectedColumnId = previousSelectedColumn;
|
||||
error = `${de ? "Verschieben nicht bestätigt. Bitte aktualisieren und Schreibrechte prüfen." : "Move not confirmed. Refresh and check write access."} ${String(cause)}`;
|
||||
moveMessage = "";
|
||||
}
|
||||
} finally { if (!destroyed) moving = false; }
|
||||
}
|
||||
|
||||
async function timeout<T>(promise: Promise<T>, duration: number): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
try {
|
||||
return await Promise.race([promise, new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(de ? "Das Board hat nicht rechtzeitig geantwortet." : "The board did not respond in time.")), duration);
|
||||
})]);
|
||||
} finally { clearTimeout(timer!); }
|
||||
}
|
||||
|
||||
async function openOriginal(url: string) {
|
||||
try { await openInBrowser(url); } catch (cause) { error = String(cause); }
|
||||
}
|
||||
|
||||
function notice(text: string) {
|
||||
if (!de) return text;
|
||||
if (text.startsWith("Team scope")) return "Team-Zuordnung, Spalten, geteilte Spalten und Swimlane-Namen werden übernommen. Temporäre Browser-Filter und das Alterslimit für abgeschlossene Karten werden nicht angewendet.";
|
||||
if (text.startsWith("Saved board scope")) return "Die gespeicherte Board-Konfiguration wird angewendet. Temporäre Suchfilter aus dem Browser werden nicht übernommen.";
|
||||
if (text.startsWith("Archived cards")) return "Archivierte Karten werden ausgeblendet. Die Spalten entsprechen den Auswahloptionen des Board-Feldes.";
|
||||
if (text.startsWith("The saved GitHub view filter")) return `Der gespeicherte GitHub-Ansichtsfilter wird nicht angewendet. Alle nicht archivierten Projektkarten erscheinen in ihren ursprünglichen Spalten. ${text.split(": ")[1]?.split(". All non-archived")[0] ?? ""}`;
|
||||
return text;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={event => { if (event.key === "Escape") selected = null; }} />
|
||||
|
||||
<section class="board-view" aria-label={de ? "Kanban-Board" : "Kanban board"}>
|
||||
<div class="board-directory">
|
||||
<SelectMenu class="board-select" value={activeUrl} options={[{ value: "", label: de ? "Board auswählen" : "Select board" }, ...savedBoards.map(item => ({ value: item.webUrl, label: `${item.scope ? `${item.scope} · ` : ""}${item.title}` }))]} ariaLabel={de ? "Board auswählen" : "Select board"} onChange={value => { if (value && !moving) { boardUrl = value; void loadBoard(value, true); } }} />
|
||||
<button class="workspace-button" disabled={discovering} onclick={discoverBoards}><RefreshCw size={14} />{de ? "Boards suchen" : "Find boards"}</button>
|
||||
<small aria-live="polite">{discovering ? (de ? "Boards werden im Hintergrund gesucht …" : "Finding boards in background …") : `${savedBoards.length} Boards`}</small>
|
||||
{#if !board && boardUrl && validBoardUrl(boardUrl)}<button class="workspace-button" onclick={() => openOriginal(boardUrl)}><ExternalLink size={14} />{de ? "Original öffnen" : "Open original"}</button>{/if}
|
||||
</div>
|
||||
{#if discoveryWarnings.length}<details class="board-discovery-notice" open={!savedBoards.length}><summary>{de ? "Hinweise zur Board-Suche" : "Board discovery notices"} ({discoveryWarnings.length})</summary>{#each discoveryWarnings as warning}<p>{warning}</p>{/each}</details>{/if}
|
||||
{#if board}
|
||||
<div class="board-header">
|
||||
<div class="board-title"><h2>{board.title}</h2><small>{source.label} <span>·</span> {canMove ? (de ? "Karten zwischen Spalten verschieben" : "Move cards between columns") : (de ? "Lesende Ansicht" : "Read-only view")}{#if loading} <span>·</span> {de ? "Aktualisierung läuft …" : "Updating …"}{/if}</small></div>
|
||||
<div class="board-tools">
|
||||
<SelectMenu class="repository-select" value={repositoryFilter} options={repositoryOptions} ariaLabel={de ? "Repository filtern" : "Filter repository"} onChange={value => { repositoryFilter = value; selected = null; }} />
|
||||
<label class="workspace-search"><Search size={15} /><input bind:value={query} aria-label={de ? "Board durchsuchen" : "Search board"} placeholder={de ? "Karten suchen …" : "Search cards …"} /></label>
|
||||
<button class="workspace-button" class:active={connectOpen} aria-expanded={connectOpen} onclick={() => { connectOpen = !connectOpen; }}><Link2 size={14} />{de ? "Board wechseln" : "Change board"}</button>
|
||||
<button class="workspace-button icon-button" disabled={loading || moving} onclick={() => loadBoard(activeUrl)} aria-label={de ? "Board aktualisieren" : "Refresh board"}><RefreshCw size={15} /></button>
|
||||
<button class="workspace-button" onclick={() => openOriginal(board!.webUrl)}><ExternalLink size={14} />{de ? "Original öffnen" : "Open original"}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if connectOpen || !board}
|
||||
<form class="board-connect" onsubmit={event => { event.preventDefault(); void loadBoard(boardUrl, true); }}>
|
||||
<label for="native-board-url">{de ? "Original-Board" : "Original board"}</label>
|
||||
<div class="board-connect-fields"><input id="native-board-url" type="url" bind:value={boardUrl} {placeholder} required aria-label={de ? "Board-Link" : "Board URL"} /><button class="workspace-button" type="submit" disabled={moving || !boardUrl.trim()}>{de ? "Board übernehmen" : "Load board"}</button></div>
|
||||
</form>
|
||||
{/if}
|
||||
{#if moveMessage}<p class="board-notice" role="status">{moveMessage}</p>{/if}
|
||||
{#if error}<div class="workspace-notice error" role="alert">{error}{#if board}<p>{de ? "Der zuletzt geladene Stand bleibt sichtbar." : "The last loaded board remains visible."}</p>{/if}</div>{/if}
|
||||
{#if board}
|
||||
{#if board.notice}<p class="board-notice">{notice(board.notice)}</p>{/if}
|
||||
<div class="board-body">
|
||||
<div class="board-columns" aria-label={de ? "Board-Spalten" : "Board columns"}>
|
||||
{#each columns as column (column.id)}
|
||||
<section class="board-column" class:drop-target={dropColumn === column.id} aria-label={column.title}
|
||||
ondragover={event => { if (dragged && column.moveTarget && !moving && !loading && dragged.columnId !== column.id) { event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; dropColumn = column.id; } }}
|
||||
ondragleave={event => { if (!(event.relatedTarget instanceof Node) || !event.currentTarget.contains(event.relatedTarget)) dropColumn = ""; }}
|
||||
ondrop={event => { event.preventDefault(); if (dragged) void moveCard(dragged.cardId, dragged.columnId, column.id); dropColumn = ""; }}>
|
||||
<header><h3><span class="column-dot" data-tone={issueTone(column.title)}></span>{column.title}</h3><span class="column-count">{column.cards.length}{#if column.limit}<span class="wip-limit"> · WIP {column.limit}</span>{/if}</span></header>
|
||||
<div class="column-cards">
|
||||
{#each column.cards as card (`${column.id}:${card.id}`)}
|
||||
<button class="board-card" draggable={!!column.moveTarget && !moving && !loading}
|
||||
ondragstart={event => { if (moving || loading || !column.moveTarget) { event.preventDefault(); return; } dragged = { cardId: card.id, columnId: column.id }; if (event.dataTransfer) { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", card.id); } }}
|
||||
ondragend={() => { dragged = null; dropColumn = ""; }} class:chosen={selected?.id === card.id} aria-pressed={selected?.id === card.id} onclick={() => { selected = card; selectedColumnId = column.id; }}>
|
||||
{#if card.repository || card.number}<small class="card-reference">{card.repository}<span class="issue-number">{card.number ? `#${card.number}` : ""}</span></small>{/if}
|
||||
<strong>{card.title}</strong>
|
||||
{#if card.lane}<span class="lane">{card.lane}</span>{/if}
|
||||
<IssueLabels labels={card.labels} />
|
||||
{#if card.assignees.length}<IssueAssignees names={card.assignees} />{/if}
|
||||
</button>
|
||||
{:else}<p class="column-empty">{de ? "Keine Karten" : "No cards"}</p>{/each}
|
||||
</div>
|
||||
</section>
|
||||
{:else}<p class="column-empty">{de ? "Dieses Board hat keine Spalten." : "This board has no columns."}</p>{/each}
|
||||
</div>
|
||||
{#if selected}
|
||||
<aside class="issue-inspector board-inspector" aria-label={de ? "Kartendetails" : "Card details"}>
|
||||
<div class="inspector-top"><span>{selected.repository} <span class="issue-number">{selected.number ? `#${selected.number}` : ""}</span></span><button class="workspace-button close-button" onclick={() => { selected = null; }} aria-label={de ? "Kartendetails schließen" : "Close card details"}><X size={16} /></button></div>
|
||||
<h2>{selected.title}</h2>
|
||||
{#if canMove}
|
||||
{@const currentColumn = board.columns.find(column => column.id === selectedColumnId && column.cards.some(card => card.id === selected?.id))}
|
||||
{#if currentColumn?.moveTarget}
|
||||
<div class="card-move-control"><small>{de ? "In Spalte verschieben" : "Move to column"}</small><SelectMenu value={currentColumn.id} options={board.columns.filter(column => column.moveTarget).map(column => ({ value: column.id, label: column.title }))} ariaLabel={de ? "Karte verschieben" : "Move card"} onChange={value => { if (selected && currentColumn) void moveCard(selected.id, currentColumn.id, value); }} /></div>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="inspector-properties"><IssueAssignees names={selected.assignees} />{#if selected.lane}<span class="lane">{selected.lane}</span>{/if}</div>
|
||||
{#if selected.labels.length}<div class="inspector-labels"><small>Labels</small><IssueLabels labels={selected.labels} /></div>{/if}
|
||||
<div class="inspector-description"><h3>{de ? "Beschreibung" : "Description"}</h3><div class="description">{selected.description || (de ? "Weitere Details im Original-Board." : "More details in the original board.")}</div></div>
|
||||
{#if selected.webUrl}<button class="workspace-button inspector-open" onclick={() => openOriginal(selected!.webUrl)}><ExternalLink size={14} />{de ? "Karte öffnen" : "Open card"}</button>{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="workspace-empty"><span class="empty-symbol"><Columns3 size={26} /></span><h2>{loading ? (de ? "Board wird im Hintergrund geladen" : "Loading board in background") : (de ? "Dein vorhandenes Board übernehmen" : "Load your existing board")}</h2><p>{de ? "Wähle oben ein gefundenes Board aus oder füge einen Board-Link ein. Spalten und Karten werden aus der Integration gelesen; der Link wird für diese Integration gespeichert." : "Select a discovered board above or paste a board link. Columns and cards come from the integration; the link is saved for this integration."}</p>
|
||||
{#if source.provider === "gitea"}<p>{de ? "Gitea benötigt eine Serverversion mit Projects-/Columns-API." : "Gitea requires a server version with the Projects/Columns API."}</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
export let names: string[] = [];
|
||||
export let emptyLabel = "—";
|
||||
$: initials = names[0]?.trim().split(/[\s._-]+/).filter(Boolean).slice(0, 2).map(part => part[0]).join("").toLocaleUpperCase() ?? "";
|
||||
</script>
|
||||
<span class="issue-person" title={names.join(", ")}>
|
||||
{#if names.length}<span class="issue-avatar" aria-hidden="true">{initials}</span><span class="person-name">{names[0]}{names.length > 1 ? ` +${names.length - 1}` : ""}</span>{:else}<span class="unassigned">{emptyLabel}</span>{/if}
|
||||
</span>
|
||||
@@ -0,0 +1,259 @@
|
||||
<script context="module" lang="ts">
|
||||
const cache = new Map<string, { issues: IntegrationIssue[]; nextCursor: string | null }>();
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, tick } from "svelte";
|
||||
import { fly } from "svelte/transition";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import { ChevronRight, CircleDot, FolderGit2, ExternalLink, RefreshCw, Search, Settings2, X, XCircle } from "@lucide/svelte";
|
||||
import "../issueWorkspace.css";
|
||||
import IssueComments from "./IssueComments.svelte";
|
||||
import IssueLabels from "./IssueLabels.svelte";
|
||||
import IssueAssignees from "./IssueAssignees.svelte";
|
||||
import { issueTone, issueStateLabel } from "../issuePresentation";
|
||||
import IntegrationBoardView from "./IntegrationBoardView.svelte";
|
||||
import SelectMenu from "./SelectMenu.svelte";
|
||||
import { listIntegrationIssues, closeIntegrationIssue, openInBrowser } from "../git";
|
||||
import { configuredIntegrationSources, integrationCredentialKey } from "../integrations";
|
||||
import type { GitIntegrationSettings, IntegrationIssue, StoredCredential } from "../types";
|
||||
|
||||
export let language: "de" | "en" = "en";
|
||||
export let integrations: GitIntegrationSettings;
|
||||
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||
export let onOpenSettings: () => void;
|
||||
|
||||
let viewMode: "list" | "board" = "list";
|
||||
let sourceId = "";
|
||||
let issues: IntegrationIssue[] = [];
|
||||
let nextCursor: string | null = null;
|
||||
let query = "";
|
||||
let stateFilter = "";
|
||||
let repositoryFilter = "";
|
||||
let collapsedRepositories = new Set<string>();
|
||||
let selectedId = "";
|
||||
let detailTrigger: HTMLElement | null = null;
|
||||
let detailPanel: HTMLElement | undefined;
|
||||
let reduceMotion = false;
|
||||
let loading = false;
|
||||
let closingId = "";
|
||||
let closingKey = "";
|
||||
let actionError = "";
|
||||
let closedStates = new Map<string, string>();
|
||||
let error = "";
|
||||
let mounted = false;
|
||||
let generation = 0;
|
||||
let lastKey = "";
|
||||
$: de = language === "de";
|
||||
$: sources = configuredIntegrationSources(integrations);
|
||||
$: source = sources.find(item => item.id === sourceId) ?? sources[0];
|
||||
$: sourceKey = source ? JSON.stringify([source.id, source.provider, source.accountId, source.baseUrl]) : "";
|
||||
$: if (mounted && sourceKey !== lastKey) {
|
||||
lastKey = sourceKey;
|
||||
generation++;
|
||||
loading = false;
|
||||
const saved = cache.get(sourceKey);
|
||||
issues = saved?.issues ?? [];
|
||||
nextCursor = saved?.nextCursor ?? null;
|
||||
selectedId = "";
|
||||
stateFilter = "";
|
||||
repositoryFilter = "";
|
||||
error = "";
|
||||
if (source) void loadIssues();
|
||||
}
|
||||
$: normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
$: filtered = issues.filter(issue => (!repositoryFilter || issue.repositoryName === repositoryFilter) && (!stateFilter || issue.state === stateFilter) &&
|
||||
`${issue.title} ${issue.number} ${issue.repositoryName} ${issue.author} ${issue.labels.join(" ")} ${issue.assignees.join(" ")}`.toLocaleLowerCase().includes(normalizedQuery));
|
||||
$: repositoryOptions = [{ value: "", label: de ? "Alle Repositories" : "All repositories" }, ...[...new Set(issues.map(issue => issue.repositoryName))].sort().map(value => ({ value, label: value || (de ? "Ohne Repository" : "No repository") }))];
|
||||
$: groups = [...new Set(filtered.map(issue => issue.repositoryName))].sort().map(repository => ({ repository, issues: filtered.filter(issue => issue.repositoryName === repository) }));
|
||||
$: stateOptions = [{ value: "", label: de ? "Alle Status" : "All states" }, ...[...new Set(issues.map(issue => issue.state))].sort().map(value => ({ value, label: value }))];
|
||||
$: selected = issues.find(issue => issue.id === selectedId);
|
||||
$: description = selected ? plainDescription(selected) : "";
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const closeDetailsOutside = (event: PointerEvent) => {
|
||||
if (selectedId && detailPanel && event.target instanceof Node && !detailPanel.contains(event.target)) {
|
||||
selectedId = "";
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointerdown", closeDetailsOutside, true);
|
||||
const timer = window.setInterval(() => { if (!loading && !closingId && source) void loadIssues(); }, 180_000);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener("pointerdown", closeDetailsOutside, true);
|
||||
};
|
||||
});
|
||||
onDestroy(() => { generation++; });
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
try {
|
||||
return await Promise.race([promise, new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(de ? "Zeitüberschreitung beim Laden." : "Loading timed out.")), ms);
|
||||
})]);
|
||||
} finally { clearTimeout(timer!); }
|
||||
}
|
||||
|
||||
async function loadIssues(more = false) {
|
||||
if (!source || loading || (closingId && closingKey === sourceKey)) return;
|
||||
const current = source;
|
||||
const key = sourceKey;
|
||||
const requestGeneration = ++generation;
|
||||
loading = true;
|
||||
error = "";
|
||||
try {
|
||||
const credential = await withTimeout(loadCredential(integrationCredentialKey(current.provider, current.accountId)), 15_000);
|
||||
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token stored for this integration.");
|
||||
const page = await withTimeout(listIntegrationIssues(current.provider, current.baseUrl, credential.username, credential.password, more ? nextCursor : null), 40_000);
|
||||
if (generation !== requestGeneration) return;
|
||||
issues = [...new Map([...(more ? issues : []), ...page.issues].map(issue => [issue.id, issue])).values()];
|
||||
nextCursor = page.nextCursor;
|
||||
cache.set(key, { issues, nextCursor });
|
||||
} catch (cause) {
|
||||
if (generation === requestGeneration) error = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
if (generation === requestGeneration) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function closeDetails() {
|
||||
selectedId = "";
|
||||
await tick();
|
||||
detailTrigger?.focus();
|
||||
}
|
||||
|
||||
function toggleRepository(repository: string) {
|
||||
const key = JSON.stringify([sourceKey, repository]);
|
||||
const next = new Set(collapsedRepositories);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
collapsedRepositories = next;
|
||||
}
|
||||
|
||||
function plainDescription(issue: IntegrationIssue) {
|
||||
if (!issue.descriptionHtml) return issue.description;
|
||||
// Azure returns HTML. Display inert text, never inject provider HTML into the app.
|
||||
const document = new DOMParser().parseFromString(issue.description, "text/html");
|
||||
document.querySelectorAll("script, style").forEach(element => element.remove());
|
||||
document.querySelectorAll("br").forEach(element => element.replaceWith("\n"));
|
||||
document.querySelectorAll("p, div, li").forEach(element => element.append("\n"));
|
||||
return document.body.textContent?.trim() ?? "";
|
||||
}
|
||||
|
||||
async function closeIssue() {
|
||||
if (!selected || !source || closingId || loading) return;
|
||||
const issue = selected;
|
||||
const current = source;
|
||||
const key = sourceKey;
|
||||
closingId = issue.id;
|
||||
closingKey = key;
|
||||
actionError = "";
|
||||
try {
|
||||
const credential = await withTimeout(loadCredential(integrationCredentialKey(current.provider, current.accountId)), 15_000);
|
||||
if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
|
||||
const state = await closeIntegrationIssue(current.provider, current.baseUrl, credential.username, credential.password, issue.repositoryName, issue.number);
|
||||
const saved = cache.get(key);
|
||||
if (saved) cache.set(key, { ...saved, issues: saved.issues.map(item => item.id === issue.id ? { ...item, state } : item) });
|
||||
closedStates = new Map(closedStates).set(`${key}:${issue.id}`, state);
|
||||
if (sourceKey === key) issues = issues.map(item => item.id === issue.id ? { ...item, state } : item);
|
||||
} catch (cause) {
|
||||
if (sourceKey === key && selectedId === issue.id) actionError = `${de ? "Issue konnte nicht geschlossen werden. Bitte Schreibrechte prüfen." : "Could not close issue. Check write permissions."} ${String(cause)}`;
|
||||
} finally { closingId = ""; }
|
||||
}
|
||||
|
||||
async function openIssue(url: string) {
|
||||
try { await openInBrowser(url); }
|
||||
catch (cause) { error = String(cause); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={event => { if (event.key === "Escape" && selectedId) void closeDetails(); }} />
|
||||
|
||||
<section class="issue-center" aria-label="Issues">
|
||||
<header class="workspace-heading">
|
||||
<div class="workspace-title"><span class="workspace-symbol"><CircleDot size={21} strokeWidth={1.7} /></span><div><h1>Issues</h1><p>{de ? "Arbeit im Blick. Über alle Integrationen." : "Work in focus. Across your integrations."}</p></div></div>
|
||||
<button class="workspace-button" onclick={onOpenSettings} title={de ? "Integrationen konfigurieren" : "Configure integrations"}><Settings2 size={14} />{de ? "Integrationen" : "Integrations"}</button>
|
||||
</header>
|
||||
{#if !sources.length}
|
||||
<div class="workspace-empty"><span class="empty-symbol"><CircleDot size={26} /></span><h2>{de ? "Noch keine Integration eingerichtet" : "No integrations configured"}</h2><p>{de ? "Verbinde einen Anbieter, um Issues zu sehen." : "Connect a provider to view issues."}</p><button class="workspace-button" onclick={onOpenSettings}>{de ? "Integration einrichten" : "Set up integration"}</button></div>
|
||||
{:else}
|
||||
<div class="workspace-navigation">
|
||||
<div class="view-toggle" role="group" aria-label={de ? "Issue-Ansicht" : "Issue view"}>
|
||||
<button class:active={viewMode === "list"} aria-pressed={viewMode === "list"} onclick={() => { viewMode = "list"; }}>{de ? "Liste" : "List"}</button>
|
||||
<button class:active={viewMode === "board"} aria-pressed={viewMode === "board"} onclick={() => { viewMode = "board"; }}>Board</button>
|
||||
</div>
|
||||
<SelectMenu class="source-select" value={source?.id ?? ""} options={sources.map(item => ({ value: item.id, label: item.label }))} ariaLabel={de ? "Integration auswählen" : "Select integration"} onChange={value => { sourceId = value; }} />
|
||||
</div>
|
||||
{#if viewMode === "board" && source}
|
||||
{#key sourceKey}<IntegrationBoardView {source} {language} {loadCredential} />{/key}
|
||||
{:else}
|
||||
<div class="issue-content">
|
||||
<div class="issue-main">
|
||||
<div class="issue-toolbar">
|
||||
<label class="workspace-search"><Search size={15} /><input bind:value={query} placeholder={de ? "Issues durchsuchen …" : "Search issues …"} aria-label={de ? "Issues durchsuchen" : "Search issues"} /></label>
|
||||
<SelectMenu class="repository-select" value={repositoryFilter} options={repositoryOptions} ariaLabel={de ? "Repository filtern" : "Filter repository"} onChange={value => { repositoryFilter = value; selectedId = ""; }} />
|
||||
<SelectMenu class="state-select" value={stateFilter} options={stateOptions} ariaLabel={de ? "Status filtern" : "Filter state"} onChange={value => { stateFilter = value; }} />
|
||||
<button class="workspace-button icon-button" disabled={loading} onclick={() => loadIssues()} title={de ? "Aktualisieren" : "Refresh"} aria-label={de ? "Aktualisieren" : "Refresh"}><RefreshCw size={15} /></button>
|
||||
</div>
|
||||
{#if error}<div class="workspace-notice error" role="alert">{error}<p>{de ? "Bitte Token und Leserechte für Issues bzw. Azure Boards prüfen." : "Check the token and read permissions for issues or Azure Boards."}</p></div>{/if}
|
||||
<div class="issue-list">
|
||||
{#if filtered.length}<div class="issue-table-heading"><span>Issue</span><span>Status</span><span>{de ? "Zugewiesen" : "Assignee"}</span></div>{/if}
|
||||
{#each groups as group (group.repository)}
|
||||
{@const collapsed = collapsedRepositories.has(JSON.stringify([sourceKey, group.repository]))}
|
||||
<button class="repository-group-heading" aria-expanded={!collapsed} onclick={() => toggleRepository(group.repository)}>
|
||||
<ChevronRight size={14} class={collapsed ? "repository-chevron" : "repository-chevron expanded"} />
|
||||
<FolderGit2 size={15} /><strong>{group.repository || (de ? "Ohne Repository" : "No repository")}</strong><span>{group.issues.length}</span>
|
||||
</button>
|
||||
{#if !collapsed}
|
||||
{#each group.issues as issue (issue.id)}
|
||||
<button class="issue-row" class:selected={selectedId === issue.id} aria-pressed={selectedId === issue.id} onclick={event => { detailTrigger = event.currentTarget; selectedId = issue.id; actionError = ""; }}>
|
||||
<span class="issue-identity"><span class="issue-number">#{issue.number}</span><span class="issue-text"><strong>{issue.title}</strong><small>{issue.repositoryName}</small><IssueLabels labels={issue.labels} /></span></span>
|
||||
<span class="issue-status" data-tone={issueTone(issue.state)}><span class="status-dot"></span>{issueStateLabel(issue.state, de)}</span>
|
||||
<IssueAssignees names={issue.assignees} />
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="workspace-empty"><span class="empty-symbol"><CircleDot size={26} /></span><h2>{loading && !issues.length ? (de ? "Issues werden geladen" : "Loading issues") : error && !issues.length ? (de ? "Issues konnten nicht geladen werden" : "Could not load issues") : (de ? "Keine passenden Issues" : "No matching issues")}</h2><p>{de ? "Suche und Filter gelten für die bereits geladenen Einträge." : "Search and filters apply to the items already loaded."}</p></div>
|
||||
{/each}
|
||||
{#if nextCursor}<button class="workspace-button load-more" disabled={loading} onclick={() => loadIssues(true)}>{de ? "Weitere laden" : "Load more"}</button>{/if}
|
||||
</div>
|
||||
<footer class="issue-footer" aria-live="polite"><span>{filtered.length !== issues.length ? `${filtered.length} / ` : ""}{issues.length} {de ? "geladene Issues" : "loaded issues"}</span>{#if loading}<span>{de ? "Aktualisierung im Hintergrund …" : "Updating in background …"}</span>{/if}</footer>
|
||||
</div>
|
||||
</div>
|
||||
{#if selected}
|
||||
<aside bind:this={detailPanel} class="issue-detail-drawer" aria-label={de ? "Issue-Details" : "Issue details"} transition:fly={{ x: 140, duration: reduceMotion ? 0 : 210, easing: cubicOut }}>
|
||||
<header class="issue-detail-header">
|
||||
<div><CircleDot size={17} /><strong>{source?.label} · Issue</strong></div>
|
||||
<div class="issue-detail-header-actions">
|
||||
{#if selected.webUrl}<button class="workspace-button close-button" onclick={() => openIssue(selected!.webUrl)} aria-label={de ? "Im Anbieter öffnen" : "Open in provider"}><ExternalLink size={16} /></button>{/if}
|
||||
<button class="workspace-button close-button" onclick={closeDetails} aria-label={de ? "Details schließen" : "Close details"}><X size={17} /></button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="issue-detail-body">
|
||||
<div class="issue-detail-main">
|
||||
<div class="issue-detail-title"><div><span>#{selected.number}</span><h2>{selected.title}</h2></div>
|
||||
<div class="issue-detail-summary"><span class="issue-status" data-tone={issueTone(selected.state)}><span class="status-dot"></span>{issueStateLabel(selected.state, de)}</span><IssueAssignees names={selected.author ? [selected.author] : []} /></div>
|
||||
</div>
|
||||
<section class="issue-detail-description"><h3>{de ? "Beschreibung" : "Description"}</h3><div class="description">{description || (de ? "Keine Beschreibung vorhanden." : "No description provided.")}</div></section>
|
||||
{#if source}{#key `${sourceKey}:${selected.id}`}<IssueComments {source} issue={selected} {language} {loadCredential} />{/key}{/if}
|
||||
</div>
|
||||
<div class="issue-detail-sidebar">
|
||||
{#if selected.state !== "closed" && closedStates.get(`${sourceKey}:${selected.id}`) !== selected.state}
|
||||
<button class="workspace-button issue-close-action" disabled={!!closingId || loading} onclick={closeIssue}><XCircle size={15} />{closingId === selected.id ? (de ? "Wird geschlossen …" : "Closing …") : (de ? "Issue schließen" : "Close issue")}</button>
|
||||
{/if}
|
||||
{#if actionError}<p class="comment-error" role="alert">{actionError}</p>{/if}
|
||||
{#if selected.webUrl}<button class="workspace-button inspector-open" onclick={() => openIssue(selected!.webUrl)}><ExternalLink size={14} />{de ? "Im Browser öffnen" : "Open in browser"}</button>{/if}
|
||||
<section><h3>{de ? "Zugewiesen" : "Assignees"}</h3><IssueAssignees names={selected.assignees} /></section>
|
||||
<section><h3>Labels</h3>{#if selected.labels.length}<IssueLabels labels={selected.labels} />{:else}<small>{de ? "Keine Labels" : "No labels"}</small>{/if}</section>
|
||||
<section><h3>Repository</h3><strong class="issue-detail-repo"><FolderGit2 size={15} />{selected.repositoryName}</strong></section>
|
||||
{#if selected.updatedAt}<section><h3>{de ? "Aktualisiert" : "Updated"}</h3><small>{new Date(selected.updatedAt).toLocaleString(de ? "de-DE" : "en-US")}</small></section>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script context="module" lang="ts">
|
||||
const drafts = new Map<string, string>();
|
||||
</script>
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import CommentEditor from "./CommentEditor.svelte";
|
||||
import { RefreshCw } from "@lucide/svelte";
|
||||
import IssueAssignees from "./IssueAssignees.svelte";
|
||||
import { listIntegrationIssueComments, addIntegrationIssueComment } from "../git";
|
||||
import { integrationCredentialKey } from "../integrations";
|
||||
import type { GitIntegrationSource, IntegrationIssue, IssueComment, StoredCredential } from "../types";
|
||||
export let source: GitIntegrationSource;
|
||||
export let issue: IntegrationIssue;
|
||||
export let language: "de" | "en";
|
||||
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||
const key = JSON.stringify([source.id, source.baseUrl, issue.id]);
|
||||
let draft = drafts.get(key) ?? "";
|
||||
let comments: IssueComment[] = [];
|
||||
let cursor: string | null = null;
|
||||
let loading = false;
|
||||
let posting = false;
|
||||
let error = "";
|
||||
let notice = "";
|
||||
let destroyed = false;
|
||||
$: de = language === "de";
|
||||
$: drafts.set(key, draft);
|
||||
onMount(() => { void load(); });
|
||||
onDestroy(() => { destroyed = true; });
|
||||
async function credential() {
|
||||
const saved = await loadCredential(integrationCredentialKey(source.provider, source.accountId));
|
||||
if (!saved?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
|
||||
return saved;
|
||||
}
|
||||
async function load(more = false) {
|
||||
if (loading || posting) return;
|
||||
loading = true; error = "";
|
||||
try {
|
||||
const saved = await credential();
|
||||
const page = await listIntegrationIssueComments(source.provider, source.baseUrl, saved.username, saved.password, issue.repositoryName, issue.number, more ? cursor : null);
|
||||
if (destroyed) return;
|
||||
comments = [...new Map([...(more ? comments : []), ...page.comments].map(comment => [comment.id, comment])).values()];
|
||||
cursor = page.nextCursor;
|
||||
} catch (cause) { if (!destroyed) error = String(cause); }
|
||||
finally { if (!destroyed) loading = false; }
|
||||
}
|
||||
async function submit() {
|
||||
if (!draft.trim() || posting || loading) return;
|
||||
const body = draft;
|
||||
posting = true; error = ""; notice = "";
|
||||
try {
|
||||
const saved = await credential();
|
||||
const comment = await addIntegrationIssueComment(source.provider, source.baseUrl, saved.username, saved.password, issue.repositoryName, issue.number, body);
|
||||
if (drafts.get(key) === body) drafts.delete(key);
|
||||
if (destroyed) return;
|
||||
comments = [...comments.filter(item => item.id !== comment.id), comment];
|
||||
if (draft === body) draft = "";
|
||||
notice = de ? "Kommentar gesendet." : "Comment posted.";
|
||||
} catch (cause) {
|
||||
if (!destroyed) error = `${de ? "Versand nicht bestätigt. Vor erneutem Senden bitte die Kommentare aktualisieren." : "Delivery not confirmed. Refresh comments before resending."} ${String(cause)}`;
|
||||
} finally { if (!destroyed) posting = false; }
|
||||
}
|
||||
function text(comment: IssueComment) {
|
||||
if (!comment.bodyHtml) return comment.body;
|
||||
const document = new DOMParser().parseFromString(comment.body, "text/html");
|
||||
document.querySelectorAll("script, style").forEach(element => element.remove());
|
||||
document.querySelectorAll("br").forEach(element => element.replaceWith("\n"));
|
||||
document.querySelectorAll("p, div, li").forEach(element => element.append("\n"));
|
||||
return document.body.textContent?.trim() ?? "";
|
||||
}
|
||||
</script>
|
||||
<section class="issue-comments" aria-label={de ? "Issue-Kommentare" : "Issue comments"}>
|
||||
<header><h3>{de ? "Kommentare" : "Comments"}</h3><small>{comments.length}{cursor ? "+" : ""}</small><button class="workspace-button close-button" aria-label={de ? "Kommentare aktualisieren" : "Refresh comments"} disabled={loading || posting} onclick={() => load()}><RefreshCw size={14} /></button></header>
|
||||
{#if error}<p class="comment-error" role="alert">{error}</p>{/if}
|
||||
{#if loading}<small role="status">{de ? "Kommentare werden geladen …" : "Loading comments …"}</small>{/if}
|
||||
<div class="issue-comment-list">
|
||||
{#each comments as comment (comment.id)}
|
||||
<article><header><IssueAssignees names={[comment.author || (de ? "Unbekannt" : "Unknown")]} />{#if comment.createdAt}<time datetime={comment.createdAt}>{new Date(comment.createdAt).toLocaleString(de ? "de-DE" : "en-US")}</time>{/if}</header><p>{text(comment)}</p></article>
|
||||
{:else}{#if !loading && !error}<small>{de ? "Noch keine Kommentare." : "No comments yet."}</small>{/if}{/each}
|
||||
</div>
|
||||
{#if cursor}<button class="workspace-button" disabled={loading || posting} onclick={() => load(true)}>{de ? "Weitere Kommentare laden" : "Load more comments"}</button>{/if}
|
||||
<CommentEditor bind:value={draft} {language} disabled={loading} busy={posting} onSend={submit} />
|
||||
{#if notice}<small role="status">{notice}</small>{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { labelTone } from "../issuePresentation";
|
||||
export let labels: string[] = [];
|
||||
</script>
|
||||
<span class="issue-labels">{#each labels as label}<span class="issue-label" data-tone={labelTone(label)}>{label}</span>{/each}</span>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Check, ExternalLink, FileDiff, LoaderCircle, MousePointer2, X } from "@lucide/svelte";
|
||||
import { ArrowDown, ArrowUp, Check, ExternalLink, FileDiff, LoaderCircle, Minus, Plus, RefreshCw, Trash2, X } from "@lucide/svelte";
|
||||
import type { GitFileStatus, PatchApplyAction } from "../types";
|
||||
|
||||
type PatchLineKind = "context" | "add" | "delete" | "meta";
|
||||
@@ -60,18 +60,26 @@
|
||||
const isGerman = $derived(language === "de");
|
||||
|
||||
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
||||
let patchWidth = $state(0);
|
||||
let patchScroll = $state<HTMLDivElement | null>(null);
|
||||
let selectedLineIds = $state<Set<string>>(new Set());
|
||||
let lastSelectedLineId = $state("");
|
||||
|
||||
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
|
||||
const t = (de: string, en: string) => isGerman ? de : en;
|
||||
let scopeLabel = $derived(staged ? t("Gestagte Änderungen", "Staged changes") : t("Nicht gestagte Änderungen", "Unstaged changes"));
|
||||
let activeHunk = $state(0);
|
||||
let hasTextPatch = $derived(!isLoading && !error && !!patch.trim() && !parsed.binary && parsed.hunks.length > 0);
|
||||
function clearSelection() { selectedLineIds = new Set(); lastSelectedLineId = ""; }
|
||||
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
||||
let selectableLines = $derived(
|
||||
parsed.hunks.flatMap((hunk) => hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete")),
|
||||
);
|
||||
let addedCount = $derived(selectableLines.filter(line => line.kind === "add").length);
|
||||
let deletedCount = $derived(selectableLines.filter(line => line.kind === "delete").length);
|
||||
let selectedCount = $derived(selectableLines.filter((line) => selectedLineIds.has(line.id)).length);
|
||||
|
||||
$effect(() => {
|
||||
activeHunk = 0;
|
||||
parsed = parsePatch(patch);
|
||||
selectedLineIds = new Set();
|
||||
lastSelectedLineId = "";
|
||||
@@ -191,6 +199,7 @@
|
||||
}
|
||||
|
||||
function toggleHunkSelection(hunk: PatchHunk) {
|
||||
if (isBusy || isLoading) return;
|
||||
const changed = hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete");
|
||||
const allSelected = changed.length > 0 && changed.every((line) => selectedLineIds.has(line.id));
|
||||
const next = new Set(selectedLineIds);
|
||||
@@ -266,149 +275,105 @@
|
||||
|
||||
function scrollToHunk(hunkId: string) {
|
||||
const target = patchScroll?.querySelector<HTMLElement>(`[data-hunk-id="${hunkId}"]`);
|
||||
if (patchScroll && target) patchScroll.scrollTop = Math.max(target.offsetTop - 8, 0);
|
||||
if (patchScroll && target) {
|
||||
patchScroll.scrollTop += target.getBoundingClientRect().top - patchScroll.getBoundingClientRect().top;
|
||||
activeHunk = parsed.hunks.findIndex(hunk => hunk.id === hunkId);
|
||||
}
|
||||
}
|
||||
|
||||
function updateActiveHunk() {
|
||||
if (!patchScroll) return;
|
||||
if (patchScroll.scrollTop > 0 && patchScroll.scrollTop + patchScroll.clientHeight >= patchScroll.scrollHeight - 2) {
|
||||
activeHunk = Math.max(0, parsed.hunks.length - 1);
|
||||
return;
|
||||
}
|
||||
const top = patchScroll.getBoundingClientRect().top;
|
||||
const sections = Array.from(patchScroll.querySelectorAll<HTMLElement>("[data-hunk-id]"));
|
||||
let index = 0;
|
||||
sections.forEach((section, i) => { if (section.getBoundingClientRect().top <= top + 2) index = i; });
|
||||
activeHunk = index;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog line-patch-dialog" role="dialog" aria-modal="true" aria-label="Line patch">
|
||||
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
||||
<div class="dialog line-patch-dialog" role="dialog" aria-modal="true" aria-label={t("Geänderte Zeilen", "Changed lines")}>
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">{scopeLabel}</span>
|
||||
<p class="dialog-title" title={displayPath}>{displayPath}</p>
|
||||
</div>
|
||||
<div class="dialog-header-actions line-patch-header-actions">
|
||||
<div class="tool-surface-choice" aria-label={isGerman ? "Diff öffnen mit" : "Open diff with"}>
|
||||
<span>{isGerman ? "Öffnen mit" : "Open with"}</span>
|
||||
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty anzeigen" : "Show in Gitty"}>
|
||||
<FileDiff size={13} aria-hidden="true" />Gitty
|
||||
</button>
|
||||
<button type="button" onclick={onExternalDiff} disabled={isBusy || isLoading} title={isGerman ? `In ${diffName} öffnen` : `Open in ${diffName}`}>
|
||||
<ExternalLink size={13} aria-hidden="true" /><span>{diffName}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>Refresh</button>
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<div class="patch-identity"><FileDiff size={23} aria-hidden="true" /><div><p class="dialog-title" title={displayPath}>{displayPath}</p><span class="patch-scope">{scopeLabel}</span></div></div>
|
||||
<div class="dialog-header-actions">
|
||||
<button class="external-diff" type="button" onclick={onExternalDiff} disabled={isBusy || isLoading} title={t(`In ${diffName} öffnen`, `Open in ${diffName}`)}><span>{t("Extern öffnen", "Open externally")}</span><ExternalLink size={14} /><span>·</span><span>{diffName}</span></button>
|
||||
<button class="icon-action" type="button" onclick={onRefresh} disabled={isBusy || isLoading} aria-label={t("Aktualisieren", "Refresh")} title={t("Aktualisieren", "Refresh")}><RefreshCw size={16} /></button>
|
||||
<button class="icon-action" type="button" onclick={onClose} disabled={isBusy} aria-label={t("Schließen", "Close")}><X size={16} /></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="line-patch-body">
|
||||
{#if isLoading}
|
||||
<div class="blank-state">
|
||||
<LoaderCircle class="spin" size={18} aria-hidden="true" />
|
||||
Loading patch...
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="blank-state">{error}</div>
|
||||
{:else if !patch.trim()}
|
||||
<div class="blank-state">No line patch available for this file.</div>
|
||||
{:else if parsed.binary || parsed.hunks.length === 0}
|
||||
<div class="blank-state">This change cannot be split into text lines.</div>
|
||||
<div class="blank-state"><LoaderCircle class="spin" size={18} />{t("Änderungen werden geladen …", "Loading changes …")}</div>
|
||||
{:else if error}<div class="blank-state" role="alert">{error}</div>
|
||||
{:else if !patch.trim()}<div class="blank-state">{t("Keine Änderungen für diese Datei vorhanden.", "No changes available for this file.")}</div>
|
||||
{:else if parsed.binary || parsed.hunks.length === 0}<div class="blank-state">{t("Diese Änderung lässt sich nicht in Textzeilen aufteilen.", "This change cannot be split into text lines.")}</div>
|
||||
{:else}
|
||||
<div class:active={selectedCount > 0} class="line-patch-selection-bar">
|
||||
<div>
|
||||
<MousePointer2 size={14} aria-hidden="true" />
|
||||
{#if selectedCount > 0}
|
||||
<strong>{selectedCount} {selectedCount === 1 ? "line" : "lines"} selected</strong>
|
||||
<span>Shift-click to select a range.</span>
|
||||
{:else}
|
||||
<strong>Select changed lines</strong>
|
||||
<span>Choose individual additions or deletions below.</span>
|
||||
{/if}
|
||||
<div class="patch-toolbar">
|
||||
<strong>{t("Geänderte Zeilen auswählen", "Select changed lines")}</strong><span class="range-hint">{t("Shift-Klick für einen Bereich", "Shift-click to select a range")}</span>
|
||||
<div class="patch-summary"><span class="add-count">+{addedCount}</span><span class="delete-count">−{deletedCount}</span><span>{parsed.hunks.length} {t(parsed.hunks.length === 1 ? "Abschnitt" : "Abschnitte", parsed.hunks.length === 1 ? "hunk" : "hunks")}</span>
|
||||
<button class="icon-action" type="button" onclick={() => scrollToHunk(parsed.hunks[activeHunk - 1].id)} disabled={activeHunk === 0} aria-label={t("Vorheriger Abschnitt", "Previous hunk")} title={t("Vorheriger Abschnitt", "Previous hunk")}><ArrowUp size={16} /></button>
|
||||
<button class="icon-action" type="button" onclick={() => scrollToHunk(parsed.hunks[activeHunk + 1].id)} disabled={activeHunk >= parsed.hunks.length - 1} aria-label={t("Nächster Abschnitt", "Next hunk")} title={t("Nächster Abschnitt", "Next hunk")}><ArrowDown size={16} /></button>
|
||||
</div>
|
||||
{#if selectedCount > 0}
|
||||
<div class="line-patch-selected-actions">
|
||||
<button class="line-patch-hunk-button discard" type="button" onclick={() => applySelected(staged ? "discard-staged" : "discard-unstaged")} disabled={isBusy}>
|
||||
Discard selected
|
||||
</button>
|
||||
<button class="line-patch-hunk-button {staged ? "unstage" : "stage"}" type="button" onclick={() => applySelected(staged ? "unstage" : "stage")} disabled={isBusy}>
|
||||
{staged ? "Unstage selected" : "Stage selected"}
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => { selectedLineIds = new Set(); }} disabled={isBusy}>Clear</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="line-patch-workspace">
|
||||
<div class="line-patch-scroll" bind:this={patchScroll}>
|
||||
{#each parsed.hunks as hunk (hunk.id)}
|
||||
<section class="line-patch-hunk" data-hunk-id={hunk.id}>
|
||||
<div class="line-patch-workspace" style={`--patch-visible-width: ${patchWidth}px`}>
|
||||
<div class="line-patch-scroll" bind:this={patchScroll} bind:clientWidth={patchWidth} onscroll={updateActiveHunk}>
|
||||
{#each parsed.hunks as hunk, index (hunk.id)}
|
||||
<section class="line-patch-hunk" data-hunk-id={hunk.id} aria-label={`${t("Abschnitt", "Hunk")} ${index + 1}`}>
|
||||
<div class="line-patch-hunk-head">
|
||||
<button
|
||||
class:all={hunkSelectionState(hunk) === "all"}
|
||||
class:some={hunkSelectionState(hunk) === "some"}
|
||||
class="line-patch-select-hunk"
|
||||
type="button"
|
||||
onclick={() => toggleHunkSelection(hunk)}
|
||||
aria-label={`Select changed lines in ${hunk.header}`}
|
||||
aria-pressed={hunkSelectionState(hunk) === "all"}
|
||||
title="Select all changed lines in this hunk"
|
||||
>
|
||||
{#if hunkSelectionState(hunk) !== "none"}<Check size={12} aria-hidden="true" />{/if}
|
||||
<button class:all={hunkSelectionState(hunk) === "all"} class:some={hunkSelectionState(hunk) === "some"} class="line-patch-select-hunk" type="button" role="checkbox" aria-checked={hunkSelectionState(hunk) === "some" ? "mixed" : hunkSelectionState(hunk) === "all"} onclick={() => toggleHunkSelection(hunk)} disabled={isBusy || isLoading} aria-label={`${t("Abschnitt auswählen", "Select hunk")} ${index + 1}`} title={t("Alle geänderten Zeilen dieses Abschnitts auswählen", "Select all changed lines in this hunk")}>
|
||||
{#if hunkSelectionState(hunk) === "all"}<Check size={11} />{:else if hunkSelectionState(hunk) === "some"}<Minus size={11} />{/if}
|
||||
</button>
|
||||
<code>{hunk.header}</code>
|
||||
<strong>{t("Abschnitt", "Hunk")} {index + 1}</strong><code title={hunk.header}>{hunk.header}</code>
|
||||
<div class="line-patch-hunk-actions">
|
||||
{#if staged}
|
||||
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-staged", hunk)} disabled={isBusy}>
|
||||
Discard Hunk
|
||||
</button>
|
||||
<button class="line-patch-hunk-button unstage" type="button" onclick={() => applyHunkAction("unstage", hunk)} disabled={isBusy}>
|
||||
Unstage Hunk
|
||||
</button>
|
||||
{:else}
|
||||
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-unstaged", hunk)} disabled={isBusy}>
|
||||
Discard Hunk
|
||||
</button>
|
||||
<button class="line-patch-hunk-button stage" type="button" onclick={() => applyHunkAction("stage", hunk)} disabled={isBusy}>
|
||||
Stage Hunk
|
||||
</button>
|
||||
{/if}
|
||||
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction(staged ? "discard-staged" : "discard-unstaged", hunk)} disabled={isBusy}><Trash2 size={14} />{t("Verwerfen", "Discard")}</button>
|
||||
<button class="line-patch-hunk-button {staged ? "unstage" : "stage"}" type="button" onclick={() => applyHunkAction(staged ? "unstage" : "stage", hunk)} disabled={isBusy}>{#if staged}<Minus size={14} />{:else}<Plus size={14} />{/if}{staged ? t("Abschnitt unstagen", "Unstage hunk") : t("Abschnitt stagen", "Stage hunk")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="line-patch-lines">
|
||||
{#each hunk.lines as line (line.id)}
|
||||
<div class:selected={selectedLineIds.has(line.id)} class:selectable={line.kind === "add" || line.kind === "delete"} class={`line-patch-row ${line.kind}`}>
|
||||
{#if line.kind === "add" || line.kind === "delete"}
|
||||
<button
|
||||
class="line-patch-line-select"
|
||||
type="button"
|
||||
onclick={(event) => toggleLine(event, line)}
|
||||
aria-label={`${selectedLineIds.has(line.id) ? "Deselect" : "Select"} ${line.kind === "add" ? "added" : "deleted"} line ${line.newLine ?? line.oldLine ?? ""}`}
|
||||
aria-pressed={selectedLineIds.has(line.id)}
|
||||
title="Select line (Shift-click for range)"
|
||||
>
|
||||
{#if selectedLineIds.has(line.id)}<Check size={11} aria-hidden="true" />{/if}
|
||||
<button class="line-patch-line-select" type="button" role="checkbox" aria-checked={selectedLineIds.has(line.id)} onclick={(event) => toggleLine(event, line)} disabled={isBusy || isLoading} aria-label={`${line.kind === "add" ? t("Hinzugefügte Zeile", "Added line") : t("Entfernte Zeile", "Deleted line")} ${line.newLine ?? line.oldLine ?? ""}`} title={t("Zeile auswählen (Shift-Klick für Bereich)", "Select line (Shift-click for range)")}>
|
||||
{#if selectedLineIds.has(line.id)}<Check size={11} />{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="line-patch-line-select-placeholder"></span>
|
||||
{/if}
|
||||
<span class="line-patch-line-number">{line.oldLine ?? ""}</span>
|
||||
<span class="line-patch-line-number">{line.newLine ?? ""}</span>
|
||||
<span class="line-patch-prefix">{linePrefix(line)}</span>
|
||||
<code>{lineBody(line)}</code>
|
||||
{:else}<span class="line-patch-line-select-placeholder"></span>{/if}
|
||||
<span class="line-patch-line-number">{line.oldLine ?? ""}</span><span class="line-patch-line-number">{line.newLine ?? ""}</span><span class="line-patch-prefix">{linePrefix(line)}</span><code>{lineBody(line)}</code>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
<nav class="diff-overview line-patch-overview" aria-label="Change overview">
|
||||
{#each parsed.hunks as hunk, index (hunk.id)}
|
||||
<button
|
||||
class="diff-overview-marker {hunkKind(hunk)}"
|
||||
type="button"
|
||||
style={`--marker-position: ${hunkPosition(index)}%`}
|
||||
onclick={() => scrollToHunk(hunk.id)}
|
||||
title={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
|
||||
aria-label={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
|
||||
></button>
|
||||
{/each}
|
||||
<nav class="diff-overview line-patch-overview" aria-label={t("Änderungsübersicht", "Change overview")}>
|
||||
{#each parsed.hunks as hunk, index (hunk.id)}<button class="diff-overview-marker {hunkKind(hunk)}" type="button" style={`--marker-position: ${hunkPosition(index)}%`} onclick={() => scrollToHunk(hunk.id)} title={`${t("Zu Abschnitt", "Jump to hunk")} ${index + 1}`} aria-label={`${t("Zu Abschnitt", "Jump to hunk")} ${index + 1}`}></button>{/each}
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if hasTextPatch}
|
||||
<footer class="patch-footer">
|
||||
<div class="selection-summary"><span class="selection-symbol" class:has-selection={selectedCount > 0}><Check size={13} /></span><strong aria-live="polite">{selectedCount} {t(selectedCount === 1 ? "Zeile ausgewählt" : "Zeilen ausgewählt", selectedCount === 1 ? "line selected" : "lines selected")}</strong><button class="clear-selection" type="button" onclick={clearSelection} disabled={isBusy || selectedCount === 0}>{t("Auswahl aufheben", "Clear selection")}</button></div>
|
||||
<div class="selection-actions"><button class="discard-selection" type="button" onclick={() => applySelected(staged ? "discard-staged" : "discard-unstaged")} disabled={isBusy || selectedCount === 0}>{t("Auswahl verwerfen", "Discard selected")}</button><button class="stage-selection" type="button" onclick={() => applySelected(staged ? "unstage" : "stage")} disabled={isBusy || selectedCount === 0}>{selectedCount} {t(selectedCount === 1 ? "Zeile" : "Zeilen", selectedCount === 1 ? "line" : "lines")} {staged ? t("unstagen", "to unstage") : t("stagen", "to stage")}</button></div>
|
||||
</footer>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.line-patch-dialog{width:min(1700px,100%);height:min(960px,100%);grid-template-rows:auto minmax(0,1fr) auto;font-size:13px}
|
||||
.dialog-header{padding:12px 18px}.patch-identity{display:flex;align-items:center;gap:12px;min-width:0}.patch-identity>div{min-width:0}.patch-identity :global(svg){flex:none;color:var(--color-ink-muted)}.patch-identity .dialog-title{font-size:15px;line-height:1.4;margin:0;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.patch-scope{display:block;color:var(--color-ink-muted);font-size:12px;margin-top:2px}
|
||||
.dialog-header-actions{gap:8px}.dialog-header-actions .external-diff{display:flex;align-items:center;gap:8px;border:0;background:transparent;font-size:12px;color:var(--color-ink-muted);padding:5px 10px}.line-patch-dialog .icon-action{display:inline-flex;align-items:center;justify-content:center;flex:none;width:30px;min-width:30px;height:30px;min-height:30px;padding:0;border:1px solid var(--color-border);background:transparent;color:var(--color-ink-muted)}
|
||||
.patch-toolbar{display:flex;align-items:center;gap:20px;padding:8px 18px;min-height:46px;border-bottom:1px solid var(--color-border-subtle);background:var(--app-dialog-bg)}.patch-toolbar strong{font-size:12px;font-weight:600}.range-hint{color:var(--color-ink-faint);font-size:12px}.patch-summary{display:flex;align-items:center;gap:10px;margin-left:auto;color:var(--color-ink-muted);font-size:12px;white-space:nowrap}.patch-summary .add-count{color:var(--code-add-text)}.patch-summary .delete-count{color:var(--code-delete-text);margin-right:10px}
|
||||
.line-patch-scroll{position:relative}.line-patch-hunk{min-width:100%;width:max-content}.line-patch-hunk-head{width:var(--patch-visible-width, 100%);min-width:0;max-width:100%;height:44px;gap:14px;padding:6px 14px;background:var(--app-dialog-chrome)}.line-patch-hunk-head>strong{font-size:12px;font-weight:600;white-space:nowrap}.line-patch-hunk-head code{font-size:12px;color:var(--color-ink-faint);overflow:hidden;text-overflow:ellipsis;min-width:0}.line-patch-hunk-actions{margin-left:auto;padding-left:10px;gap:8px}.line-patch-hunk-button{display:inline-flex;align-items:center;gap:6px;min-height:28px;padding:4px 9px;font-size:12px;font-weight:500;background:transparent}.line-patch-hunk-button.discard{border-color:transparent}.line-patch-hunk-button.discard:hover:not(:disabled){border-color:transparent}
|
||||
.line-patch-dialog .line-patch-select-hunk,.line-patch-dialog .line-patch-line-select{display:flex;align-items:center;justify-content:center;width:14px;min-width:14px;max-width:14px;height:14px;min-height:14px;max-height:14px;padding:0;line-height:1;border:1px solid var(--color-ink-faint);background:transparent;box-shadow:none;align-self:center;border-radius:2px!important;color:var(--app-bg);flex:none}
|
||||
.line-patch-dialog .line-patch-select-hunk.all,.line-patch-dialog .line-patch-select-hunk.some,.line-patch-dialog .line-patch-line-select[aria-checked="true"]{background:var(--color-accent);border-color:var(--color-accent)}
|
||||
.line-patch-lines{font-size:13px}.line-patch-row{grid-template-columns:28px 44px 44px 24px minmax(max-content,1fr);align-items:center;height:22px;min-height:22px;padding:0 14px;line-height:22px}.line-patch-row code{font-size:13px;line-height:22px}.line-patch-line-number{font-size:11px;line-height:22px}.line-patch-row.selected{box-shadow:inset 3px 0 0 var(--color-accent);filter:none}.line-patch-row.add.selected{background:color-mix(in srgb,var(--color-accent) 5%,var(--code-add-bg))}.line-patch-row.delete.selected{background:color-mix(in srgb,var(--color-accent) 5%,var(--code-delete-bg))}
|
||||
.patch-footer{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:60px;padding:10px 18px;border-top:1px solid var(--color-border);background:var(--app-dialog-chrome)}.selection-summary,.selection-actions{display:flex;align-items:center;gap:12px}.selection-summary strong{font-size:12px;font-weight:600;white-space:nowrap}.selection-symbol{display:flex;width:16px;height:16px;align-items:center;justify-content:center;border:1px solid var(--color-border-input);color:var(--color-ink-faint)}.selection-symbol.has-selection{background:var(--color-accent);color:var(--app-bg);border-color:var(--color-accent)}.patch-footer button{min-height:30px;padding:5px 12px;font-size:12px;white-space:nowrap}.patch-footer .clear-selection{border:0;background:transparent;color:var(--color-ink-muted)}.patch-footer .discard-selection{border:1px solid color-mix(in srgb,var(--code-delete-text) 60%,var(--color-border));color:var(--code-delete-text);background:transparent}.patch-footer .stage-selection{background:var(--color-primary);border-color:var(--color-primary);color:white}.patch-footer .stage-selection:hover:not(:disabled){background:var(--color-primary-dark)}
|
||||
@media(max-width:850px){.range-hint{display:none}.patch-toolbar{gap:10px}.patch-footer{flex-wrap:wrap}.selection-actions{margin-left:auto}.line-patch-hunk-head{gap:8px}.line-patch-hunk-head code{max-width:180px}.dialog-header-actions .external-diff{max-width:180px;overflow:hidden}}
|
||||
@media(max-width:560px){.dialog-header{padding:10px}.patch-identity{gap:8px}.patch-identity .dialog-title{font-size:13px}.external-diff span{display:none}.dialog-header-actions .external-diff{width:30px;min-width:30px;height:30px;padding:0;justify-content:center;overflow:visible}.patch-toolbar{flex-wrap:wrap;padding:8px}.patch-summary{margin-left:0}.patch-footer{padding:8px}.selection-summary{gap:8px}.selection-actions{width:100%;justify-content:flex-end}.line-patch-hunk-head{height:auto;min-height:44px;flex-wrap:wrap}.line-patch-hunk-actions{width:100%;justify-content:flex-end}.line-patch-hunk-head code{max-width:160px}}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { Check, ChevronRight, GitMerge, X } from "@lucide/svelte";
|
||||
let { conflictCount, isBusy, language, onResolve, onContinue, onAbort }: {
|
||||
conflictCount: number; isBusy: boolean; language: "de" | "en";
|
||||
onResolve: () => void; onContinue: () => void; onAbort: () => void;
|
||||
} = $props();
|
||||
const de = $derived(language === "de");
|
||||
const hasConflicts = $derived(conflictCount > 0);
|
||||
</script>
|
||||
|
||||
<section class="merge-progress" class:ready={!hasConflicts} role="status">
|
||||
<span class="status-icon">{#if hasConflicts}<GitMerge size={18} />{:else}<Check size={18} />{/if}</span>
|
||||
<div class="status-copy">
|
||||
<strong>{hasConflicts ? (de ? "Merge läuft" : "Merge in progress") : (de ? "Bereit zum Abschließen" : "Ready to complete")}</strong>
|
||||
<span>{hasConflicts ? `${conflictCount} ${de ? (conflictCount === 1 ? "Datei mit Konflikten" : "Dateien mit Konflikten") : (conflictCount === 1 ? "file with conflicts" : "files with conflicts")}` : (de ? "Alle Konflikte sind gelöst." : "All conflicts are resolved.")}</span>
|
||||
</div>
|
||||
<div class="merge-actions">
|
||||
{#if hasConflicts}<button class="resolve-action" type="button" onclick={onResolve} disabled={isBusy}><GitMerge size={14} />{de ? "Konflikte lösen" : "Resolve conflicts"}</button>{/if}
|
||||
<button class="continue-action" type="button" onclick={onContinue} disabled={isBusy || hasConflicts} title={hasConflicts ? (de ? "Zuerst alle Konflikte lösen" : "Resolve all conflicts first") : undefined}>{de ? "Fortsetzen" : "Continue"}<ChevronRight size={14} /></button>
|
||||
<span class="action-divider" aria-hidden="true"></span>
|
||||
<button class="abort-action" type="button" onclick={onAbort} disabled={isBusy}><X size={14} />{de ? "Abbrechen" : "Abort"}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.merge-progress{--status-color:var(--color-sync-ahead);display:flex;align-items:center;gap:12px;flex-shrink:0;min-height:50px;padding:8px 14px;border-bottom:1px solid var(--color-border);border-left:3px solid var(--status-color);background:color-mix(in srgb,var(--status-color) 5%,var(--app-bg));color:var(--color-ink);font-size:13px}
|
||||
.merge-progress.ready{--status-color:#68c878}.status-icon{display:flex;align-items:center;color:var(--status-color)}.status-copy{display:flex;align-items:center;gap:14px;min-width:0;flex-wrap:wrap}.status-copy strong{font-size:13px;font-weight:600;white-space:nowrap}.status-copy>span{color:var(--color-ink-muted);font-size:12px}
|
||||
.merge-actions{display:flex;align-items:center;gap:8px;margin-left:auto;flex-shrink:0}.merge-actions button{display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:30px;padding:5px 10px;border:1px solid var(--color-border-input);background:transparent;color:var(--color-ink-muted);font-size:12px;font-weight:500;white-space:nowrap}.merge-actions button:hover:not(:disabled){background:var(--color-surface-hover);color:var(--color-ink)}.merge-actions button:disabled{opacity:.4}.merge-actions .resolve-action{border-color:color-mix(in srgb,var(--status-color) 45%,var(--color-border));background:color-mix(in srgb,var(--status-color) 10%,transparent);color:var(--status-color)}.ready .continue-action{color:var(--status-color);border-color:color-mix(in srgb,var(--status-color) 45%,var(--color-border));background:color-mix(in srgb,var(--status-color) 10%,transparent)}.action-divider{height:20px;width:1px;background:var(--color-border);margin:0 3px}.merge-actions .abort-action{border-color:transparent}.merge-actions .abort-action:hover:not(:disabled){color:#ef8080;background:color-mix(in srgb,#ef8080 8%,transparent)}
|
||||
@media(max-width:700px){.merge-progress{flex-wrap:wrap;gap:8px}.status-copy{gap:8px}.merge-actions{width:100%;justify-content:flex-end}.merge-actions button{font-size:11px;padding:5px 8px}}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { listIntegrationReviewRequests, listRemotes } from "../git";
|
||||
import { configuredIntegrationSources, integrationCredentialKey } from "../integrations";
|
||||
import type { GitIntegrationSettings, GitIntegrationSource, IntegrationReviewRequest, StoredCredential } from "../types";
|
||||
import type { PullRequestBadgeState } from "../pullRequestBadges";
|
||||
interface DashboardRepository { path: string; }
|
||||
interface RemoteIdentity { host: string; repository: string; organization: string; }
|
||||
export let repos: DashboardRepository[] = [];
|
||||
export let language: "de" | "en" = "en";
|
||||
export let integrations: GitIntegrationSettings;
|
||||
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||
export let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
|
||||
const STORAGE_KEY = "gitty.pull-request-badges.v1";
|
||||
let pullRequestLoadGeneration = 0;
|
||||
let lastPullRequestLoadKey = "";
|
||||
let mounted = false;
|
||||
let loading = false;
|
||||
$: de = language === "de";
|
||||
$: integrationSources = configuredIntegrationSources(integrations);
|
||||
$: sourceKey = JSON.stringify(integrationSources.map(source => [source.id, source.provider, source.accountId, source.baseUrl]));
|
||||
$: loadKey = JSON.stringify([repos.map(repo => repo.path).sort(), sourceKey]);
|
||||
$: if (mounted && loadKey !== lastPullRequestLoadKey) {
|
||||
lastPullRequestLoadKey = loadKey;
|
||||
restoreCache();
|
||||
void refresh();
|
||||
}
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
const timer = window.setInterval(() => { if (!loading) void refresh(); }, 3 * 60_000);
|
||||
return () => window.clearInterval(timer);
|
||||
});
|
||||
onDestroy(() => { mounted = false; ++pullRequestLoadGeneration; });
|
||||
|
||||
function restoreCache() {
|
||||
pullRequestBadges = {};
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null");
|
||||
if (saved?.sourceKey !== sourceKey || !saved?.badges) return;
|
||||
for (const repo of repos) {
|
||||
const badge = saved.badges[repo.path];
|
||||
if (badge?.status === "ready" && Number.isInteger(badge.count) && badge.count >= 0
|
||||
&& typeof badge.repository === "string" && typeof badge.sourceId === "string"
|
||||
&& integrationSources.some(source => source.id === badge.sourceId)) {
|
||||
pullRequestBadges[repo.path] = { ...badge, message: "" };
|
||||
}
|
||||
}
|
||||
} catch { /* Cache is optional. */ }
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading = true;
|
||||
const pending = loadPullRequestBadges();
|
||||
const generation = pullRequestLoadGeneration;
|
||||
try { await pending; }
|
||||
finally {
|
||||
if (generation === pullRequestLoadGeneration) {
|
||||
loading = false;
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ sourceKey, badges: pullRequestBadges })); }
|
||||
catch { /* Keep in-memory values if storage is unavailable. */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
if (state.status === "error" && pullRequestBadges[path]?.status === "ready") {
|
||||
state = { ...pullRequestBadges[path], message: state.message };
|
||||
}
|
||||
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, pullRequestBadges[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 withTimeout(listRemotes(repo.path), 15_000, "Remote lookup timed out.");
|
||||
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);
|
||||
}
|
||||
}));
|
||||
|
||||
if (generation !== pullRequestLoadGeneration) return;
|
||||
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);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -33,7 +33,7 @@
|
||||
<style>
|
||||
.repo-loading {
|
||||
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;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
@@ -47,8 +47,8 @@
|
||||
color-mix(in srgb, var(--color-accent) 13%, transparent),
|
||||
transparent 38%
|
||||
),
|
||||
var(--app-dialog-backdrop);
|
||||
backdrop-filter: blur(5px);
|
||||
color-mix(in srgb, var(--app-dialog-bg) 78%, #05070a 22%);
|
||||
backdrop-filter: blur(9px);
|
||||
animation: overlay-in 180ms ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
<script lang="ts">
|
||||
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, Circle, Download, FolderGit2, GitBranch, GitPullRequest, LayoutGrid, List, Plus, Search, Star, TriangleAlert, Trash2, X } from "@lucide/svelte";
|
||||
import type { PullRequestBadgeState } from "../pullRequestBadges";
|
||||
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; }
|
||||
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 onOpenPullRequests: (repository: string, sourceId: string) => unknown;
|
||||
|
||||
const STORAGE_KEY = "gitty.dashboard.v1";
|
||||
let query = "";
|
||||
let viewMode: "cards" | "list" = "list";
|
||||
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>();
|
||||
export let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
|
||||
|
||||
$: 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" })),
|
||||
];
|
||||
loadPreferences();
|
||||
|
||||
function loadPreferences() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") as { workspaces?: unknown; assignments?: unknown; viewMode?: unknown };
|
||||
viewMode = saved.viewMode === "cards" ? "cards" : "list";
|
||||
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, viewMode })); }
|
||||
catch { /* Preferences remain optional when storage is unavailable. */ }
|
||||
}
|
||||
|
||||
function setViewMode(mode: "cards" | "list") {
|
||||
viewMode = mode;
|
||||
savePreferences();
|
||||
}
|
||||
|
||||
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 pullRequestTitle(badge: PullRequestBadgeState | undefined): string {
|
||||
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;
|
||||
const title = de ? `${badge.count} offene Pull Requests` : `${badge.count} open pull requests`;
|
||||
return badge.message ? `${title} (${de ? "Letzter bekannter Stand" : "Last known status"}: ${badge.message})` : title;
|
||||
}
|
||||
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="view-switch" role="group" aria-label={de ? "Repository-Ansicht" : "Repository view"}>
|
||||
<button type="button" class:active={viewMode === "list"} aria-pressed={viewMode === "list"} aria-label={de ? "Listenansicht" : "List view"} title={de ? "Listenansicht" : "List view"} onclick={() => setViewMode("list")}><List size={16} /></button>
|
||||
<button type="button" class:active={viewMode === "cards"} aria-pressed={viewMode === "cards"} aria-label={de ? "Kachelansicht" : "Card view"} title={de ? "Kachelansicht" : "Card view"} onclick={() => setViewMode("cards")}><LayoutGrid size={15} /></button>
|
||||
</div>
|
||||
<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" class:table-view={viewMode === "list"}>
|
||||
{#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" class:list-view={viewMode === "list"} class:tiles-view={viewMode === "cards"}>
|
||||
{#each category.repos as repo (repo.path)}
|
||||
{@const pullRequests = pullRequestBadges[repo.path]}
|
||||
<article class="repo-card" class:open={repo.isOpen} class:favorite-category={category.id === "favorites"}>
|
||||
<button class="card-main" type="button" onclick={() => openCard(repo.path)} disabled={isBusy}>
|
||||
<span class="card-title"><FolderGit2 size={15} /><strong title={repo.path}>{repo.name}</strong></span>
|
||||
<span class="card-branch" title={repo.branch ?? undefined}><GitBranch size={13} />{repo.branch ?? (de ? "Branch unbekannt" : "Unknown branch")}</span>
|
||||
<span class="card-status">
|
||||
{#if !repo.known}<span class="muted"><Circle class="filled" size={9} />{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}
|
||||
</span>
|
||||
<span class="list-sync">{#if repo.known}<span class:ahead={repo.ahead > 0} title={de ? `${repo.ahead} Commits voraus` : `${repo.ahead} commits ahead`}><ArrowUp size={14} />{repo.ahead}</span><span class:behind={repo.behind > 0} title={de ? `${repo.behind} Commits zurück` : `${repo.behind} commits behind`}><ArrowDown size={14} />{repo.behind}</span>{:else}<span class="muted">—</span>{#if viewMode === "cards"}<span class="muted">—</span>{/if}{/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(pullRequests)}`} title={pullRequestTitle(pullRequests)}>
|
||||
{#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 && category.id === "open"}<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:20px}.section-header{min-height:31px;margin-bottom:6px}.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-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{color:#eeb94e}.behind{color:var(--color-sync-behind)}.clean{color:#68c878}.ahead{color:var(--color-sync-ahead)}.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}}
|
||||
.view-switch{display:flex;flex:0 0 auto;height:30px;border:1px solid var(--color-border-input);background:var(--app-input-bg)}
|
||||
.view-switch button{display:grid;width:32px;min-height:0;height:28px;place-items:center;padding:0;border:0;border-radius:0;color:var(--color-ink-muted);background:transparent}
|
||||
.view-switch button+button{border-left:1px solid var(--color-border-subtle)}
|
||||
.view-switch button.active{color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 12%,var(--app-input-bg));box-shadow:inset 0 -2px var(--color-accent)}
|
||||
.view-switch button:hover{color:var(--color-ink);background:var(--color-surface-hover)}
|
||||
.view-switch button:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px}
|
||||
/* Approved list design: shared columns across every category. */
|
||||
.table-view{--repo-list-columns:minmax(180px,1.8fr) minmax(140px,1fr) minmax(150px,1fr) minmax(110px,.75fr)}
|
||||
.table-view .repo-card{display:grid;grid-template-columns:minmax(0,1fr) minmax(170px,22%)}
|
||||
.list-view .card-main{display:grid;grid-template-columns:var(--repo-list-columns);gap:16px;padding:0 18px;align-items:center}
|
||||
.repo-grid.list-view{display:block;border:0}
|
||||
.table-view .repo-card{height:auto;min-height:48px;border:0;border-bottom:1px solid var(--color-border-subtle);background:transparent}
|
||||
.table-view .repo-card:hover,.table-view .repo-card:focus-within{background:color-mix(in srgb,var(--color-accent) 5%,var(--color-surface))}
|
||||
.table-view .repo-card:focus-within{box-shadow:inset 2px 0 var(--color-accent)}
|
||||
.list-view .card-main{height:auto;min-height:48px;min-width:0;justify-content:stretch;align-content:center;text-align:left}
|
||||
.list-view .card-title{min-width:0;gap:9px}.list-view .card-title strong{font-size:12px;font-weight:500}.list-view .card-title :global(svg){flex:none}
|
||||
.list-view .card-branch{min-width:0;font-size:12px}.list-view .card-branch :global(svg){flex:none}
|
||||
.list-view .card-status{margin:0;gap:5px;font-size:12px;flex-wrap:nowrap;white-space:nowrap}.list-view .card-status .muted{font-style:italic}
|
||||
.list-sync{display:flex;align-items:center;gap:18px;color:var(--color-ink-faint);font-size:12px}.list-sync>span{display:flex;align-items:center;gap:5px}
|
||||
.list-view .card-actions{position:static;display:grid;grid-template-columns:minmax(60px,1fr) 40px 40px;gap:0;align-items:center;margin:0}
|
||||
.list-view .card-actions .pr-badge{justify-self:start;justify-content:flex-start;padding:0;gap:8px;color:var(--color-accent)}
|
||||
.list-view .pr-badge span{min-width:18px;padding:1px 4px;border:1px solid var(--color-border);border-radius:2px;color:var(--color-ink-muted);font-size:11px;text-align:center}
|
||||
.list-view .card-actions button{height:28px;width:28px}.list-view .card-actions button :global(svg){width:16px;height:16px}
|
||||
.list-view .card-actions button.active :global(svg){fill:var(--color-accent)}
|
||||
.table-view .category-empty{border:0;border-bottom:1px solid var(--color-border-subtle);background:transparent}
|
||||
.list-view .card-actions .pr-badge{width:auto;min-width:60px}.list-view .card-actions .pr-badge :global(svg){flex:0 0 16px}
|
||||
/* Approved tile layout: repository identity above a shared status footer. */
|
||||
.tiles-view{gap:12px}
|
||||
.tiles-view .repo-card{height:150px;border-radius:3px;background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))}
|
||||
.tiles-view .repo-card:focus-within{box-shadow:inset 3px 0 var(--color-accent)}
|
||||
.tiles-view .card-main{display:grid;grid-template-columns:minmax(0,1fr) 104px 42px;grid-template-rows:24px 24px 1fr 30px;gap:0 10px;padding:16px 16px 10px;align-items:center;align-content:stretch}
|
||||
.tiles-view .card-main:after{position:absolute;left:16px;right:16px;bottom:51px;height:1px;background:var(--color-border);content:"";pointer-events:none}
|
||||
.tiles-view .card-title{grid-column:1/-1;grid-row:1;min-width:0;padding:0 60px 0 48px;max-width:100%;display:block}
|
||||
.tiles-view .card-title strong{display:block;font-size:13px;font-weight:650;line-height:22px}
|
||||
.tiles-view .card-title>:global(svg){position:absolute;top:21px;left:18px;width:32px;height:32px;color:var(--color-ink-muted);stroke-width:1.4}
|
||||
.tiles-view .card-branch{grid-column:1/-1;grid-row:2;padding-left:48px;min-width:0;display:block;overflow:hidden;text-overflow:ellipsis;font-size:11px;line-height:22px}
|
||||
.tiles-view .card-branch>:global(svg){display:inline-block;margin-right:6px;vertical-align:-2px}
|
||||
.tiles-view .card-status{grid-column:1;grid-row:4;min-width:0;flex-wrap:nowrap;margin:0;font-size:11px;gap:0;white-space:nowrap}
|
||||
.tiles-view .card-status>span{min-width:0;overflow:hidden;text-overflow:ellipsis}
|
||||
.tiles-view .list-sync{grid-column:2;grid-row:4;gap:16px;font-size:11px}
|
||||
.tiles-view .card-actions{position:static;display:contents}
|
||||
.tiles-view .card-actions button{position:absolute;top:14px;right:14px;width:26px;height:26px}
|
||||
.tiles-view .card-actions button[aria-pressed]{right:44px}
|
||||
.tiles-view .card-actions .pr-badge{top:auto;bottom:12px;right:14px;width:42px;min-width:0;height:26px;gap:6px;padding:0;font-size:12px;color:var(--color-ink-muted)}
|
||||
.tiles-view .card-actions .pr-badge.has-open-prs{color:var(--color-accent)}
|
||||
.tiles-view .card-actions .pr-badge.pr-error{color:#e0a35b}
|
||||
.tiles-view .card-actions .pr-badge>:global(svg){width:16px;height:16px;flex:none}
|
||||
.tiles-view .card-actions button.active>:global(svg){fill:var(--color-accent)}
|
||||
.table-view{--repo-list-columns:minmax(180px,1.8fr) minmax(140px,1fr) minmax(150px,1fr) minmax(110px,.75fr)}
|
||||
.table-view .dashboard-section{min-width:900px}
|
||||
.list-view .card-title strong,.list-view .card-branch,.list-view .card-status{font-size:13px}
|
||||
.list-view .card-actions .pr-badge.pr-error{color:#e0a35b}
|
||||
.list-view .card-actions .pr-badge:disabled{color:var(--color-ink-faint)}
|
||||
@media(min-width:1001px) and (max-width:1150px){.tiles-view{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media(max-width:420px){.tiles-view .card-main{grid-template-columns:minmax(0,1fr) 82px 34px;gap:0 6px;padding-right:12px;padding-left:12px}.tiles-view .list-sync{gap:8px}.tiles-view .card-title{padding-left:42px}.tiles-view .card-branch{padding-left:42px}.tiles-view .card-title>:global(svg){left:14px;width:28px;height:28px}.tiles-view .card-status{font-size:10px}}
|
||||
.tiles-view .card-title strong{font-size:14px}
|
||||
.tiles-view .card-branch,.tiles-view .card-status,.tiles-view .list-sync{font-size:12px}
|
||||
.tiles-view .card-status .changed,.tiles-view .card-status .clean{color:var(--color-ink-muted)}
|
||||
.tiles-view .card-status .changed>:global(svg){color:#eeb94e;flex:none}.tiles-view .card-status .clean>:global(svg){color:#68c878;flex:none}
|
||||
.tiles-view .favorite-category .card-actions button[aria-pressed]{right:14px}
|
||||
.tiles-view .favorite-category .card-title{padding-right:30px}
|
||||
.list-view .card-status .changed,.list-view .card-status .clean{color:var(--color-ink-muted)}
|
||||
.list-view .card-status .changed>:global(svg){color:#eeb94e}.list-view .card-status .clean>:global(svg){color:#68c878}
|
||||
</style>
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { AlertCircle, Check, ExternalLink, GitMerge, LoaderCircle, X } from "@lucide/svelte";
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { AlertCircle, Check, ChevronDown, ChevronLeft, ChevronRight, ExternalLink, FileText, GitMerge, LoaderCircle, Pencil, X } from "@lucide/svelte";
|
||||
import type { ConflictChoice, ConflictFile, ConflictPart, GitFileStatus, PreparedResolution } from "../types";
|
||||
|
||||
type ConflictRegion = Extract<ConflictPart, { kind: "conflict" }>;
|
||||
type ResolveSplitRow =
|
||||
| { type: "marker"; conflictIndex: number }
|
||||
| {
|
||||
@@ -53,9 +52,33 @@
|
||||
let resolveContent = $state("");
|
||||
let manualMode = $state(false);
|
||||
let binarySide = $state<"ours" | "theirs" | null>(null);
|
||||
let resolveBeforePane = $state<HTMLDivElement | null>(null);
|
||||
let resolveAfterPane = $state<HTMLDivElement | null>(null);
|
||||
let isSyncingResolveScroll = false;
|
||||
let dialogElement: HTMLDivElement;
|
||||
onMount(() => {
|
||||
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
dialogElement.focus();
|
||||
return () => previousFocus?.focus();
|
||||
});
|
||||
function trapFocus(event: KeyboardEvent) {
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = Array.from(dialogElement.querySelectorAll<HTMLElement>('button:not(:disabled), textarea:not(:disabled), [tabindex="0"]')).filter(element => element.getClientRects().length > 0);
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (!first) { event.preventDefault(); return; }
|
||||
if (event.shiftKey && (document.activeElement === first || document.activeElement === dialogElement)) { event.preventDefault(); last.focus(); }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
||||
}
|
||||
let comparison = $state<HTMLDivElement | null>(null);
|
||||
let activeConflict = $state(0);
|
||||
let bothMenu = $state<number | null>(null);
|
||||
const t = (de: string, en: string) => isGerman ? de : en;
|
||||
let previewLines = $derived(manualMode ? resolveContent.split("\n") : conflictParts.flatMap(part =>
|
||||
part.kind === "text" ? part.lines : conflictChoices[part.index] == null
|
||||
? [`[${t("Konflikt", "Conflict")} ${part.index + 1} ${t("noch offen", "unresolved")}]`]
|
||||
: buildResolution([part], conflictChoices).split("\n")));
|
||||
function navigateConflict(direction: number) {
|
||||
activeConflict = Math.max(0, Math.min(conflictRegionCount - 1, activeConflict + direction));
|
||||
comparison?.querySelector(`[data-conflict="${activeConflict}"]`)?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const c = conflict;
|
||||
@@ -63,6 +86,8 @@
|
||||
const parts = conflictParts;
|
||||
|
||||
untrack(() => {
|
||||
activeConflict = 0;
|
||||
bothMenu = null;
|
||||
if (!c) {
|
||||
conflictChoices = [];
|
||||
resolveContent = "";
|
||||
@@ -95,7 +120,6 @@
|
||||
});
|
||||
|
||||
let conflictRegionCount = $derived(conflictParts.filter((p) => p.kind === "conflict").length);
|
||||
let conflictRegions = $derived(conflictOnlyParts(conflictParts));
|
||||
let resolveSplitRows = $derived(buildResolveSplitRows(conflictParts));
|
||||
let unresolvedCount = $derived(manualMode ? 0 : conflictChoices.filter((c) => c == null).length);
|
||||
let resolvedContent = $derived(manualMode ? resolveContent : buildResolution(conflictParts, conflictChoices));
|
||||
@@ -147,10 +171,6 @@
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function conflictOnlyParts(parts: ConflictPart[]): ConflictRegion[] {
|
||||
return parts.filter((part): part is ConflictRegion => part.kind === "conflict");
|
||||
}
|
||||
|
||||
function buildResolveSplitRows(parts: ConflictPart[]): ResolveSplitRow[] {
|
||||
const rows: ResolveSplitRow[] = [];
|
||||
let leftNum = 0;
|
||||
@@ -174,7 +194,6 @@
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({ type: "marker", conflictIndex: part.index });
|
||||
const count = Math.max(part.oursLines.length, part.theirsLines.length);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const hasOurs = i < part.oursLines.length;
|
||||
@@ -192,36 +211,22 @@
|
||||
rightKind: hasTheirs ? "theirs" : "empty",
|
||||
});
|
||||
}
|
||||
rows.push({ type: "marker", conflictIndex: part.index });
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function syncResolveScroll(source: "before" | "after") {
|
||||
if (isSyncingResolveScroll) return;
|
||||
const sourcePane = source === "before" ? resolveBeforePane : resolveAfterPane;
|
||||
const targetPane = source === "before" ? resolveAfterPane : resolveBeforePane;
|
||||
if (!sourcePane || !targetPane) return;
|
||||
|
||||
isSyncingResolveScroll = true;
|
||||
targetPane.scrollTop = sourcePane.scrollTop;
|
||||
targetPane.scrollLeft = sourcePane.scrollLeft;
|
||||
requestAnimationFrame(() => {
|
||||
isSyncingResolveScroll = false;
|
||||
});
|
||||
}
|
||||
|
||||
function legacyConflictParts(): any[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function setConflictChoice(index: number, choice: ConflictChoice) {
|
||||
activeConflict = index;
|
||||
bothMenu = null;
|
||||
const next = [...conflictChoices];
|
||||
next[index] = choice;
|
||||
conflictChoices = next;
|
||||
}
|
||||
|
||||
function setAllConflicts(choice: ConflictChoice) {
|
||||
bothMenu = null;
|
||||
conflictChoices = conflictParts.filter((p) => p.kind === "conflict").map(() => choice);
|
||||
}
|
||||
|
||||
@@ -258,266 +263,121 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
style="grid-template-rows: auto minmax(0,1fr) auto;"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Resolve conflicts"
|
||||
tabindex="-1"
|
||||
>
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Resolve</span>
|
||||
<h2 class="dialog-title">Conflicts</h2>
|
||||
<svelte:window onkeydown={(event) => { if (event.key === "Escape") { if (bothMenu !== null) bothMenu = null; else if (!isBusy) onClose(); } }} onclick={() => { bothMenu = null; }} />
|
||||
|
||||
{#snippet bothChoices(index: number)}
|
||||
<div class="both-control">
|
||||
<button type="button" aria-haspopup="menu" aria-expanded={bothMenu === index} disabled={isBusy} onclick={(event) => { event.stopPropagation(); bothMenu = bothMenu === index ? null : index; }}>
|
||||
{index === -1 ? t("Beide", "Both") : t("Beide übernehmen", "Accept both")}<ChevronDown size={14} />
|
||||
</button>
|
||||
{#if bothMenu === index}
|
||||
<div class="both-menu" role="menu">
|
||||
<button type="button" role="menuitem" onclick={() => index === -1 ? setAllConflicts("both-ot") : setConflictChoice(index, "both-ot")}>{t("Aktuell, dann eingehend", "Current, then incoming")}</button>
|
||||
<button type="button" role="menuitem" onclick={() => index === -1 ? setAllConflicts("both-to") : setConflictChoice(index, "both-to")}>{t("Eingehend, dann aktuell", "Incoming, then current")}</button>
|
||||
</div>
|
||||
<div class="dialog-header-actions">
|
||||
{#if conflictTarget}
|
||||
<div class="tool-surface-choice" aria-label={isGerman ? "Konflikt bearbeiten mit" : "Edit conflict with"}>
|
||||
<span>{isGerman ? "Bearbeiten mit" : "Edit with"}</span>
|
||||
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty bearbeiten" : "Edit in Gitty"}>
|
||||
<GitMerge size={13} aria-hidden="true" />Gitty
|
||||
</button>
|
||||
<button type="button" onclick={() => onExternalMerge(conflictTarget)} disabled={isBusy} title={isGerman ? `In ${mergeName} öffnen` : `Open in ${mergeName}`}>
|
||||
<ExternalLink size={13} aria-hidden="true" /><span>{mergeName}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Konflikte schließen" : "Close conflicts"}>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="dialog-backdrop app-chrome-backdrop conflict-backdrop">
|
||||
<div class="conflict-workbench" bind:this={dialogElement} onkeydown={trapFocus} role="dialog" aria-modal="true" aria-label={t("Konflikte lösen", "Resolve conflicts")} tabindex="-1">
|
||||
<header class="workbench-header">
|
||||
<h2><GitMerge size={24} />{t("Konflikte lösen", "Resolve conflicts")}</h2>
|
||||
<div class="header-actions">
|
||||
{#if conflictTarget}<button type="button" onclick={() => onExternalMerge(conflictTarget)} disabled={isBusy} title={mergeName}><ExternalLink size={18} />{t("Externes Merge-Tool", "External merge tool")}</button>{/if}
|
||||
<button class="close-button" type="button" onclick={onClose} disabled={isBusy} aria-label={t("Schließen", "Close")}><X size={22} /></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if conflictedFiles.length === 0}
|
||||
<div class="blank-state">All conflicts resolved. Continue the current operation when ready.</div>
|
||||
<div class="empty-message">{t("Alle Konflikte gelöst. Du kannst den aktuellen Vorgang fortsetzen.", "All conflicts resolved. You can continue the current operation.")}</div>
|
||||
{:else}
|
||||
<div class="dialog-body">
|
||||
<aside class="dialog-files" aria-label="Conflicted files">
|
||||
{#each conflictedFiles as file (file.path)}
|
||||
<button
|
||||
class="dialog-file-row"
|
||||
class:active={conflictTarget === file.path}
|
||||
class:prepared={preparedResolutions[file.path] != null}
|
||||
type="button"
|
||||
onclick={() => onSelectFile(file.path)}
|
||||
disabled={isBusy}
|
||||
title={file.path}
|
||||
>
|
||||
<span class={`status-badge ${preparedResolutions[file.path] ? "added" : "conflicted"}`}>
|
||||
{preparedResolutions[file.path] ? "ready" : "conflicted"}
|
||||
</span>
|
||||
<strong>{file.path}</strong>
|
||||
{#if preparedResolutions[file.path]}
|
||||
<Check size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</aside>
|
||||
|
||||
<div class="resolve-editor" aria-label="Conflict editor">
|
||||
{#if !conflict}
|
||||
<div class="blank-state">Select a file to resolve.</div>
|
||||
{:else if conflict.binary}
|
||||
<div class="resolve-binary">
|
||||
<div class="resolve-binary-note">
|
||||
<AlertCircle size={16} aria-hidden="true" />
|
||||
<span>Binary file — pick which version to keep, then mark it resolved.</span>
|
||||
</div>
|
||||
<div class="resolve-binary-options">
|
||||
<button
|
||||
class="resolve-binary-card ours"
|
||||
class:active={binarySide === "ours"}
|
||||
type="button"
|
||||
onclick={() => { binarySide = "ours"; }}
|
||||
disabled={isBusy || conflict.ours_size == null}
|
||||
>
|
||||
<span class="resolve-side-label">Current (ours)</span>
|
||||
<strong>{formatBytes(conflict.ours_size)}</strong>
|
||||
<span class="resolve-binary-hint">{conflict.ours_size == null ? "Deleted on this side" : "Keep this version"}</span>
|
||||
</button>
|
||||
<button
|
||||
class="resolve-binary-card theirs"
|
||||
class:active={binarySide === "theirs"}
|
||||
type="button"
|
||||
onclick={() => { binarySide = "theirs"; }}
|
||||
disabled={isBusy || conflict.theirs_size == null}
|
||||
>
|
||||
<span class="resolve-side-label">Incoming (theirs)</span>
|
||||
<strong>{formatBytes(conflict.theirs_size)}</strong>
|
||||
<span class="resolve-binary-hint">{conflict.theirs_size == null ? "Deleted on this side" : "Keep this version"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="resolve-actions">
|
||||
<span class="resolve-path" title={conflictTarget}>{conflictTarget}</span>
|
||||
{#if currentPrepared}
|
||||
<span class="prepared-tag"><Check size={14} aria-hidden="true" /> Prepared</span>
|
||||
{/if}
|
||||
<button type="button" onclick={handleMarkResolved} disabled={!canMarkResolved}>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{currentPrepared ? "Update decision" : "Mark as resolved"}
|
||||
<div class="workbench-body">
|
||||
<aside class="file-sidebar" aria-label={t("Dateien mit Konflikten", "Conflicted files")}>
|
||||
<div class="files-heading">{t("Dateien", "Files")} · {conflictedFiles.length}</div>
|
||||
<div class="file-list">
|
||||
{#each conflictedFiles as file (file.path)}
|
||||
{@const prepared = preparedResolutions[file.path] != null}
|
||||
<button class="file-item" class:selected={conflictTarget === file.path} type="button" onclick={() => onSelectFile(file.path)} disabled={isBusy} title={file.path}>
|
||||
<FileText size={19} /><span class="file-copy"><strong>{file.path}</strong><small class:ready={prepared}>{prepared ? t("Vorbereitet", "Prepared") : conflictTarget === file.path ? t("In Bearbeitung", "In progress") : t("Offen", "Open")}</small></span>
|
||||
{#if prepared}<Check class="ready" size={20} />{:else}<span class="open-dot"></span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="file-progress">{preparedCount} {t("von", "of")} {conflictedFiles.length} {t("vorbereitet", "prepared")}</div>
|
||||
</aside>
|
||||
<main class="conflict-main">
|
||||
<header class="file-header">
|
||||
<h3 title={conflictTarget}>{conflictTarget || t("Datei auswählen", "Select a file")}</h3>
|
||||
{#if conflictRegionCount > 0 && !manualMode}
|
||||
<nav aria-label={t("Konfliktnavigation", "Conflict navigation")}><span>{t("Konflikt", "Conflict")} {activeConflict + 1} {t("von", "of")} {conflictRegionCount}</span><button type="button" aria-label={t("Vorheriger Konflikt", "Previous conflict")} disabled={activeConflict === 0} onclick={() => navigateConflict(-1)}><ChevronLeft size={20} /></button><button type="button" aria-label={t("Nächster Konflikt", "Next conflict")} disabled={activeConflict >= conflictRegionCount - 1} onclick={() => navigateConflict(1)}><ChevronRight size={20} /></button></nav>
|
||||
{/if}
|
||||
</header>
|
||||
{#if !conflict}
|
||||
<div class="empty-message">{t("Wähle eine Datei aus, um ihren Konflikt zu lösen.", "Select a file to resolve its conflict.")}</div>
|
||||
{:else if conflict.binary}
|
||||
<div class="binary-content"><p><AlertCircle size={18} />{t("Binärdatei – wähle die Version, die erhalten bleiben soll.", "Binary file — choose the version to keep.")}</p>
|
||||
<div class="binary-options">{#each ["ours", "theirs"] as side}
|
||||
{@const size = side === "ours" ? conflict.ours_size : conflict.theirs_size}
|
||||
<button type="button" class:chosen={binarySide === side} disabled={isBusy || size == null} onclick={() => { binarySide = side as "ours" | "theirs"; }}><strong>{side === "ours" ? t("Aktuell (ours)", "Current (ours)") : t("Eingehend (theirs)", "Incoming (theirs)")}</strong><span>{size == null ? t("Auf dieser Seite gelöscht", "Deleted on this side") : formatBytes(size)}</span>{#if binarySide === side}<Check size={20} />{/if}</button>
|
||||
{/each}</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="resolve-toolbar">
|
||||
{#if manualMode}
|
||||
<button type="button" onclick={() => { manualMode = false; }} disabled={isBusy}>Back to guided</button>
|
||||
{:else}
|
||||
<span class="resolve-toolbar-label">Apply to all:</span>
|
||||
<button type="button" onclick={() => setAllConflicts("ours")} disabled={isBusy}>Current</button>
|
||||
<button type="button" onclick={() => setAllConflicts("theirs")} disabled={isBusy}>Incoming</button>
|
||||
<button type="button" onclick={() => setAllConflicts("both-ot")} disabled={isBusy}>Both</button>
|
||||
<button type="button" onclick={enableManualEdit} disabled={isBusy}>Edit manually</button>
|
||||
{/if}
|
||||
<span class="resolve-status">
|
||||
{#if unresolvedCount > 0}
|
||||
<AlertCircle size={14} aria-hidden="true" />
|
||||
{unresolvedCount} of {conflictRegionCount} unresolved
|
||||
{:else if resolveHasMarkers}
|
||||
<AlertCircle size={14} aria-hidden="true" />
|
||||
Conflict markers still present
|
||||
{:else}
|
||||
<Check size={14} aria-hidden="true" />
|
||||
{conflictRegionCount} {conflictRegionCount === 1 ? "conflict" : "conflicts"} resolved
|
||||
{/if}
|
||||
</span>
|
||||
<div class="comparison-toolbar">
|
||||
{#if manualMode}<button type="button" onclick={() => { manualMode = false; }} disabled={isBusy}>{t("Zurück zur Auswahl", "Back to guided")}</button>
|
||||
{:else}<span>{t("Für alle Konflikte", "For all conflicts")}</span><div class="bulk-actions"><button type="button" onclick={() => setAllConflicts("ours")} disabled={isBusy}>{t("Aktuell", "Current")}</button><button type="button" onclick={() => setAllConflicts("theirs")} disabled={isBusy}>{t("Eingehend", "Incoming")}</button>{@render bothChoices(-1)}</div><button class="manual-button" type="button" onclick={enableManualEdit} disabled={isBusy}><Pencil size={16} />{t("Manuell bearbeiten", "Edit manually")}</button>{/if}
|
||||
</div>
|
||||
|
||||
{#if manualMode}
|
||||
<textarea
|
||||
class="resolve-textarea"
|
||||
bind:value={resolveContent}
|
||||
spellcheck="false"
|
||||
disabled={isBusy}
|
||||
></textarea>
|
||||
<textarea class="manual-editor" aria-label={t("Konflikt manuell bearbeiten", "Edit conflict manually")} bind:value={resolveContent} spellcheck="false" disabled={isBusy}></textarea>
|
||||
{:else}
|
||||
<div class="resolve-structured">
|
||||
{#if conflictRegions.length > 0}
|
||||
<div class="resolve-conflict-controls" aria-label="Conflict decisions">
|
||||
{#each conflictRegions as part (part.index)}
|
||||
<div class="resolve-conflict-bar" class:unresolved={conflictChoices[part.index] == null}>
|
||||
<span class="resolve-conflict-label">Conflict {part.index + 1}</span>
|
||||
<div class="resolve-choice-buttons">
|
||||
<button type="button" class:active={conflictChoices[part.index] === "ours"} onclick={() => setConflictChoice(part.index, "ours")} disabled={isBusy}>Current</button>
|
||||
<button type="button" class:active={conflictChoices[part.index] === "theirs"} onclick={() => setConflictChoice(part.index, "theirs")} disabled={isBusy}>Incoming</button>
|
||||
<button type="button" class:active={conflictChoices[part.index] === "both-ot"} onclick={() => setConflictChoice(part.index, "both-ot")} disabled={isBusy} title="Both - current first">Both C+I</button>
|
||||
<button type="button" class:active={conflictChoices[part.index] === "both-to"} onclick={() => setConflictChoice(part.index, "both-to")} disabled={isBusy} title="Both - incoming first">Both I+C</button>
|
||||
</div>
|
||||
<div class="comparison-scroll" bind:this={comparison}>
|
||||
<div class="comparison-grid">
|
||||
<div class="side-heading ours-heading">{t("Aktuell (ours)", "Current (ours)")}</div><div class="side-heading theirs-heading">{t("Eingehend (theirs)", "Incoming (theirs)")}</div>
|
||||
{#each resolveSplitRows as row, i (i)}
|
||||
{#if row.type === "marker"}
|
||||
{@const choice = conflictChoices[row.conflictIndex]}
|
||||
<div class="conflict-decision" data-conflict={row.conflictIndex}>
|
||||
<strong>{t("Konflikt", "Conflict")} {row.conflictIndex + 1}{#if choice == null}<span class="pending"> · {t("Offen", "Open")}</span>{/if}</strong>
|
||||
<button class="accept-ours" class:chosen={choice === "ours"} aria-pressed={choice === "ours"} type="button" disabled={isBusy} onclick={() => setConflictChoice(row.conflictIndex, "ours")}>{t("Aktuell übernehmen", "Accept current")}</button>
|
||||
<button class="accept-theirs" class:chosen={choice === "theirs"} aria-pressed={choice === "theirs"} type="button" disabled={isBusy} onclick={() => setConflictChoice(row.conflictIndex, "theirs")}>{t("Eingehend übernehmen", "Accept incoming")}</button>
|
||||
{@render bothChoices(row.conflictIndex)}
|
||||
{#if choice}<small class="decision-label">{choice === "ours" ? t("Aktuell gewählt", "Current selected") : choice === "theirs" ? t("Eingehend gewählt", "Incoming selected") : choice === "both-ot" ? t("Aktuell → Eingehend", "Current → Incoming") : t("Eingehend → Aktuell", "Incoming → Current")}</small>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="resolve-split" role="table" aria-label="Full conflict comparison">
|
||||
<div
|
||||
class="resolve-pane"
|
||||
bind:this={resolveBeforePane}
|
||||
aria-label="Current file content"
|
||||
onscroll={() => syncResolveScroll("before")}
|
||||
>
|
||||
<div class="resolve-pane-grid">
|
||||
{#each resolveSplitRows as row, i (`left-${i}`)}
|
||||
{#if row.type === "marker"}
|
||||
<div class="resolve-split-marker" class:unresolved={conflictChoices[row.conflictIndex] == null}>Conflict {row.conflictIndex + 1}</div>
|
||||
{:else}
|
||||
<div class="resolve-num" class:ours={row.leftKind === "ours"} class:empty={row.leftKind === "empty"}>{row.leftNum ?? ""}</div>
|
||||
<div
|
||||
class="resolve-cell"
|
||||
class:ours={row.leftKind === "ours"}
|
||||
class:empty={row.leftKind === "empty"}
|
||||
class:dimmed={row.conflictIndex != null && !oursActive(conflictChoices[row.conflictIndex])}
|
||||
>{displayLine(row.leftText ?? "") || " "}</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="resolve-pane"
|
||||
bind:this={resolveAfterPane}
|
||||
aria-label="Incoming file content"
|
||||
onscroll={() => syncResolveScroll("after")}
|
||||
>
|
||||
<div class="resolve-pane-grid">
|
||||
{#each resolveSplitRows as row, i (`right-${i}`)}
|
||||
{#if row.type === "marker"}
|
||||
<div class="resolve-split-marker" class:unresolved={conflictChoices[row.conflictIndex] == null}>Conflict {row.conflictIndex + 1}</div>
|
||||
{:else}
|
||||
<div class="resolve-num" class:theirs={row.rightKind === "theirs"} class:empty={row.rightKind === "empty"}>{row.rightNum ?? ""}</div>
|
||||
<div
|
||||
class="resolve-cell"
|
||||
class:theirs={row.rightKind === "theirs"}
|
||||
class:empty={row.rightKind === "empty"}
|
||||
class:dimmed={row.conflictIndex != null && !theirsActive(conflictChoices[row.conflictIndex])}
|
||||
>{displayLine(row.rightText ?? "") || " "}</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if false}
|
||||
{#each legacyConflictParts() as part, partIndex (partIndex)}
|
||||
{#if part.kind === "text"}
|
||||
{#if part.lines.length > 0}
|
||||
<pre class="resolve-context">{#each part.lines as line}<span class="resolve-line context">{displayLine(line) || " "}</span>{/each}</pre>
|
||||
{:else}
|
||||
<div class="code-line" class:ours-line={row.leftKind === "ours"} class:dimmed={row.conflictIndex != null && conflictChoices[row.conflictIndex] != null && !oursActive(conflictChoices[row.conflictIndex])}><span>{row.leftNum ?? ""}</span><code>{displayLine(row.leftText ?? "") || " "}</code></div>
|
||||
<div class="code-line incoming-line" class:theirs-line={row.rightKind === "theirs"} class:dimmed={row.conflictIndex != null && conflictChoices[row.conflictIndex] != null && !theirsActive(conflictChoices[row.conflictIndex])}><span>{row.rightNum ?? ""}</span><code>{displayLine(row.rightText ?? "") || " "}</code></div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="resolve-conflict" class:unresolved={conflictChoices[part.index] == null}>
|
||||
<div class="resolve-conflict-bar">
|
||||
<span class="resolve-conflict-label">Conflict {part.index + 1}</span>
|
||||
<div class="resolve-choice-buttons">
|
||||
<button type="button" class:active={conflictChoices[part.index] === "ours"} onclick={() => setConflictChoice(part.index, "ours")} disabled={isBusy}>Current</button>
|
||||
<button type="button" class:active={conflictChoices[part.index] === "theirs"} onclick={() => setConflictChoice(part.index, "theirs")} disabled={isBusy}>Incoming</button>
|
||||
<button type="button" class:active={conflictChoices[part.index] === "both-ot"} onclick={() => setConflictChoice(part.index, "both-ot")} disabled={isBusy} title="Both — current first">Both C+I</button>
|
||||
<button type="button" class:active={conflictChoices[part.index] === "both-to"} onclick={() => setConflictChoice(part.index, "both-to")} disabled={isBusy} title="Both — incoming first">Both I+C</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resolve-side ours" class:dimmed={!oursActive(conflictChoices[part.index])}>
|
||||
<span class="resolve-side-label">Current (ours)</span>
|
||||
<pre class="resolve-lines">{#each part.oursLines as line}<span class="resolve-line ours">{displayLine(line) || " "}</span>{/each}</pre>
|
||||
</div>
|
||||
<div class="resolve-side theirs" class:dimmed={!theirsActive(conflictChoices[part.index])}>
|
||||
<span class="resolve-side-label">Incoming (theirs)</span>
|
||||
<pre class="resolve-lines">{#each part.theirsLines as line}<span class="resolve-line theirs">{displayLine(line) || " "}</span>{/each}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="resolve-actions">
|
||||
<span class="resolve-path" title={conflictTarget}>{conflictTarget}</span>
|
||||
{#if currentPrepared}
|
||||
<span class="prepared-tag"><Check size={14} aria-hidden="true" /> Prepared</span>
|
||||
{/if}
|
||||
<button type="button" onclick={handleMarkResolved} disabled={!canMarkResolved}>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{currentPrepared ? "Update decision" : "Mark as resolved"}
|
||||
</button>
|
||||
</div>
|
||||
<section class="result-preview" aria-label={t("Ergebnisvorschau", "Result preview")}>
|
||||
<header><strong>{t("Ergebnisvorschau", "Result preview")}</strong><span class:pending={resolveHasMarkers} class:ready={!resolveHasMarkers} aria-live="polite">{manualMode && resolveHasMarkers ? t("Konfliktmarker vorhanden", "Conflict markers remain") : unresolvedCount > 0 ? `${unresolvedCount} ${t(unresolvedCount === 1 ? "Konflikt offen" : "Konflikte offen", unresolvedCount === 1 ? "conflict open" : "conflicts open")}` : t("Alle Konflikte gelöst", "All conflicts resolved")}</span></header>
|
||||
<div class="preview-code">{#each previewLines as line, i}<div class="code-line"><span>{i + 1}</span><code class:pending={line.startsWith(`[${t("Konflikt", "Conflict")} `)}>{displayLine(line) || " "}</code></div>{/each}</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{#if conflict}<div class="file-actions"><span>{!canMarkResolved ? t("Alle Konflikte dieser Datei lösen, dann vorbereiten.", "Resolve all conflicts in this file, then prepare it.") : currentPrepared ? t("Diese Datei ist vorbereitet.", "This file is prepared.") : t("Ergebnis prüfen und Datei vorbereiten.", "Review the result and prepare the file.")}</span><button type="button" disabled={!canMarkResolved} onclick={handleMarkResolved}>{currentPrepared ? t("Vorbereitung aktualisieren", "Update preparation") : t("Datei vorbereiten", "Prepare file")}</button></div>{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer class="dialog-footer">
|
||||
<span class="dialog-footer-info">{preparedCount} of {conflictedFiles.length} prepared</span>
|
||||
<button class="btn-primary" type="button" onclick={onApply} disabled={isBusy || preparedCount === 0}>
|
||||
{#if operation.startsWith("Resolving")}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Apply {preparedCount} resolved
|
||||
</button>
|
||||
</footer>
|
||||
<footer class="workbench-footer"><span>{t("Änderungen werden erst beim Anwenden gespeichert.", "Changes are saved only when applied.")}</span><button class="apply-button" type="button" onclick={onApply} disabled={isBusy || preparedCount === 0}>{#if isBusy}<LoaderCircle class="spin" size={16} />{/if}{preparedCount} {t(preparedCount === 1 ? "vorbereitete Lösung anwenden" : "vorbereitete Lösungen anwenden", preparedCount === 1 ? "prepared resolution to apply" : "prepared resolutions to apply")}</button></footer>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.conflict-workbench{--ours:var(--color-accent);--theirs:#b78ae6;display:flex;flex-direction:column;width:min(1700px,100%);height:min(960px,100%);min-width:0;overflow:hidden;border:1px solid var(--color-border-input);border-radius:6px;background:var(--app-dialog-bg);color:var(--color-ink);box-shadow:var(--app-dialog-shadow);font-size:13px}
|
||||
.conflict-workbench button{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:30px;padding:5px 10px;border:1px solid var(--color-border-input);border-radius:3px;background:transparent;color:var(--color-ink);font-size:13px;font-weight:400;white-space:nowrap}
|
||||
.conflict-workbench button:hover:not(:disabled){background:var(--color-surface-hover);border-color:var(--color-ink-faint)}.conflict-workbench button:disabled{opacity:.45;cursor:default}.conflict-workbench button:focus-visible{outline:2px solid var(--color-accent);outline-offset:-2px}
|
||||
.workbench-header{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:52px;padding:10px 16px;border-bottom:1px solid var(--color-border);background:var(--app-dialog-chrome)}h2{display:flex;align-items:center;gap:10px;margin:0;font-size:15px;font-weight:600}.header-actions{display:flex;gap:12px;align-items:center}.header-actions .close-button{border:0;padding:6px}
|
||||
.workbench-body{display:flex;flex:1;min-height:0}.file-sidebar{display:flex;flex-direction:column;flex:0 0 220px;min-width:0;border-right:1px solid var(--color-border);padding:0 10px}.files-heading{padding:14px 10px 12px;font-size:13px;color:var(--color-ink-muted)}.file-list{flex:1;overflow:auto}.file-sidebar .file-item{display:flex;width:100%;gap:12px;padding:10px 10px;border:1px solid transparent;align-items:flex-start;justify-content:flex-start;text-align:left;white-space:normal}.file-sidebar .file-item.selected{background:var(--color-surface);border-left-color:var(--color-accent)}.file-copy{display:grid;gap:5px;flex:1;min-width:0}.file-copy strong{font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px}.file-copy small{font-size:13px;color:#eda33d}.file-item :global(svg){flex-shrink:0}.open-dot{height:11px;width:11px;background:#eda33d;border-radius:50%;margin-top:5px;flex-shrink:0}.ready,.file-copy small.ready,.file-item :global(svg.ready){color:#68c878}.file-progress{border-top:1px solid var(--color-border);padding:12px 10px;color:var(--color-ink-muted)}
|
||||
.conflict-main{display:flex;flex-direction:column;flex:1;min-width:0;min-height:0}.file-header{display:flex;justify-content:space-between;align-items:center;gap:12px;margin:0 10px;padding:10px 6px;border-bottom:1px solid var(--color-border);min-height:48px}.file-header h3{font-size:14px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin:0}.file-header nav{display:flex;align-items:center;gap:12px;white-space:nowrap}.file-header nav span{margin-right:8px}.file-header nav button{padding:7px 9px}
|
||||
.comparison-toolbar{display:flex;gap:10px;align-items:center;padding:8px 12px;flex-wrap:wrap}.comparison-toolbar>span{color:var(--color-ink-muted)}.bulk-actions{display:flex}.bulk-actions>button+button{border-left:0}.comparison-toolbar .manual-button{margin-left:auto;border:0;color:var(--ours);padding-right:0}
|
||||
.comparison-scroll{flex:1.25;min-height:150px;overflow:auto;margin:0 10px;border:1px solid var(--color-border)}.comparison-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);align-content:start;min-width:760px}.side-heading{position:sticky;top:0;z-index:2;background:var(--app-dialog-bg);padding:7px 10px;border-top:2px solid var(--ours);font-size:13px}.theirs-heading{border-color:var(--theirs);border-left:1px solid var(--color-border)}
|
||||
.code-line{display:flex;min-width:0;line-height:20px;min-height:20px;font-family:var(--font-mono);font-size:13px}.code-line>span{flex:0 0 44px;text-align:right;padding-right:14px;color:var(--color-ink-faint);user-select:none}.code-line code{font:inherit;white-space:pre;overflow-x:auto;min-width:0;flex:1;padding-right:12px}.incoming-line{border-left:1px solid var(--color-border)}.ours-line{background:color-mix(in srgb,var(--ours) 13%,transparent)}.theirs-line{background:color-mix(in srgb,var(--theirs) 13%,transparent)}.dimmed code{opacity:.45}
|
||||
.conflict-decision{grid-column:1/-1;display:flex;align-items:center;flex-wrap:wrap;gap:10px;padding:7px 10px;border-top:1px solid var(--color-border);border-bottom:1px solid var(--color-border);background:color-mix(in srgb,var(--color-surface) 45%,var(--app-dialog-bg));scroll-margin-top:36px}.conflict-decision strong{font-weight:500;margin-right:12px}.pending{color:#eda33d}.conflict-decision .accept-ours{color:var(--ours);border-color:color-mix(in srgb,var(--ours) 65%,var(--color-border))}.conflict-decision .accept-theirs{color:var(--theirs);border-color:color-mix(in srgb,var(--theirs) 65%,var(--color-border))}.conflict-decision .chosen{background:color-mix(in srgb,var(--ours) 10%,transparent)}.conflict-decision .accept-theirs.chosen{background:color-mix(in srgb,var(--theirs) 10%,transparent)}.decision-label{color:var(--ours);font-size:12px}.both-control{position:relative}.both-menu{position:absolute;right:0;top:100%;z-index:5;min-width:220px;padding:4px;background:var(--app-dialog-chrome);border:1px solid var(--color-border-input);box-shadow:var(--app-panel-shadow)}.both-menu button{width:100%;border:0;text-align:left;justify-content:flex-start}
|
||||
.result-preview{display:flex;flex:1;min-height:120px;flex-direction:column;margin:10px 10px 0;border:1px solid var(--color-border)}.result-preview header{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:7px 10px;border-bottom:1px solid var(--color-border)}.result-preview strong{font-weight:500;font-size:13px}.preview-code{overflow:auto;flex:1;padding:6px 0}.preview-code code{overflow:visible}.file-actions{display:flex;justify-content:space-between;align-items:center;gap:16px;min-height:48px;padding:8px 12px;border-top:1px solid var(--color-border)}.file-actions>span{color:var(--color-ink-muted)}.file-actions button{background:var(--color-surface)}.workbench-footer{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:10px 16px;border-top:1px solid var(--color-border);min-height:52px}.workbench-footer>span{color:var(--color-ink-muted)}.workbench-footer .apply-button{background:var(--color-primary);border-color:var(--color-primary);color:white;padding:6px 12px;font-weight:500}.workbench-footer .apply-button:hover:not(:disabled){background:var(--color-primary-dark)}
|
||||
.empty-message{flex:1;padding:32px;color:var(--color-ink-muted)}.manual-editor{flex:1.25;min-height:150px;resize:none;margin:0 10px;padding:14px;font:13px/20px var(--font-mono);background:var(--app-input-bg);color:var(--color-ink);border:1px solid var(--color-border-input)}.binary-content{flex:1;padding:24px}.binary-content p{display:flex;gap:10px;align-items:center;color:var(--color-ink-muted)}.binary-options{display:flex;gap:16px;margin-top:24px}.binary-options button{flex:1;flex-direction:column;gap:12px;padding:24px;white-space:normal}.binary-options button.chosen{border-color:var(--ours);background:color-mix(in srgb,var(--ours) 10%,transparent)}
|
||||
.conflict-backdrop{display:flex;align-items:center;justify-content:center}
|
||||
.conflict-workbench{outline:none}.open-dot{border-radius:50%!important}
|
||||
@media(max-width:1100px){.file-sidebar{flex-basis:210px}.conflict-workbench{font-size:13px}.conflict-workbench button{font-size:12px;padding:7px 10px}.file-copy strong{font-size:13px}.comparison-toolbar{gap:10px;padding:10px}.decision-label{display:none}.file-header{padding:12px 4px}.file-header nav{gap:4px}.code-line{font-size:12px}}
|
||||
@media(max-width:700px){.conflict-backdrop{padding:8px}.conflict-workbench{width:100%;height:100%}.workbench-header{padding:10px;gap:8px}h2{font-size:13px;gap:8px}.header-actions{gap:6px}.header-actions button{font-size:11px}.workbench-body{flex-direction:column}.file-sidebar{flex:0 0 auto;border-right:0;border-bottom:1px solid var(--color-border)}.files-heading{font-size:13px;padding:8px}.file-list{display:flex;max-height:80px}.file-sidebar .file-item{flex:0 0 180px;min-width:180px;width:180px;padding:8px}.file-copy small{white-space:nowrap;font-size:12px}.file-progress{display:none}.file-header{min-height:48px;padding:6px}.file-header h3{font-size:13px}.file-header nav span{font-size:11px}.comparison-toolbar{padding:6px;gap:6px}.comparison-scroll{min-height:100px}.result-preview{min-height:100px}.file-actions{min-height:48px;padding:6px;gap:8px}.file-actions>span,.workbench-footer>span{font-size:11px}.workbench-footer{min-height:60px;padding:8px;gap:8px}.workbench-footer .apply-button{white-space:normal;padding:8px;font-size:13px}.result-preview header{padding:6px}.result-preview strong{font-size:13px}}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import CreateReviewDialog from "./CreateReviewDialog.svelte";
|
||||
import CommentEditor from "./CommentEditor.svelte";
|
||||
import SelectMenu from "./SelectMenu.svelte";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import { fly } from "svelte/transition";
|
||||
import {
|
||||
AlertTriangle, Check, ChevronDown, ChevronRight,
|
||||
CircleCheck, Clock3, CircleDotDashed, ExternalLink, GitBranch, GitMerge, GitPullRequest, Inbox,
|
||||
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;
|
||||
localRepositoryPath?: string;
|
||||
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 { localRepositoryPath = "", language = "en", integrations, initialQuery = "", initialSourceId = "", localResolutionRequestId = "", localResolutionPhase = "idle", localResolutionMessage = "", loadCredential, onOpenSettings = () => {}, onStartLocalResolution = () => {}, onOpenLocalResolver = () => {}, onContinueLocalResolution = () => {}, onAbortLocalResolution = () => {}, onPushLocalResolution = () => {} }: Props = $props();
|
||||
let createOpen = $state(false);
|
||||
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 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 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>
|
||||
|
||||
{#if createOpen && activeSource}
|
||||
<CreateReviewDialog {localRepositoryPath} source={activeSource} {de} {loadCredential} onClose={() => createOpen = false} onCreated={(request) => {
|
||||
++loadGeneration;
|
||||
loading = false;
|
||||
requests = [request, ...requests.filter(item => item.id !== request.id)];
|
||||
stateFilter = request.state;
|
||||
query = "";
|
||||
collapsedRepositories = new Set();
|
||||
errors = [];
|
||||
createOpen = false;
|
||||
selectRequest(request, true);
|
||||
}}/>
|
||||
{/if}
|
||||
|
||||
<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>
|
||||
{#if activeSource}<button class="create-review" type="button" disabled={loading} onclick={() => { detailOpen = false; createOpen = true; }}><GitPullRequest size={14}/>{activeSource.provider.startsWith("gitlab") ? (de ? "MR erstellen" : "Create MR") : (de ? "PR erstellen" : "Create PR")}</button>{/if}
|
||||
</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}
|
||||
<div class="review-comment-editor"><CommentEditor bind:value={commentDraft} language={de ? "de" : "en"} busy={commentPosting} onSend={submitComment} /></div>
|
||||
</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>
|
||||
.create-review{margin-left:auto;display:flex;align-items:center;gap:7px;padding:7px 11px;border:1px solid var(--color-border-subtle);border-radius:7px;background:var(--color-accent);color:white;cursor:pointer}.create-review:disabled{opacity:.5;cursor:default}
|
||||
.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}
|
||||
@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{flex:0 0 auto}.comment-list{min-height:0;max-height:none;flex:1;overflow-y:auto}.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-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))}
|
||||
.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}.table-head,.request-groups{min-width:980px}.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}.table-head,.request-groups{min-width:980px}.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}
|
||||
.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:0 7px;box-sizing:border-box;border-top:8px solid var(--app-bg)}.table-head{padding:0 16px;height:37px}.table-head,.request-row{grid-template-columns:100px minmax(210px,1fr) 106px 120px 165px 230px}.table-head>span{font-size:10px}.table-head,.request-groups{min-width:980px}.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:130px;flex-shrink:0;padding:0 10px;font-size:13px;font-weight:500;white-space:nowrap;line-height:1.2}.provider-button>:global(svg){flex-shrink:0}.provider-action,.action-toggle,.panel-button{flex-shrink:0}.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}.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}}
|
||||
@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}.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)}
|
||||
.review-comment-editor{flex:0 0 auto;min-width:0;padding:14px 0 18px;border-top:1px solid var(--color-border-subtle)}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { Check, ChevronDown } from "@lucide/svelte";
|
||||
import { Check, ChevronDown, Search } from "@lucide/svelte";
|
||||
|
||||
export interface SelectMenuOption {
|
||||
value: string;
|
||||
@@ -17,6 +17,9 @@
|
||||
ariaLabel?: string;
|
||||
class?: string;
|
||||
showSelectedGroup?: boolean;
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
@@ -28,9 +31,16 @@
|
||||
ariaLabel = "",
|
||||
class: className = "",
|
||||
showSelectedGroup = false,
|
||||
searchable = false,
|
||||
searchPlaceholder = "Search…",
|
||||
emptyText = "No results",
|
||||
onChange,
|
||||
}: Props = $props();
|
||||
|
||||
let search = $state("");
|
||||
let searchInput = $state<HTMLInputElement>();
|
||||
const visibleOptions = $derived(options.filter(option => !searchable || `${option.label} ${option.group ?? ""}`.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())));
|
||||
|
||||
let root = $state<HTMLDivElement>();
|
||||
let trigger = $state<HTMLButtonElement>();
|
||||
let open = $state(false);
|
||||
@@ -39,10 +49,10 @@
|
||||
const menuId = `select-menu-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
let selectedOption = $derived(options.find((option) => option.value === value));
|
||||
let enabledIndices = $derived(options.map((option, index) => option.disabled ? -1 : index).filter((index) => index >= 0));
|
||||
let enabledIndices = $derived(visibleOptions.map((option, index) => option.disabled ? -1 : index).filter((index) => index >= 0));
|
||||
|
||||
function groupCount(group: string): number {
|
||||
return options.filter((option) => option.group === group).length;
|
||||
return visibleOptions.filter((option) => option.group === group).length;
|
||||
}
|
||||
|
||||
function positionMenu() {
|
||||
@@ -50,7 +60,8 @@
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const viewportGap = 8;
|
||||
const menuGap = 5;
|
||||
const desiredHeight = Math.min(300, options.length * 34 + 24);
|
||||
const groupHeaderCount = new Set(visibleOptions.map((option) => option.group).filter(Boolean)).size;
|
||||
const desiredHeight = Math.min(300, visibleOptions.length * 32 + (searchable ? 46 : 0) + groupHeaderCount * 36 + 12);
|
||||
const spaceBelow = window.innerHeight - rect.bottom - viewportGap;
|
||||
const spaceAbove = rect.top - viewportGap;
|
||||
const openAbove = spaceBelow < Math.min(desiredHeight, 180) && spaceAbove > spaceBelow;
|
||||
@@ -62,12 +73,14 @@
|
||||
}
|
||||
|
||||
async function show() {
|
||||
if (disabled || enabledIndices.length === 0) return;
|
||||
const selectedIndex = options.findIndex((option) => option.value === value && !option.disabled);
|
||||
if (disabled || !options.some(option => !option.disabled)) return;
|
||||
search = "";
|
||||
const selectedIndex = visibleOptions.findIndex((option) => option.value === value && !option.disabled);
|
||||
activeIndex = selectedIndex >= 0 ? selectedIndex : enabledIndices[0];
|
||||
open = true;
|
||||
await tick();
|
||||
positionMenu();
|
||||
if (searchable) searchInput?.focus();
|
||||
document.getElementById(`${menuId}-option-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
@@ -76,7 +89,7 @@
|
||||
}
|
||||
|
||||
function choose(index: number) {
|
||||
const option = options[index];
|
||||
const option = visibleOptions[index];
|
||||
if (!option || option.disabled) return;
|
||||
onChange(option.value);
|
||||
close();
|
||||
@@ -101,6 +114,8 @@
|
||||
return;
|
||||
}
|
||||
if (!open) return;
|
||||
const editingSearch = event.target === searchInput;
|
||||
if (editingSearch && [" ", "Home", "End"].includes(event.key)) return;
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
moveActive(event.key === "ArrowDown" ? 1 : -1);
|
||||
@@ -147,9 +162,14 @@
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div id={menuId} class="select-menu-popup" style={menuStyle} role="listbox" aria-label={ariaLabel || undefined}>
|
||||
{#each options as option, index (`${option.value}:${index}`)}
|
||||
{#if option.group && (index === 0 || options[index - 1]?.group !== option.group)}
|
||||
<div class="select-menu-popup" class:searchable style={menuStyle}>
|
||||
{#if searchable}
|
||||
<div class="select-search"><Search size={14} aria-hidden="true"/><input bind:this={searchInput} bind:value={search} placeholder={searchPlaceholder} aria-label={searchPlaceholder} role="combobox" aria-expanded={open} aria-controls={menuId} aria-autocomplete="list" aria-activedescendant={activeIndex >= 0 ? `${menuId}-option-${activeIndex}` : undefined} onkeydown={handleKeydown} oninput={async () => { await tick(); activeIndex = enabledIndices[0] ?? -1; positionMenu(); }}/></div>
|
||||
{/if}
|
||||
<div id={menuId} class="select-options" role="listbox" aria-label={ariaLabel || undefined}>
|
||||
{#each visibleOptions as option, index (`${option.value}:${index}`)}
|
||||
|
||||
{#if option.group && (index === 0 || visibleOptions[index - 1]?.group !== option.group)}
|
||||
<div class="select-menu-group" role="presentation">
|
||||
<span>{option.group}</span>
|
||||
<small>{groupCount(option.group)}</small>
|
||||
@@ -171,6 +191,17 @@
|
||||
{#if option.value === value}<Check size={14} aria-hidden="true" />{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if searchable && !visibleOptions.length}<div class="select-empty" role="status">{emptyText}</div>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.select-options{display:grid;gap:2px;min-height:0}
|
||||
.select-menu-popup.searchable{display:flex;flex-direction:column;overflow:hidden}
|
||||
.searchable .select-options{overflow:auto}
|
||||
.select-search{display:flex;flex-shrink:0;align-items:center;gap:9px;margin:2px 3px 5px;padding:0 8px;border-bottom:1px solid var(--color-border-subtle);color:var(--color-ink-faint)}
|
||||
.select-search input{width:100%;min-width:0;height:36px;padding:0;border:0;background:transparent;color:var(--color-ink);font:inherit;outline:none;box-shadow:none}
|
||||
.select-empty{padding:15px 12px;color:var(--color-ink-muted);font-size:12px}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,9 @@ import { tracedInvoke as invoke } from "./telemetry";
|
||||
import type {
|
||||
AiReviewResult,
|
||||
AiCommitPlan,
|
||||
BisectState,
|
||||
CommitAiProvider,
|
||||
CloneOptions,
|
||||
ConflictFile,
|
||||
DetectedExternalTool,
|
||||
ExternalToolCommand,
|
||||
@@ -15,6 +17,8 @@ import type {
|
||||
GitIgnoreKind,
|
||||
GitIntegrationProvider,
|
||||
GitIntegrationRepository,
|
||||
IntegrationReviewRequest,
|
||||
IntegrationReviewAction,
|
||||
GitLfsStatus,
|
||||
GitRepositoryFile,
|
||||
GitRemote,
|
||||
@@ -57,6 +61,26 @@ export function listIntegrationRepositories(provider: GitIntegrationProvider, ba
|
||||
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> {
|
||||
return invoke<void>("launch_external_tool", { path, file: file ?? null, command });
|
||||
}
|
||||
@@ -80,6 +104,7 @@ export function cloneRepository(
|
||||
username?: string,
|
||||
password?: string,
|
||||
commitLimit = 100,
|
||||
options: CloneOptions = { branch: null, blobless: false, customFlags: "", shallowDepth: null, shallowSince: null, sparse: false, sparsePaths: [] },
|
||||
): Promise<RepositoryBundle> {
|
||||
return invoke<RepositoryBundle>("clone_repository", {
|
||||
remoteUrl,
|
||||
@@ -88,6 +113,13 @@ export function cloneRepository(
|
||||
username: username ?? null,
|
||||
password: password ?? null,
|
||||
commitLimit,
|
||||
branch: options.branch,
|
||||
blobless: options.blobless,
|
||||
customFlags: options.customFlags,
|
||||
shallowDepth: options.shallowDepth,
|
||||
shallowSince: options.shallowSince,
|
||||
sparse: options.sparse,
|
||||
sparsePaths: options.sparsePaths,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -95,6 +127,22 @@ export function getStatus(path: string): Promise<GitStatus> {
|
||||
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> {
|
||||
return invoke<GitLfsStatus>("git_lfs_status", { path });
|
||||
}
|
||||
@@ -627,3 +675,38 @@ export function resolveConflictSide(
|
||||
): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("resolve_conflict_side", { path, file, side });
|
||||
}
|
||||
|
||||
export function listIntegrationIssues(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, cursor: string | null = null): Promise<import("./types").IntegrationIssuePage> {
|
||||
return invoke("list_integration_issues", { provider, baseUrl, username, token, cursor });
|
||||
}
|
||||
|
||||
export function getIntegrationBoard(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, boardUrl: string): Promise<import("./types").IntegrationBoard> {
|
||||
return invoke("get_integration_board", { provider, baseUrl, username, token, boardUrl });
|
||||
}
|
||||
|
||||
export function listIntegrationBoards(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string): Promise<{ boards: import("./types").IntegrationBoardReference[]; warnings: string[] }> {
|
||||
return invoke("list_integration_boards", { provider, baseUrl, username, token });
|
||||
}
|
||||
|
||||
export function moveIntegrationBoardCard(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, boardUrl: string, cardId: string, sourceColumnId: string, targetColumnId: string): Promise<void> {
|
||||
return invoke("move_integration_board_card", { provider, baseUrl, username, token, boardUrl, cardId, sourceColumnId, targetColumnId });
|
||||
}
|
||||
|
||||
export function listIntegrationIssueComments(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number, cursor: string | null): Promise<{ comments: import("./types").IssueComment[]; nextCursor: string | null }> {
|
||||
return invoke("list_integration_issue_comments", { provider, baseUrl, username, token, repository, number, cursor });
|
||||
}
|
||||
export function addIntegrationIssueComment(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number, body: string): Promise<import("./types").IssueComment> {
|
||||
return invoke("add_integration_issue_comment", { provider, baseUrl, username, token, repository, number, body });
|
||||
}
|
||||
|
||||
export function closeIntegrationIssue(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number): Promise<string> {
|
||||
return invoke("close_integration_issue", { provider, baseUrl, username, token, repository, number });
|
||||
}
|
||||
|
||||
export function createIntegrationReviewRequest(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: GitIntegrationRepository, sourceBranch: string, targetBranch: string, title: string, description: string): Promise<IntegrationReviewRequest> {
|
||||
return invoke("create_integration_review_request", { provider, baseUrl, username, token, repository, sourceBranch, targetBranch, title, description });
|
||||
}
|
||||
|
||||
export function listIntegrationRepositoryBranches(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: GitIntegrationRepository): Promise<{branches: string[]; defaultBranch: string}> {
|
||||
return invoke("list_integration_repository_branches", {provider, baseUrl, username, token, repository});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Visual tones only; provider status values and board membership stay untouched. */
|
||||
export function issueTone(value: string): "neutral" | "active" | "review" | "done" {
|
||||
const name = value.toLocaleLowerCase();
|
||||
if (/^(closed|done|completed|resolved|erledigt|geschlossen|shipped)$/.test(name)) return "done";
|
||||
if (/review|testing|test|\bqa\b|prüfung/.test(name)) return "review";
|
||||
if (/progress|development|entwicklung|active|doing|build|bearbeitung/.test(name)) return "active";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
export function issueStateLabel(value: string, de: boolean): string {
|
||||
if (!de) return value;
|
||||
return ({ open: "Offen", opened: "Offen", closed: "Geschlossen" } as Record<string, string>)[value] ?? value;
|
||||
}
|
||||
|
||||
export function labelTone(label: string): string {
|
||||
const name = label.toLocaleLowerCase();
|
||||
if (/bug|error|fehler/.test(name)) return "red";
|
||||
if (/enhancement|feature|verbesserung/.test(name)) return "blue";
|
||||
return "neutral";
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/* Shared visual system for the issue list, native boards and their inspectors. */
|
||||
.issue-center {
|
||||
--issue-muted: var(--color-ink-dim);
|
||||
--issue-canvas: var(--color-surface-solid);
|
||||
--issue-line: var(--color-border-subtle);
|
||||
display:flex;flex:1;min-height:0;min-width:0;flex-direction:column;overflow:hidden;
|
||||
background:var(--issue-canvas);color:var(--color-ink);
|
||||
font:400 13px/1.5 var(--font-sans);
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
.issue-center * {box-sizing:border-box}
|
||||
.issue-center button,.issue-center input {font-family:inherit;font-size:12px;font-weight:400;line-height:1.4}
|
||||
.issue-center button {cursor:pointer;transition:background 140ms,border-color 140ms,color 140ms}
|
||||
.issue-center button:disabled {cursor:default;opacity:.5}
|
||||
.issue-center button:focus-visible,.issue-center input:focus-visible {outline:2px solid var(--color-accent);outline-offset:2px}
|
||||
.issue-center h1,.issue-center h2,.issue-center h3,.issue-center p {margin:0}
|
||||
.issue-center h1 {font-size:20px;font-weight:650;line-height:1.3;letter-spacing:-.4px}
|
||||
.issue-center h2 {font-size:18px;font-weight:600;line-height:1.45;letter-spacing:-.25px;overflow-wrap:anywhere}
|
||||
.issue-center h3 {font-size:12px;font-weight:600;line-height:1.5}
|
||||
.issue-center small {font-size:11px;line-height:1.5;color:var(--issue-muted)}
|
||||
.issue-center .workspace-heading {display:flex;align-items:center;justify-content:space-between;gap:20px;padding:14px 24px 8px;flex-shrink:0}
|
||||
.issue-center .workspace-title {display:flex;align-items:center;gap:13px;min-width:0}
|
||||
.issue-center .workspace-title p {color:var(--issue-muted);font-size:12px;margin-top:4px}
|
||||
.issue-center .workspace-symbol {display:grid;place-items:center;flex-shrink:0;width:34px;height:34px;color:var(--color-accent);border:1px solid color-mix(in srgb,var(--color-accent) 55%,transparent);border-radius:6px;background:color-mix(in srgb,var(--color-accent) 5%,transparent)}
|
||||
.issue-center .workspace-button {display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:32px;padding:6px 10px;white-space:nowrap;border:1px solid var(--color-border);border-radius:4px;background:transparent;color:var(--color-ink-muted);box-shadow:none}
|
||||
.issue-center .workspace-button:hover:not(:disabled) {background:var(--color-surface-hover);border-color:var(--color-border-input);color:var(--color-ink)}
|
||||
.issue-center .workspace-button.active {color:var(--color-accent)}
|
||||
.issue-center .workspace-button svg {flex-shrink:0}
|
||||
.issue-center .icon-button {width:32px;min-width:32px;padding:0}
|
||||
.issue-center .workspace-navigation {display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:40px;padding:0 24px;border-bottom:1px solid var(--issue-line);flex-shrink:0}
|
||||
.issue-center .view-toggle {display:flex;gap:6px;align-self:stretch;margin-left:-12px}
|
||||
.issue-center .view-toggle button {position:relative;min-height:40px;padding:0 14px;background:transparent;border:0;border-radius:0;color:var(--issue-muted);font-weight:550}
|
||||
.issue-center .view-toggle button.active {color:var(--color-accent)}
|
||||
.issue-center .view-toggle button.active:after {position:absolute;content:"";bottom:-1px;left:0;right:0;height:2px;background:var(--color-accent)}
|
||||
.issue-center .view-toggle button:hover {color:var(--color-ink)}
|
||||
.issue-center .source-select {width:190px;flex:0 1 190px}
|
||||
.issue-center .source-select button,.issue-center .state-select button {border-radius:4px;font-size:12px;font-weight:400;min-height:32px;background:transparent}
|
||||
.issue-center .workspace-search {display:flex;align-items:center;gap:9px;min-width:0;height:34px;padding:0 10px;border:1px solid var(--color-border);border-radius:4px;color:var(--issue-muted);background:color-mix(in srgb,var(--color-surface) 35%,transparent)}
|
||||
.issue-center .workspace-search:focus-within {border-color:var(--color-accent)}
|
||||
.issue-center .workspace-search svg {flex-shrink:0}
|
||||
.issue-center .workspace-search input {min-width:0;width:100%;height:100%;border:0;border-radius:0;outline:0;background:transparent;color:var(--color-ink);padding:0}
|
||||
.issue-center input::placeholder {color:var(--issue-muted);opacity:1}
|
||||
.issue-center .workspace-notice {margin:0 24px 12px;padding:10px 12px;border:1px solid var(--issue-line);border-radius:4px;color:var(--color-ink-muted);font-size:12px;overflow-wrap:anywhere}
|
||||
.issue-center .workspace-notice.error {border-color:color-mix(in srgb,#d6a555 45%,transparent);background:color-mix(in srgb,#d6a555 4%,transparent)}
|
||||
.issue-center .workspace-notice p {margin-top:4px;color:var(--issue-muted)}
|
||||
.issue-center .issue-content {display:flex;flex:1;min-height:0;min-width:0;position:relative;overflow:hidden}
|
||||
.issue-center .issue-main {display:flex;flex:1;flex-direction:column;min-width:0;min-height:0}
|
||||
.issue-center .issue-toolbar {display:flex;align-items:center;gap:10px;padding:12px 24px 10px;flex-shrink:0}
|
||||
.issue-center .issue-toolbar .workspace-search {width:360px;max-width:55%;flex:0 1 360px}
|
||||
.issue-center .state-select {flex:0 0 150px;width:150px}
|
||||
.issue-center .issue-list {flex:0 1 auto;min-height:0;overflow:auto;padding:0 24px}
|
||||
.issue-center .issue-table-heading,.issue-center .issue-row {display:grid;grid-template-columns:minmax(0,1fr) 145px 130px;gap:18px;align-items:center;text-align:left}
|
||||
.issue-center .issue-table-heading {min-height:32px;padding:0 14px 0 16px;color:var(--issue-muted);font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:.65px;background:color-mix(in srgb,var(--color-surface) 35%,transparent);border-radius:4px 4px 0 0;border-bottom:1px solid var(--issue-line);position:sticky;top:0;z-index:1}
|
||||
.issue-center .issue-row {position:relative;width:100%;min-height:56px;padding:8px 14px 8px 16px;border:0;border-bottom:1px solid var(--issue-line);border-radius:0;color:var(--color-ink);background:transparent;box-shadow:none}
|
||||
.issue-center .issue-row:hover {background:color-mix(in srgb,var(--color-surface-hover) 55%,transparent)}
|
||||
.issue-center .issue-row.selected {background:color-mix(in srgb,var(--color-accent) 9%,transparent);box-shadow:inset 2px 0 var(--color-accent)}
|
||||
.issue-center .issue-identity {display:flex;min-width:0;gap:18px;align-items:center}
|
||||
.issue-center .issue-number {font-family:var(--font-mono);color:var(--issue-muted);font-size:11px;font-weight:400;white-space:nowrap}
|
||||
.issue-center .issue-identity>.issue-number {flex:0 0 44px}
|
||||
.issue-center .issue-text {display:grid;grid-template-columns:minmax(0,320px) auto;gap:3px 12px;flex:1;max-width:465px;min-width:0;align-items:center}
|
||||
.issue-center .issue-text strong {font-size:13px;line-height:1.45;font-weight:550;overflow-wrap:anywhere;grid-column:1}
|
||||
.issue-center .issue-text>small {grid-column:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.issue-center .issue-text>.issue-labels {grid-column:2;grid-row:1 / 3;max-width:145px;justify-content:flex-end}
|
||||
.issue-center .issue-status {display:flex;align-items:center;gap:9px;font-size:12px;color:var(--color-ink-muted);min-width:0;overflow-wrap:anywhere}
|
||||
.issue-center .status-dot {display:block;width:13px;height:13px;border:2px solid var(--issue-muted);border-radius:50%;flex:0 0 13px}
|
||||
.issue-center [data-tone="active"] {--status-color:#48cef0}
|
||||
.issue-center [data-tone="review"] {--status-color:#ffb52e}
|
||||
.issue-center [data-tone="done"] {--status-color:#6fe480}
|
||||
.issue-center [data-tone="neutral"] {--status-color:var(--issue-muted)}
|
||||
.issue-center .issue-status .status-dot {border-color:var(--status-color)}
|
||||
.issue-center .issue-person {display:inline-flex;align-items:center;gap:8px;min-width:0;font-size:12px;font-weight:400;color:var(--color-ink-muted)}
|
||||
.issue-center .issue-avatar {display:grid;place-items:center;width:23px;height:23px;flex:0 0 23px;border-radius:50%;border:1px solid color-mix(in srgb,var(--color-ink-muted) 20%,transparent);background:color-mix(in srgb,var(--color-ink-muted) 25%,var(--color-surface));font-size:10px;font-weight:550;color:var(--color-ink)}
|
||||
.issue-center .person-name {min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.issue-center .unassigned {color:var(--color-ink-faint)}
|
||||
.issue-center .issue-labels {display:flex;flex-wrap:wrap;gap:4px;min-width:0}
|
||||
.issue-center .issue-label {padding:2px 6px;border-radius:4px;font-size:10px;line-height:1.5;letter-spacing:.1px;color:var(--color-ink-muted);background:var(--color-surface-dim);overflow-wrap:anywhere;font-weight:400}
|
||||
.issue-center .issue-label[data-tone="red"] {background:#9e3d43;color:#fff0f1}
|
||||
.issue-center .issue-label[data-tone="blue"] {background:#3e5699;color:#eef2ff}
|
||||
:root[data-theme="light"] .issue-center .issue-label[data-tone="red"] {color:#972e3c;background:#fbe5e8}
|
||||
:root[data-theme="light"] .issue-center .issue-label[data-tone="blue"] {color:#3753a4;background:#e8edfc}
|
||||
.issue-center .issue-footer {display:flex;justify-content:space-between;gap:16px;padding:12px 24px;color:var(--issue-muted);font-size:11px;flex-shrink:0}
|
||||
.issue-center .load-more {display:flex;margin:18px auto}
|
||||
.issue-center .issue-inspector {flex:0 0 clamp(320px,26vw,420px);width:clamp(320px,26vw,420px);min-width:0;overflow:auto;padding:20px 22px 24px;border-left:1px solid var(--issue-line);background:color-mix(in srgb,var(--color-surface) 20%,var(--issue-canvas))}
|
||||
.issue-center .inspector-top {display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;color:var(--issue-muted);font-size:11px;overflow-wrap:anywhere}
|
||||
.issue-center .inspector-top .issue-number {margin-left:6px}
|
||||
.issue-center .close-button {border:0;background:transparent;min-height:24px;width:24px;padding:0;flex-shrink:0}
|
||||
.issue-center .issue-inspector h2 {font-size:20px;font-weight:600;line-height:1.4;letter-spacing:-.3px}
|
||||
.issue-center .inspector-properties {display:flex;align-items:center;flex-wrap:wrap;gap:16px;margin:18px 0}
|
||||
.issue-center .inspector-properties>.issue-person:not(:first-child) {padding-left:16px;border-left:1px solid var(--color-border)}
|
||||
.issue-center .inspector-labels {display:flex;flex-direction:column;gap:5px;margin-bottom:20px}
|
||||
.issue-center .inspector-description {border-top:1px solid var(--issue-line);padding-top:18px;margin-top:18px}
|
||||
.issue-center .description {white-space:pre-wrap;overflow-wrap:anywhere;font-size:13px;line-height:1.75;color:var(--color-ink-muted);margin-top:8px}
|
||||
.issue-center .inspector-open {margin-top:22px}
|
||||
.issue-center .workspace-empty {display:flex;flex:1;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center;padding:56px 24px;color:var(--issue-muted);min-height:250px}
|
||||
.issue-center .workspace-empty p {font-size:12px;max-width:440px;line-height:1.7}
|
||||
.issue-center .workspace-empty h2 {color:var(--color-ink);font-size:16px}
|
||||
.issue-center .empty-symbol {display:grid;place-items:center;width:52px;height:52px;margin-bottom:6px;border:1px solid var(--issue-line);border-radius:12px;color:var(--issue-muted)}
|
||||
.issue-center .board-view {display:flex;flex:1;flex-direction:column;min-height:0;min-width:0;overflow:hidden}
|
||||
.issue-center .board-header {display:flex;align-items:center;justify-content:space-between;gap:20px;padding:18px 24px 14px;flex-shrink:0}
|
||||
.issue-center .board-title {min-width:0}
|
||||
.issue-center .board-title h2 {font-size:16px;line-height:1.4;font-weight:600;letter-spacing:.05px}
|
||||
.issue-center .board-title small {display:block;margin-top:4px}
|
||||
.issue-center .board-title small>span {padding:0 5px}
|
||||
.issue-center .board-tools {display:flex;align-items:center;gap:9px;flex-shrink:0}
|
||||
.issue-center .board-tools .workspace-search {width:240px}
|
||||
.issue-center .board-connect {padding:18px 24px;flex-shrink:0;border-bottom:1px solid var(--issue-line)}
|
||||
.issue-center .board-connect label {display:block;font-size:11px;color:var(--issue-muted);margin-bottom:7px}
|
||||
.issue-center .board-connect-fields {display:flex;gap:10px}
|
||||
.issue-center .board-connect input {flex:1;min-width:100px;min-height:34px;padding:6px 10px;border:1px solid var(--color-border);border-radius:4px;background:transparent;color:var(--color-ink)}
|
||||
.issue-center .board-notice {font-size:11px;line-height:1.6;padding:0 24px 12px;color:var(--issue-muted);flex-shrink:0}
|
||||
.issue-center .board-body {position:relative;display:flex;flex:1;min-height:0;overflow:hidden}
|
||||
.issue-center .board-columns {display:flex;flex:1;min-width:0;gap:14px;padding:0 24px 20px;overflow:auto;align-items:stretch}
|
||||
.issue-center .board-column {display:flex;flex:1 0 278px;max-width:400px;min-width:0;flex-direction:column;border:0;border-radius:6px;background:color-mix(in srgb,var(--color-surface) 60%,var(--issue-canvas));overflow:hidden}
|
||||
.issue-center .board-column>header {display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 12px 12px;flex-shrink:0}
|
||||
.issue-center .board-column h3 {display:flex;align-items:center;gap:9px;min-width:0;font-size:12px;font-weight:600;overflow-wrap:anywhere}
|
||||
.issue-center .column-dot {width:10px;height:10px;flex:0 0 10px;border-radius:50%;background:var(--status-color)}
|
||||
.issue-center .column-count {color:var(--issue-muted);font-size:11px;white-space:nowrap}
|
||||
.issue-center .wip-limit {font-size:10px}
|
||||
.issue-center .column-cards {padding:0 10px 10px;min-height:0;overflow:auto;flex:1}
|
||||
.issue-center .board-card {display:flex;flex-direction:column;align-items:flex-start;gap:7px;width:100%;text-align:left;padding:12px;margin-bottom:9px;border:1px solid var(--color-border);border-radius:6px;box-shadow:0 2px 3px #0000000a;background:var(--issue-canvas);color:var(--color-ink);overflow-wrap:anywhere}
|
||||
.issue-center .board-card:hover {border-color:var(--color-border-input);background:color-mix(in srgb,var(--color-surface) 55%,var(--issue-canvas))}
|
||||
.issue-center .board-card.chosen {border-color:color-mix(in srgb,var(--color-accent) 65%,var(--color-border));background:color-mix(in srgb,var(--color-accent) 5%,var(--issue-canvas))}
|
||||
.issue-center .board-card strong {font-size:13px;line-height:1.5;font-weight:550}
|
||||
.issue-center .card-reference {display:flex;align-items:center;flex-wrap:wrap;gap:8px;font-size:11px;line-height:1.4}
|
||||
.issue-center .board-card .issue-person {margin-top:3px;font-size:11px}
|
||||
.issue-center .board-card .issue-avatar {width:19px;height:19px;flex-basis:19px;font-size:9px}
|
||||
.issue-center .lane {font-size:10px;line-height:1.5;color:var(--color-accent);font-weight:400}
|
||||
.issue-center .column-empty {padding:28px 12px;text-align:center;color:var(--color-ink-faint);font-size:11px}
|
||||
@media(min-width:1250px) {
|
||||
.issue-center .issue-content:not(.with-details) .issue-table-heading,.issue-center .issue-content:not(.with-details) .issue-row {grid-template-columns:minmax(0,1fr) 180px 160px}
|
||||
}
|
||||
@media(max-width:1150px) {
|
||||
.issue-center .issue-text {display:flex;flex-direction:column;align-items:flex-start;gap:3px}
|
||||
.issue-center .issue-text>.issue-labels {justify-content:flex-start;max-width:none;margin-top:3px}
|
||||
.issue-center .issue-table-heading,.issue-center .issue-row {grid-template-columns:minmax(0,1fr) 110px 100px;gap:12px}
|
||||
.issue-center .issue-identity {gap:10px}
|
||||
.issue-center .issue-inspector {flex-basis:310px;width:310px;padding:18px}
|
||||
.issue-center .board-header {align-items:flex-start;flex-direction:column;gap:14px}
|
||||
.issue-center .board-tools {width:100%}
|
||||
.issue-center .board-tools .workspace-search {flex:1;width:auto}
|
||||
}
|
||||
@media(max-width:800px) {
|
||||
.issue-center .workspace-heading {padding:18px 16px 10px;gap:12px}
|
||||
.issue-center .workspace-title p {max-width:235px}
|
||||
.issue-center .workspace-navigation {padding:0 16px}
|
||||
.issue-center .workspace-symbol {display:none}
|
||||
.issue-center .issue-toolbar {padding:14px 16px 10px;gap:8px;flex-wrap:wrap}
|
||||
.issue-center .issue-toolbar .workspace-search {max-width:none;flex:1 1 190px;width:auto}
|
||||
.issue-center .state-select {flex:0 0 130px;width:130px}
|
||||
.issue-center .issue-list {padding:0 16px}
|
||||
.issue-center .issue-footer {padding:12px 16px;font-size:10px}
|
||||
.issue-center .issue-inspector {position:absolute;inset:0;width:100%;max-width:none;z-index:3;border:0;flex-basis:auto;background:var(--issue-canvas);padding:20px}
|
||||
.issue-center .with-details .issue-main {visibility:hidden}
|
||||
.issue-center .board-header,.issue-center .board-connect {padding:16px}
|
||||
.issue-center .board-tools {flex-wrap:wrap;gap:8px}
|
||||
.issue-center .board-tools .workspace-search {flex:1 1 100%;height:34px}
|
||||
.issue-center .board-columns {padding:0 16px 16px;gap:12px}
|
||||
.issue-center .board-column {flex:0 0 270px}
|
||||
.issue-center .board-notice {padding:0 16px 12px}
|
||||
.issue-center .workspace-notice {margin:0 16px 12px}
|
||||
}
|
||||
@media(max-width:520px) {
|
||||
.issue-center .workspace-title p {font-size:11px;max-width:205px}
|
||||
.issue-center .workspace-heading>.workspace-button {font-size:11px;padding:6px 8px}
|
||||
.issue-center .workspace-heading>.workspace-button>svg {display:none}
|
||||
.issue-center .source-select {flex-basis:155px;width:155px}
|
||||
.issue-center .issue-table-heading,.issue-center .issue-row {grid-template-columns:minmax(0,1fr) 94px;gap:8px}
|
||||
.issue-center .issue-table-heading>span:last-child,.issue-center .issue-row>.issue-person {display:none}
|
||||
.issue-center .issue-identity>.issue-number {display:none}
|
||||
.issue-center .issue-row {padding:12px 10px;min-height:80px}
|
||||
.issue-center .issue-status {font-size:11px;gap:6px}
|
||||
.issue-center .issue-toolbar .workspace-search {flex-basis:100%}
|
||||
.issue-center .board-tools>.workspace-button {font-size:11px;padding:6px 8px}
|
||||
.issue-center .board-connect-fields {flex-wrap:wrap}
|
||||
.issue-center .board-connect input {flex-basis:100%}
|
||||
}
|
||||
@media(prefers-reduced-motion:reduce) {.issue-center button {transition:none}}
|
||||
/* This workspace opts into its compact rounded controls. The app-wide square
|
||||
reset uses !important, so the same priority is needed for these local shapes. */
|
||||
.issue-center :is(.issue-avatar,.status-dot,.column-dot) {border-radius:50% !important}
|
||||
.issue-center :is(.workspace-button,.workspace-search,.issue-label,.source-select button,.state-select button,.board-connect input) {border-radius:4px !important}
|
||||
.issue-center :is(.workspace-symbol,.board-column,.board-card) {border-radius:6px !important}
|
||||
.issue-center .empty-symbol {border-radius:12px !important}
|
||||
|
||||
.issue-center .repository-select {width:190px;flex:0 1 190px;min-width:130px}
|
||||
.issue-center .repository-select button {font-size:12px;min-height:32px;border-radius:4px !important}
|
||||
.issue-center .repository-group-heading {display:flex;align-items:center;gap:9px;padding:14px 16px 9px;color:var(--color-ink-muted);border-bottom:1px solid var(--issue-line)}
|
||||
.issue-center .repository-group-heading strong {overflow-wrap:anywhere}
|
||||
.issue-center .repository-group-heading>span {margin-left:auto;color:var(--issue-muted);font-size:11px}
|
||||
.issue-center .issue-toolbar {flex-wrap:wrap}
|
||||
.issue-center .board-tools {flex-wrap:wrap;justify-content:flex-end;flex-shrink:1}
|
||||
:root[data-theme="light"] .issue-center [data-tone="active"] {--status-color:#0086a8}
|
||||
:root[data-theme="light"] .issue-center [data-tone="review"] {--status-color:#ab6800}
|
||||
:root[data-theme="light"] .issue-center [data-tone="done"] {--status-color:#24853c}
|
||||
@media(max-width:800px) {.issue-center .board-tools {justify-content:flex-start}}
|
||||
|
||||
.issue-center .repository-group-heading {width:100%;text-align:left;background:transparent;border:0;border-bottom:1px solid var(--issue-line);border-radius:0;justify-content:flex-start}
|
||||
.issue-center .repository-group-heading:hover {background:var(--color-surface-hover)}
|
||||
.issue-center .repository-group-heading strong {font-size:12px;font-weight:600}
|
||||
.issue-center .repository-group-heading svg {flex-shrink:0}
|
||||
.issue-center .repository-chevron.expanded {transform:rotate(90deg)}
|
||||
|
||||
.issue-center .board-directory {display:flex;align-items:center;gap:10px;padding:12px 24px;flex-wrap:wrap;border-bottom:1px solid var(--issue-line)}
|
||||
.issue-center .board-select {width:360px;max-width:100%;min-width:0}
|
||||
.issue-center .board-discovery-notice {padding:8px 24px;color:var(--issue-muted);font-size:12px;max-height:160px;overflow:auto;flex-shrink:0}
|
||||
.issue-center .board-discovery-notice summary {cursor:pointer}
|
||||
.issue-center .board-discovery-notice p {margin-top:8px;overflow-wrap:anywhere}
|
||||
|
||||
.issue-center .board-column.drop-target {outline:2px solid var(--color-accent);outline-offset:-2px;background:color-mix(in srgb,var(--color-accent) 12%,var(--issue-canvas))}
|
||||
.issue-center .board-card[draggable="true"] {cursor:grab}
|
||||
.issue-center .board-card[draggable="true"]:active {cursor:grabbing}
|
||||
.issue-center .card-move-control {display:flex;flex-direction:column;gap:6px;margin-top:16px}
|
||||
|
||||
/* Overlay drawer follows the PR Center's header, proportions and two-column detail layout. */
|
||||
.issue-center {position:relative}
|
||||
.issue-center .issue-detail-drawer {position:absolute;z-index:20;inset:0 0 0 auto;display:flex;flex-direction:column;width:44%;min-width:min(680px,100%);max-width:100%;height:100%;overflow:hidden;border-left:1px solid var(--color-border);background:color-mix(in srgb,var(--app-bg) 94%,var(--color-surface));box-shadow:-14px 0 30px #0007}
|
||||
.issue-center .issue-detail-header {display:flex;align-items:center;justify-content:space-between;gap:14px;min-height:54px;flex-shrink:0;padding:0 24px;border-bottom:1px solid var(--color-border);background:color-mix(in srgb,var(--app-bg) 78%,var(--color-surface))}
|
||||
.issue-center .issue-detail-header>div {display:flex;align-items:center;gap:9px;min-width:0}
|
||||
.issue-center .issue-detail-header strong {font-size:13px;font-weight:600;overflow-wrap:anywhere}
|
||||
.issue-center .issue-detail-header svg {color:var(--color-accent);flex-shrink:0}
|
||||
.issue-center .issue-detail-header .issue-detail-header-actions {gap:3px;flex-shrink:0}
|
||||
.issue-center .issue-detail-header-actions svg {color:var(--color-ink-muted)}
|
||||
.issue-center .issue-detail-body {display:grid;grid-template-columns:minmax(0,1fr) 225px;gap:18px;padding-left:24px;min-height:0;flex:1;overflow:hidden}
|
||||
.issue-center .issue-detail-main {min-width:0;overflow:auto}
|
||||
.issue-center .issue-detail-title {padding:18px 0 16px;border-bottom:1px solid var(--issue-line)}
|
||||
.issue-center .issue-detail-title>div:first-child {display:flex;align-items:baseline;gap:10px}
|
||||
.issue-center .issue-detail-title>div:first-child>span {color:var(--color-accent);font-size:17px;font-weight:700;flex-shrink:0}
|
||||
.issue-center .issue-detail-title h2 {font-size:19px;line-height:1.3;font-weight:700;min-width:0}
|
||||
.issue-center .issue-detail-summary {display:flex;align-items:center;flex-wrap:wrap;gap:12px;min-height:33px;margin-top:8px}
|
||||
.issue-center .issue-detail-summary .issue-status,.issue-center .issue-detail-summary .issue-person {font-size:10.5px}
|
||||
.issue-center .issue-detail-description {padding:14px 0 18px}
|
||||
.issue-center .issue-detail-description h3 {font-size:11.5px}
|
||||
.issue-center .issue-detail-description .description {font-size:11px;line-height:1.6}
|
||||
.issue-center .issue-detail-sidebar {min-width:0;overflow:auto;padding:28px 17px 16px;border-left:1px solid var(--color-border);background:color-mix(in srgb,var(--color-surface) 46%,var(--app-bg))}
|
||||
.issue-center .issue-detail-sidebar .inspector-open {width:100%;margin:0 0 12px;min-height:39px;font-size:11px;color:var(--color-accent);border-color:var(--color-accent)}
|
||||
.issue-center .issue-detail-sidebar section {display:flex;flex-direction:column;gap:11px;padding:17px 0;border-bottom:1px solid var(--issue-line)}
|
||||
.issue-center .issue-detail-sidebar h3 {font-size:11.5px;color:var(--color-ink-muted)}
|
||||
.issue-center .issue-detail-repo {display:flex;align-items:flex-start;gap:8px;font-size:11px;overflow-wrap:anywhere}
|
||||
.issue-center .issue-detail-repo svg {flex-shrink:0}
|
||||
@media(max-width:1280px) {.issue-center .issue-detail-drawer {width:50%;min-width:min(650px,100%)}}
|
||||
@media(max-width:680px) {.issue-center .issue-detail-drawer {width:100%;min-width:0}.issue-center .issue-detail-body {display:block;overflow:auto;padding:0 15px}.issue-center .issue-detail-main {overflow:visible}.issue-center .issue-detail-sidebar {padding:0 0 18px;border-left:0;background:transparent;overflow:visible}}
|
||||
|
||||
.issue-center .issue-comments {padding:14px 0 18px;border-top:1px solid var(--issue-line)}
|
||||
.issue-center .issue-comments>header {display:flex;align-items:center;gap:8px;margin-bottom:12px}
|
||||
.issue-center .issue-comments>header button {margin-left:auto}
|
||||
.issue-center .issue-comment-list {display:flex;flex-direction:column;gap:10px;margin:12px 0}
|
||||
.issue-center .issue-comment-list article {border:1px solid var(--color-border);background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))}
|
||||
.issue-center .issue-comment-list article header {display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;padding:10px}
|
||||
.issue-center .issue-comment-list time {font-size:9px;color:var(--issue-muted)}
|
||||
.issue-center .issue-comment-list article p {white-space:pre-wrap;overflow-wrap:anywhere;padding:0 12px 12px;font-size:11px;line-height:1.6}
|
||||
.issue-center .comment-error {color:#e0737b;font-size:11px;overflow-wrap:anywhere}
|
||||
|
||||
.issue-center .issue-close-action {width:100%;min-height:39px;margin-bottom:10px;color:#e0737b;border-color:#a74b55;font-size:11px}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface PullRequestBadgeState {
|
||||
status: "loading" | "ready" | "error" | "unavailable";
|
||||
count: number;
|
||||
sourceId: string;
|
||||
repository: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ export function beginFrontendShutdown() {
|
||||
frontendShuttingDown = true;
|
||||
}
|
||||
|
||||
export function resumeFrontend() {
|
||||
frontendShuttingDown = false;
|
||||
}
|
||||
|
||||
// Telemetry must never contain repository contents or identifying local data.
|
||||
function sanitize(message: string): string {
|
||||
return message
|
||||
|
||||
@@ -65,6 +65,39 @@ export interface GitIntegrationRepository {
|
||||
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 {
|
||||
background: string;
|
||||
surface: string;
|
||||
@@ -161,6 +194,16 @@ export interface GitRemote { name: string; fetch_url: string; push_url: string;
|
||||
export type PullStrategy = "merge" | "rebase" | "ff-only";
|
||||
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 {
|
||||
path: string;
|
||||
old_path: string | null;
|
||||
@@ -226,6 +269,24 @@ export interface GitCommit {
|
||||
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 {
|
||||
path: string;
|
||||
old_path: string | null;
|
||||
@@ -387,3 +448,46 @@ export interface StoredCredential {
|
||||
password: string;
|
||||
mode?: "credentials" | "token";
|
||||
}
|
||||
|
||||
export interface IntegrationIssue {
|
||||
id: string;
|
||||
number: number;
|
||||
title: string;
|
||||
description: string;
|
||||
descriptionHtml: boolean;
|
||||
repositoryName: string;
|
||||
author: string;
|
||||
state: string;
|
||||
labels: string[];
|
||||
assignees: string[];
|
||||
webUrl: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface IntegrationIssuePage {
|
||||
issues: IntegrationIssue[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface IntegrationBoardCard {
|
||||
id: string;
|
||||
title: string;
|
||||
number: number;
|
||||
webUrl: string;
|
||||
repository: string;
|
||||
description: string;
|
||||
labels: string[];
|
||||
assignees: string[];
|
||||
lane: string;
|
||||
}
|
||||
|
||||
export interface IntegrationBoard {
|
||||
title: string;
|
||||
webUrl: string;
|
||||
notice: string;
|
||||
columns: Array<{ id: string; title: string; limit: number; moveTarget?: Record<string, unknown> | null; cards: IntegrationBoardCard[] }>;
|
||||
}
|
||||
|
||||
export interface IntegrationBoardReference { title: string; webUrl: string; scope: string }
|
||||
|
||||
export interface IssueComment { id: string; author: string; body: string; createdAt: string; bodyHtml: boolean }
|
||||
|
||||
Reference in New Issue
Block a user