Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40b871f888 | ||
|
|
be073a8f39 | ||
|
|
54972f24b8 | ||
|
|
eb4346fce2 | ||
|
|
5c39fa3e30 | ||
|
|
088cbc5e18 | ||
|
|
d0dd79354a | ||
|
|
c42f8eb352 | ||
|
|
136afa9921 | ||
|
|
67283bf37f | ||
|
|
95c72340fe | ||
|
|
abbd737a8d | ||
|
|
bb53de3b83 | ||
|
|
8c652b5df8 | ||
|
|
200f01185a | ||
|
|
814f0e3a40 | ||
|
|
cd224fcc55 | ||
|
|
298bda2b54 | ||
|
|
b7a5bfe29f | ||
|
|
ee35169feb | ||
|
|
c7d6fa680a | ||
|
|
f7f85d387d | ||
|
|
0539020545 | ||
|
|
80c7cf7280 | ||
|
|
1b2460761f | ||
|
|
9e373e77d1 | ||
|
|
e7d6a6e4f4 | ||
|
|
5aa9d291b6 | ||
|
|
82a5f16a0c | ||
|
|
daa3298c44 |
Generated
+25
-2
@@ -1,18 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.9.1",
|
"version": "2026.9.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.9.1",
|
"version": "2026.9.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucide/svelte": "^1.21.0",
|
"@lucide/svelte": "^1.21.0",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
"@tauri-apps/api": "^2.5.0",
|
"@tauri-apps/api": "^2.5.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
|
"dompurify": "^3.4.15",
|
||||||
|
"marked": "^18.0.12",
|
||||||
"simple-icons": "^16.24.1",
|
"simple-icons": "^16.24.1",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"tailwindcss": "^4.3.1"
|
"tailwindcss": "^4.3.1"
|
||||||
@@ -1557,6 +1559,15 @@
|
|||||||
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
|
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.21.6",
|
"version": "5.21.6",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
|
||||||
@@ -1975,6 +1986,18 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/mri": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.9.1",
|
"version": "2026.9.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
"@tauri-apps/api": "^2.5.0",
|
"@tauri-apps/api": "^2.5.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
|
"dompurify": "^3.4.15",
|
||||||
|
"marked": "^18.0.12",
|
||||||
"simple-icons": "^16.24.1",
|
"simple-icons": "^16.24.1",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"tailwindcss": "^4.3.1"
|
"tailwindcss": "^4.3.1"
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
import ts from 'typescript';
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('../src/lib/workspaces.ts', import.meta.url), 'utf8');
|
||||||
|
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText;
|
||||||
|
const { readWorkspaces, WORKSPACES_KEY } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
|
||||||
|
const storage = values => ({ getItem: key => values[key] ?? null });
|
||||||
|
|
||||||
|
test('migrates dashboard assignments without copying unrelated tabs into a workspace', () => {
|
||||||
|
const result = readWorkspaces(storage({ 'gitty.dashboard.v1': JSON.stringify({
|
||||||
|
workspaces: [{ id: 'workspace-a', name: 'A' }, { id: 'workspace-b', name: 'B' }],
|
||||||
|
assignments: { '/a': 'workspace-a', '/b': 'workspace-b', '/closed': 'workspace-a' },
|
||||||
|
}) }), ['/a', '/b', '/outside']);
|
||||||
|
assert.deepEqual(result.workspaces[0], { id: 'workspace-a', name: 'A', repositories: ['/a', '/closed'], openPaths: ['/a'], activePath: '/a' });
|
||||||
|
assert.deepEqual(result.workspaces[1].openPaths, ['/b']);
|
||||||
|
assert.deepEqual(result.defaultSession.openPaths, ['/a', '/b', '/outside']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restores independent tab order and last active repository', () => {
|
||||||
|
const saved = { selectedId: 'workspace-a', workspaces: [
|
||||||
|
{ id: 'workspace-a', name: 'A', repositories: ['/a', '/b'], openPaths: ['/b', '/a'], activePath: '/a' },
|
||||||
|
{ id: 'workspace-b', name: 'B', repositories: ['/b'], openPaths: [], activePath: '' },
|
||||||
|
], defaultSession: { openPaths: ['/outside'], activePath: '/outside' } };
|
||||||
|
assert.deepEqual(readWorkspaces(storage({ [WORKSPACES_KEY]: JSON.stringify(saved) }), []), saved);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops invalid memberships and stale active paths from saved sessions', () => {
|
||||||
|
const saved = { selectedId: 'deleted', workspaces: [null, { id: 'workspace-a', name: 'A',
|
||||||
|
repositories: ['/a', '/a', null], openPaths: ['/removed', '/a', 3], activePath: '/removed' }],
|
||||||
|
defaultSession: { openPaths: ['/outside'], activePath: '/removed' } };
|
||||||
|
const result = readWorkspaces(storage({ [WORKSPACES_KEY]: JSON.stringify(saved) }), []);
|
||||||
|
assert.equal(result.selectedId, '');
|
||||||
|
assert.deepEqual(result.workspaces[0].openPaths, ['/a']);
|
||||||
|
assert.equal(result.workspaces[0].activePath, '/a');
|
||||||
|
assert.deepEqual(result.workspaces[0].repositories, ['/a']);
|
||||||
|
assert.equal(result.defaultSession.activePath, '/outside');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unavailable or corrupt preferences preserve existing repository tabs', () => {
|
||||||
|
for (const source of [storage({ [WORKSPACES_KEY]: '{broken' }), { getItem() { throw new Error('Storage unavailable'); } }]) {
|
||||||
|
assert.deepEqual(readWorkspaces(source, ['/existing']).defaultSession.openPaths, ['/existing']);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -2615,10 +2615,9 @@ pub fn undo_last_commit(path: String) -> Result<GitStatus, String> {
|
|||||||
return Err("This is the first commit; there is nothing to undo to.".to_string());
|
return Err("This is the first commit; there is nothing to undo to.".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mixed reset: moves HEAD back one commit and unstages the difference, but
|
// Soft reset: moves HEAD back one commit while preserving the index and
|
||||||
// leaves the working tree files untouched, so the undone commit's changes
|
// working tree, so the undone commit's changes remain staged.
|
||||||
// reappear as ordinary uncommitted changes instead of being discarded.
|
run_git(&repo, ["reset", "--soft", "HEAD~1"])?;
|
||||||
run_git(&repo, ["reset", "HEAD~1"])?;
|
|
||||||
status_for_repo(&repo)
|
status_for_repo(&repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
|
mod issue_creation;
|
||||||
|
pub use issue_creation::*;
|
||||||
|
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 crate::git::load_stored_credential;
|
||||||
use reqwest::blocking::{Client, Response};
|
use reqwest::blocking::{Client, Response};
|
||||||
use reqwest::header::{ACCEPT, USER_AGENT};
|
use reqwest::header::{ACCEPT, USER_AGENT};
|
||||||
@@ -5,11 +16,14 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
mod creation;
|
||||||
|
pub use creation::{create_integration_review_request, list_integration_repository_branches};
|
||||||
|
|
||||||
const PAGE_SIZE: usize = 100;
|
const PAGE_SIZE: usize = 100;
|
||||||
const REVIEW_REQUEST_TIMEOUT: Duration = Duration::from_secs(35);
|
const REVIEW_REQUEST_TIMEOUT: Duration = Duration::from_secs(35);
|
||||||
const AZURE_PROJECT_WORKERS: usize = 8;
|
const AZURE_PROJECT_WORKERS: usize = 8;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct IntegrationRepository {
|
pub struct IntegrationRepository {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|||||||
@@ -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,103 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
fn azure_issue_context(client: &Client, base: &str, username: &str, token: &str, project: &str, number: u64) -> Result<(reqwest::Url, Value, Value), String> {
|
||||||
|
let mut url = comment_url("azure-devops", base, project, number)?;
|
||||||
|
url.path_segments_mut().map_err(|_| "Invalid Azure URL.")?.pop();
|
||||||
|
url.set_query(Some("api-version=7.1"));
|
||||||
|
let item = read(request(client, reqwest::Method::GET, url.clone(), "azure-devops", username, token)?.send().map_err(|e| e.to_string())?, "azure-devops")?;
|
||||||
|
let kind = value_string(&item, &["fields", "System.WorkItemType"]);
|
||||||
|
if kind.is_empty() { return Err("Azure returned no work item type.".into()); }
|
||||||
|
let mut states_url = reqwest::Url::parse(&normalized_base_url(base)?).map_err(|e| e.to_string())?;
|
||||||
|
states_url.path_segments_mut().map_err(|_| "Invalid Azure URL.")?.pop_if_empty().extend([project, "_apis", "wit", "workitemtypes", &kind, "states"]);
|
||||||
|
states_url.set_query(Some("api-version=7.1"));
|
||||||
|
let states = read(request(client, reqwest::Method::GET, states_url, "azure-devops", username, token)?.send().map_err(|e| e.to_string())?, "azure-devops")?;
|
||||||
|
Ok((url, item, states))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn azure_state_patch(item: &Value, states: &Value, state: &str) -> Result<Value, String> {
|
||||||
|
if state.is_empty() || !states["value"].as_array().into_iter().flatten().any(|entry| entry["name"].as_str() == Some(state)) {
|
||||||
|
return Err("This state is not configured for the work item type.".into());
|
||||||
|
}
|
||||||
|
let rev = item["rev"].as_u64().ok_or("Azure returned no revision.")?;
|
||||||
|
Ok(json!([{"op":"test","path":"/rev","value":rev},{"op":"add","path":"/fields/System.State","value":state}]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_azure_state(client: &Client, url: reqwest::Url, item: &Value, states: &Value, username: &str, token: &str, state: &str) -> Result<String, String> {
|
||||||
|
let payload = azure_state_patch(item, states, state)?;
|
||||||
|
if item["fields"]["System.State"] == state { return Ok(state.into()); }
|
||||||
|
let result = read(request(client, reqwest::Method::PATCH, url, "azure-devops", username, token)?.header("Content-Type", "application/json-patch+json").json(&payload).send().map_err(|e| e.to_string())?, "azure-devops")?;
|
||||||
|
let actual = value_string(&result, &["fields", "System.State"]);
|
||||||
|
if actual != state { return Err("Azure did not confirm the requested state.".into()); }
|
||||||
|
Ok(actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_azure_issue_states(base_url: String, username: String, token: String, repository: String, number: u64) -> Result<Vec<String>, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let (_, _, states) = azure_issue_context(&comment_client()?, &base_url, &username, &token, &repository, number)?;
|
||||||
|
let names: Vec<String> = states["value"].as_array().into_iter().flatten().filter_map(|entry| entry["name"].as_str()).filter(|name| !name.is_empty()).map(str::to_owned).collect();
|
||||||
|
if names.is_empty() { return Err("Azure returned no work item states.".into()); }
|
||||||
|
Ok(names)
|
||||||
|
}).await.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_azure_issue_state(base_url: String, username: String, token: String, repository: String, number: u64, state: String) -> Result<String, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let client = comment_client()?;
|
||||||
|
let (url, item, states) = azure_issue_context(&client, &base_url, &username, &token, &repository, number)?;
|
||||||
|
set_azure_state(&client, url, &item, &states, &username, &token, &state)
|
||||||
|
}).await.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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" {
|
||||||
|
let (url, item, states) = azure_issue_context(&client, &base_url, &username, &token, &repository, number)?;
|
||||||
|
let state = completed_state(&states)?;
|
||||||
|
set_azure_state(&client, url, &item, &states, &username, &token, &state)
|
||||||
|
} 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 state_changes_validate_custom_states_and_guard_revision() {
|
||||||
|
let item = json!({"rev":17,"fields":{"System.State":"New"}});
|
||||||
|
let states = json!({"value":[{"name":"Ready for QA"},{"name":"Active"}]});
|
||||||
|
let patch = azure_state_patch(&item, &states, "Ready for QA").unwrap();
|
||||||
|
assert_eq!(patch, json!([
|
||||||
|
{"op":"test","path":"/rev","value":17},
|
||||||
|
{"op":"add","path":"/fields/System.State","value":"Ready for QA"}
|
||||||
|
]));
|
||||||
|
assert!(azure_state_patch(&item, &states, "Closed").is_err());
|
||||||
|
assert!(azure_state_patch(&item, &states, "").is_err());
|
||||||
|
assert!(azure_state_patch(&json!({}), &states, "Active").is_err());
|
||||||
|
}
|
||||||
|
#[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,109 @@
|
|||||||
|
use super::*;
|
||||||
|
use super::issue_comments::{comment_client, comment_url, request};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
fn azure_url(base: &str, segments: &[&str]) -> Result<reqwest::Url, String> {
|
||||||
|
let mut url = reqwest::Url::parse(&normalized_base_url(base)?).map_err(|e| e.to_string())?;
|
||||||
|
url.path_segments_mut().map_err(|_| "Invalid Azure URL.")?.pop_if_empty().extend(segments.iter().copied());
|
||||||
|
url.set_query(Some("api-version=7.1"));
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(response: 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_azure_issue_projects(base_url: String, username: String, token: String) -> Result<Vec<String>, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let client = comment_client()?;
|
||||||
|
let mut projects = BTreeSet::new();
|
||||||
|
let mut cursor = String::new();
|
||||||
|
let mut seen = BTreeSet::new();
|
||||||
|
loop {
|
||||||
|
let mut url = azure_url(&base_url, &["_apis", "projects"])?;
|
||||||
|
url.query_pairs_mut().append_pair("$top", "100");
|
||||||
|
if !cursor.is_empty() { url.query_pairs_mut().append_pair("continuationToken", &cursor); }
|
||||||
|
let response = request(&client, reqwest::Method::GET, url, "azure-devops", &username, &token)?.send().map_err(|e| e.to_string())?;
|
||||||
|
let next = response.headers().get("x-ms-continuationtoken").and_then(|v| v.to_str().ok()).unwrap_or("").to_owned();
|
||||||
|
let value = read(response, "azure-devops")?;
|
||||||
|
let entries = value["value"].as_array().ok_or("Azure returned no project list.")?;
|
||||||
|
for entry in entries {
|
||||||
|
if let Some(name) = entry["name"].as_str().filter(|name| !name.is_empty()) { projects.insert(name.to_owned()); }
|
||||||
|
}
|
||||||
|
if next.is_empty() { break; }
|
||||||
|
if !seen.insert(next.clone()) { return Err("Azure repeated a project page.".into()); }
|
||||||
|
cursor = next;
|
||||||
|
}
|
||||||
|
Ok(projects.into_iter().collect())
|
||||||
|
}).await.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_azure_issue_types(base_url: String, username: String, token: String, project: String) -> Result<Vec<String>, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
if project.trim().is_empty() { return Err("Select an Azure project.".into()); }
|
||||||
|
let url = azure_url(&base_url, &[&project, "_apis", "wit", "workitemtypes"])?;
|
||||||
|
let value = read(request(&comment_client()?, reqwest::Method::GET, url, "azure-devops", &username, &token)?.send().map_err(|e| e.to_string())?, "azure-devops")?;
|
||||||
|
let entries = value["value"].as_array().ok_or("Azure returned no work item types.")?;
|
||||||
|
Ok(entries.iter().filter(|entry| entry["isDisabled"] != true).filter_map(|entry| entry["name"].as_str()).filter(|name| !name.is_empty()).map(str::to_owned).collect())
|
||||||
|
}).await.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn creation_request(provider: &str, base: &str, repository: &str, title: &str, description: &str, work_item_type: &str) -> Result<(reqwest::Url, Value), String> {
|
||||||
|
if title.trim().is_empty() { return Err("An issue title is required.".into()); }
|
||||||
|
if repository.trim().is_empty() { return Err("Select a repository or project.".into()); }
|
||||||
|
if provider == "azure-devops" {
|
||||||
|
if work_item_type.trim().is_empty() { return Err("Select a work item type.".into()); }
|
||||||
|
let url = azure_url(base, &[repository, "_apis", "wit", "workitems", &format!("${work_item_type}")])?;
|
||||||
|
// Azure descriptions are HTML; preserve literal user text and line breaks.
|
||||||
|
let html = description.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """).replace('\n', "<br>");
|
||||||
|
let mut payload = vec![json!({"op":"add","path":"/fields/System.Title","value":title.trim()})];
|
||||||
|
if !description.is_empty() { payload.push(json!({"op":"add","path":"/fields/System.Description","value":html})); }
|
||||||
|
Ok((url, json!(payload)))
|
||||||
|
} else {
|
||||||
|
let mut url = comment_url(provider, base, repository, 1)?;
|
||||||
|
url.path_segments_mut().map_err(|_| "Invalid issue URL.")?.pop().pop();
|
||||||
|
let payload = if provider.starts_with("gitlab") { json!({"title":title.trim(),"description":description}) } else { json!({"title":title.trim(),"body":description}) };
|
||||||
|
Ok((url, payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_integration_issue(provider: String, base_url: String, username: String, token: String, repository: String, title: String, description: String, work_item_type: String) -> Result<IntegrationIssue, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let (url, payload) = creation_request(&provider, &base_url, &repository, &title, &description, &work_item_type)?;
|
||||||
|
let client = comment_client()?;
|
||||||
|
let mut req = request(&client, reqwest::Method::POST, url, &provider, &username, &token)?;
|
||||||
|
if provider == "azure-devops" { req = req.header("Content-Type", "application/json-patch+json"); }
|
||||||
|
// Never retry a creation automatically: a lost response may still mean success.
|
||||||
|
let response = req.json(&payload).send().map_err(|e| format!("Creation was not confirmed. Check the provider before retrying: {e}"))?;
|
||||||
|
let value = read(response, &provider)?;
|
||||||
|
super::issues::created_issue(&value, &provider, &base_url, &repository)
|
||||||
|
}).await.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn routes_issue_creation_and_preserves_markdown() {
|
||||||
|
for (provider, path) in [("gitea", "/sub/api/v1/repos/team/repo/issues"), ("github", "/repos/team/repo/issues"), ("gitlab", "/sub/api/v4/projects/team%2Frepo/issues"), ("gitlab-self-hosted", "/sub/api/v4/projects/team%2Frepo/issues")] {
|
||||||
|
let (url, body) = creation_request(provider, "https://git.test/sub", "team/repo", " Title ", "**Details**", "").unwrap();
|
||||||
|
assert_eq!(url.path(), path);
|
||||||
|
assert_eq!(body["title"], "Title");
|
||||||
|
assert_eq!(body[if provider.starts_with("gitlab") { "description" } else { "body" }], "**Details**");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn azure_uses_custom_type_and_escapes_description() {
|
||||||
|
let (url, body) = creation_request("azure-devops", "https://dev.azure.com/org", "My Project", "Task", "<script>&\nNext", "Custom Task").unwrap();
|
||||||
|
assert_eq!(url.path(), "/org/My%20Project/_apis/wit/workitems/$Custom%20Task");
|
||||||
|
assert_eq!(url.query(), Some("api-version=7.1"));
|
||||||
|
assert_eq!(body[1]["value"], "<script>&<br>Next");
|
||||||
|
assert!(creation_request("gitea", "https://git.test", "team/repo", " ", "", "").is_err());
|
||||||
|
assert!(creation_request("azure-devops", "https://dev.azure.com/org", "Project", "Task", "", "").is_err());
|
||||||
|
assert!(creation_request("gitea", "https://git.test", "invalid", "Task", "", "").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
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"]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn created_issue(value: &serde_json::Value, provider: &str, base: &str, repository: &str) -> Result<IntegrationIssue, String> {
|
||||||
|
let mut issue = if provider == "azure-devops" { azure_issue(value, base) } else { parse_issue(value, provider).ok_or("The provider returned no issue. Check the provider before retrying.")? };
|
||||||
|
if issue.number == 0 || value_u64(value, "id") == 0 { return Err("The provider returned no issue number. Check the provider before retrying.".into()); }
|
||||||
|
issue.repository_name = repository.to_owned();
|
||||||
|
Ok(issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 created_issue_keeps_target_when_response_omits_repository() {
|
||||||
|
let issue = created_issue(&serde_json::json!({"id":71,"number":4,"title":"New","state":"open"}), "gitea", "https://git.test", "team/repo").unwrap();
|
||||||
|
assert_eq!(issue.id, "gitea:71");
|
||||||
|
assert_eq!(issue.repository_name, "team/repo");
|
||||||
|
let azure = created_issue(&serde_json::json!({"id":42,"fields":{"System.Title":"Task","System.State":"New"}}), "azure-devops", "https://dev.azure.com/org", "Project").unwrap();
|
||||||
|
assert_eq!(azure.repository_name, "Project");
|
||||||
|
assert_eq!(azure.number, 42);
|
||||||
|
assert!(created_issue(&serde_json::json!({"number":4}), "gitea", "https://git.test", "team/repo").is_err());
|
||||||
|
}
|
||||||
|
#[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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-1
@@ -34,8 +34,10 @@ use git::{
|
|||||||
unstage_files, untrack_paths, update_remote,
|
unstage_files, untrack_paths, update_remote,
|
||||||
};
|
};
|
||||||
use integrations::{
|
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,
|
add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser,
|
||||||
run_integration_review_action,
|
create_integration_issue, list_azure_issue_projects, list_azure_issue_types,
|
||||||
|
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, list_azure_issue_states, set_azure_issue_state,
|
||||||
};
|
};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -437,8 +439,22 @@ async fn main() {
|
|||||||
cred_load,
|
cred_load,
|
||||||
cred_save,
|
cred_save,
|
||||||
cred_delete,
|
cred_delete,
|
||||||
|
list_integration_repository_branches,
|
||||||
|
create_integration_review_request,
|
||||||
list_integration_repositories,
|
list_integration_repositories,
|
||||||
list_integration_review_requests,
|
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,
|
||||||
|
create_integration_issue,
|
||||||
|
list_azure_issue_projects,
|
||||||
|
list_azure_issue_types,
|
||||||
|
close_integration_issue,
|
||||||
|
list_azure_issue_states,
|
||||||
|
set_azure_issue_state,
|
||||||
get_integration_review_details,
|
get_integration_review_details,
|
||||||
add_integration_review_comment,
|
add_integration_review_comment,
|
||||||
run_integration_review_action,
|
run_integration_review_action,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Gitty",
|
"productName": "Gitty",
|
||||||
"version": "2026.9.1",
|
"version": "2026.9.5",
|
||||||
"identifier": "com.gitty",
|
"identifier": "com.gitty",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run prepare:lfs && npm run dev",
|
"beforeDevCommand": "npm run prepare:lfs && npm run dev",
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"label": "main",
|
"label": "main",
|
||||||
|
"dragDropEnabled": false,
|
||||||
"title": "Gitty",
|
"title": "Gitty",
|
||||||
"width": 1200,
|
"width": 1200,
|
||||||
"height": 800,
|
"height": 800,
|
||||||
|
|||||||
+111
-11
@@ -10,6 +10,11 @@
|
|||||||
|
|
||||||
import TitleBar from "./lib/TitleBar.svelte";
|
import TitleBar from "./lib/TitleBar.svelte";
|
||||||
import RepoToolbar from "./lib/RepoToolbar.svelte";
|
import RepoToolbar from "./lib/RepoToolbar.svelte";
|
||||||
|
import 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 { readWorkspaces, WORKSPACES_KEY, type Workspace, type WorkspaceState } from "./lib/workspaces";
|
||||||
import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte";
|
import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte";
|
||||||
import ReviewCenter from "./lib/components/ReviewCenter.svelte";
|
import ReviewCenter from "./lib/components/ReviewCenter.svelte";
|
||||||
import RepoTabs from "./lib/RepoTabs.svelte";
|
import RepoTabs from "./lib/RepoTabs.svelte";
|
||||||
@@ -29,6 +34,7 @@
|
|||||||
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
|
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
|
||||||
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
|
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
|
||||||
import InitRepositoryDialog from "./lib/components/InitRepositoryDialog.svelte";
|
import InitRepositoryDialog from "./lib/components/InitRepositoryDialog.svelte";
|
||||||
|
import MergeProgressNotice from "./lib/components/MergeProgressNotice.svelte";
|
||||||
import MergeBranchDialog from "./lib/components/MergeBranchDialog.svelte";
|
import MergeBranchDialog from "./lib/components/MergeBranchDialog.svelte";
|
||||||
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
|
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
|
||||||
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
|
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
|
||||||
@@ -218,7 +224,7 @@
|
|||||||
import { setTelemetryEnabled, tracedInvoke } from "./lib/telemetry";
|
import { setTelemetryEnabled, tracedInvoke } from "./lib/telemetry";
|
||||||
|
|
||||||
type UpdateToastState = "available" | "downloading" | "installed" | "error";
|
type UpdateToastState = "available" | "downloading" | "installed" | "error";
|
||||||
type AppView = "management" | "review-center" | "repository";
|
type AppView = "management" | "review-center" | "issues" | "repository";
|
||||||
type ReviewConflictPhase = "idle" | "preparing" | "conflicts" | "ready-to-continue" | "ready-to-push" | "complete" | "error";
|
type ReviewConflictPhase = "idle" | "preparing" | "conflicts" | "ready-to-continue" | "ready-to-push" | "complete" | "error";
|
||||||
type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
|
type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
|
||||||
type CredentialMode = "credentials" | "token";
|
type CredentialMode = "credentials" | "token";
|
||||||
@@ -326,13 +332,15 @@
|
|||||||
let reviewConflictRemote = "";
|
let reviewConflictRemote = "";
|
||||||
let reviewConflictPushRequested = false;
|
let reviewConflictPushRequested = false;
|
||||||
let repoTabs: RepoTab[] = [];
|
let repoTabs: RepoTab[] = [];
|
||||||
|
let workspaceState: WorkspaceState = { selectedId: "", workspaces: [], defaultSession: { openPaths: [], activePath: "" } };
|
||||||
|
$: workspaceOptions = [{ value: "", label: appLanguage === "de" ? "Alle Repositories" : "All repositories" }, ...workspaceState.workspaces.map(item => ({ value: item.id, label: item.name }))];
|
||||||
let repoTabContextMenu: RepoTabContextMenu | null = null;
|
let repoTabContextMenu: RepoTabContextMenu | null = null;
|
||||||
let recentRepoPaths: string[] = [];
|
let recentRepoPaths: string[] = [];
|
||||||
let favoriteRepoPaths: string[] = [];
|
let favoriteRepoPaths: string[] = [];
|
||||||
// Last-seen branch/ahead/behind/changed for repos that are known (recent/favorites)
|
// 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).
|
// but not currently open as a tab — keyed by normalized path (repoKey).
|
||||||
let repoStatusCache: Record<string, RepoTab> = {};
|
let repoStatusCache: Record<string, RepoTab> = {};
|
||||||
$: dashboardRepos = uniqueRepoPaths([...repoTabs.map(tab => tab.path), ...recentRepoPaths, ...favoriteRepoPaths])
|
$: dashboardRepos = uniqueRepoPaths([...repoTabs.map(tab => tab.path), ...recentRepoPaths, ...favoriteRepoPaths, ...workspaceState.workspaces.flatMap(item => item.repositories)])
|
||||||
.map(path => ({ ...repoRowFromPath(path, repoTabs, repoStatusCache),
|
.map(path => ({ ...repoRowFromPath(path, repoTabs, repoStatusCache),
|
||||||
isOpen: repoTabs.some(tab => sameRepoPath(tab.path, path)),
|
isOpen: repoTabs.some(tab => sameRepoPath(tab.path, path)),
|
||||||
known: Boolean(repoStatusCache[repoKey(path)]),
|
known: Boolean(repoStatusCache[repoKey(path)]),
|
||||||
@@ -696,6 +704,9 @@
|
|||||||
|
|
||||||
initAnalytics();
|
initAnalytics();
|
||||||
loadRepoLists();
|
loadRepoLists();
|
||||||
|
workspaceState = readWorkspaces(localStorage, repoTabs.map(tab => tab.path));
|
||||||
|
repoTabs = currentWorkspaceSession().openPaths.map(path => repoRowFromPath(path));
|
||||||
|
persistWorkspaces();
|
||||||
|
|
||||||
void checkForUpdates();
|
void checkForUpdates();
|
||||||
aiSettings = loadAiSettings();
|
aiSettings = loadAiSettings();
|
||||||
@@ -709,6 +720,7 @@
|
|||||||
await closeStartupSplashscreen();
|
await closeStartupSplashscreen();
|
||||||
startupReady = true;
|
startupReady = true;
|
||||||
await receiveStartupRepository();
|
await receiveStartupRepository();
|
||||||
|
if (!activeRepoPath && currentWorkspaceSession().activePath) await openRepo(currentWorkspaceSession().activePath);
|
||||||
startBackgroundTimers();
|
startBackgroundTimers();
|
||||||
// Remote access can take seconds (offline networks, SSH negotiation,
|
// Remote access can take seconds (offline networks, SSH negotiation,
|
||||||
// credential helpers). It must never hold the startup screen hostage.
|
// credential helpers). It must never hold the startup screen hostage.
|
||||||
@@ -1667,7 +1679,63 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentWorkspaceSession() {
|
||||||
|
return workspaceState.workspaces.find(item => item.id === workspaceState.selectedId) ?? workspaceState.defaultSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistWorkspaces() {
|
||||||
|
const session = currentWorkspaceSession();
|
||||||
|
session.openPaths = repoTabs.map(tab => tab.path);
|
||||||
|
session.activePath = session.openPaths.find(path => sameRepoPath(path, activeRepoPath))
|
||||||
|
?? session.openPaths.find(path => sameRepoPath(path, session.activePath)) ?? session.openPaths[0] ?? "";
|
||||||
|
workspaceState = { ...workspaceState };
|
||||||
|
try { localStorage.setItem(WORKSPACES_KEY, JSON.stringify(workspaceState)); } catch { /* Optional local preferences. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchWorkspace(id: string) {
|
||||||
|
if (isBusy || id === workspaceState.selectedId) return;
|
||||||
|
persistWorkspaces();
|
||||||
|
repoOpenRequestId += 1;
|
||||||
|
closeRepoTabContextMenu();
|
||||||
|
resetRepositoryState(true);
|
||||||
|
workspaceState = { ...workspaceState, selectedId: id };
|
||||||
|
const session = currentWorkspaceSession();
|
||||||
|
repoTabs = session.openPaths.map(path => repoRowFromPath(path));
|
||||||
|
activeView = "management";
|
||||||
|
persistRepoLists();
|
||||||
|
if (session.activePath) await openRepo(session.activePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveWorkspace(id: string, name: string, repositories: string[]) {
|
||||||
|
if (isBusy) return;
|
||||||
|
persistWorkspaces();
|
||||||
|
const existing = workspaceState.workspaces.find(item => item.id === id);
|
||||||
|
const workspace: Workspace = existing
|
||||||
|
? { ...existing, name, repositories, openPaths: existing.openPaths.filter(path => repositories.some(repo => sameRepoPath(repo, path))) }
|
||||||
|
: { id: `workspace-${crypto.randomUUID()}`, name, repositories, openPaths: [], activePath: "" };
|
||||||
|
workspace.activePath = workspace.openPaths.includes(workspace.activePath) ? workspace.activePath : workspace.openPaths[0] ?? "";
|
||||||
|
workspaceState = { ...workspaceState, workspaces: [...workspaceState.workspaces.filter(item => item.id !== workspace.id), workspace] };
|
||||||
|
if (workspaceState.selectedId === workspace.id) {
|
||||||
|
repoTabs = repoTabs.filter(tab => repositories.some(path => sameRepoPath(path, tab.path)));
|
||||||
|
if (!repositories.some(path => sameRepoPath(path, activeRepoPath))) {
|
||||||
|
repoOpenRequestId += 1;
|
||||||
|
resetRepositoryState(true);
|
||||||
|
activeView = "management";
|
||||||
|
}
|
||||||
|
persistRepoLists();
|
||||||
|
} else await switchWorkspace(workspace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteWorkspace() {
|
||||||
|
if (isBusy || !workspaceState.selectedId) return;
|
||||||
|
const id = workspaceState.selectedId;
|
||||||
|
await switchWorkspace("");
|
||||||
|
workspaceState = { ...workspaceState, workspaces: workspaceState.workspaces.filter(item => item.id !== id) };
|
||||||
|
persistWorkspaces();
|
||||||
|
}
|
||||||
|
|
||||||
function persistRepoLists() {
|
function persistRepoLists() {
|
||||||
|
persistWorkspaces();
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(OPEN_REPOS_KEY, JSON.stringify(repoTabs.map((tab) => tab.path)));
|
localStorage.setItem(OPEN_REPOS_KEY, JSON.stringify(repoTabs.map((tab) => tab.path)));
|
||||||
localStorage.setItem(RECENT_REPOS_KEY, JSON.stringify(recentRepoPaths));
|
localStorage.setItem(RECENT_REPOS_KEY, JSON.stringify(recentRepoPaths));
|
||||||
@@ -2068,6 +2136,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function upsertRepoTab(path: string, nextStatus?: GitStatus | null) {
|
function upsertRepoTab(path: string, nextStatus?: GitStatus | null) {
|
||||||
|
const workspace = workspaceState.workspaces.find(item => item.id === workspaceState.selectedId);
|
||||||
|
if (workspace && !workspace.repositories.some(repo => sameRepoPath(repo, path))) {
|
||||||
|
workspace.repositories = [...workspace.repositories, path];
|
||||||
|
workspaceState = { ...workspaceState };
|
||||||
|
}
|
||||||
const existing = repoTabs.find((tab) => sameRepoPath(tab.path, path));
|
const existing = repoTabs.find((tab) => sameRepoPath(tab.path, path));
|
||||||
const next: RepoTab = {
|
const next: RepoTab = {
|
||||||
path,
|
path,
|
||||||
@@ -2794,6 +2867,19 @@
|
|||||||
return repoTabs.findIndex((tab) => sameRepoPath(tab.path, path));
|
return repoTabs.findIndex((tab) => sameRepoPath(tab.path, path));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reorderRepoTab(path: string, targetPath: string, after: boolean) {
|
||||||
|
if (isBusy || sameRepoPath(path, targetPath)) return;
|
||||||
|
const sourceIndex = repoTabIndex(path);
|
||||||
|
if (sourceIndex < 0 || repoTabIndex(targetPath) < 0) return;
|
||||||
|
const reordered = [...repoTabs];
|
||||||
|
const [moved] = reordered.splice(sourceIndex, 1);
|
||||||
|
const targetIndex = reordered.findIndex(tab => sameRepoPath(tab.path, targetPath));
|
||||||
|
reordered.splice(targetIndex + (after ? 1 : 0), 0, moved);
|
||||||
|
repoTabs = reordered;
|
||||||
|
closeRepoTabContextMenu();
|
||||||
|
persistRepoLists();
|
||||||
|
}
|
||||||
|
|
||||||
function closeRepoTabContextMenu() {
|
function closeRepoTabContextMenu() {
|
||||||
repoTabContextMenu = null;
|
repoTabContextMenu = null;
|
||||||
}
|
}
|
||||||
@@ -4714,7 +4800,7 @@
|
|||||||
async function undoLastCommitChange() {
|
async function undoLastCommitChange() {
|
||||||
if (!activeRepoPath || !canAmend || isBusy) return;
|
if (!activeRepoPath || !canAmend || isBusy) return;
|
||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
"Undo the last commit?\n\nIts changes come back as uncommitted changes in the working tree — nothing is discarded.",
|
"Undo the last commit?\n\nIts changes remain staged, ready to commit again. Your working tree files are preserved.",
|
||||||
);
|
);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
@@ -5387,13 +5473,23 @@
|
|||||||
|
|
||||||
<RepoTabs
|
<RepoTabs
|
||||||
{activeView}
|
{activeView}
|
||||||
|
{workspaceOptions}
|
||||||
|
workspaceId={workspaceState.selectedId}
|
||||||
|
onWorkspaceChange={switchWorkspace}
|
||||||
{repoTabs}
|
{repoTabs}
|
||||||
{isBusy}
|
{isBusy}
|
||||||
language={appLanguage}
|
language={appLanguage}
|
||||||
onOpenManagement={openRepoManagement}
|
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()}
|
onOpenReviewCenter={() => openReviewCenter()}
|
||||||
|
onOpenIssues={() => { activeView = "issues"; }}
|
||||||
isActive={(path) => activeView === "repository" && sameRepoPath(activeRepoPath, path)}
|
isActive={(path) => activeView === "repository" && sameRepoPath(activeRepoPath, path)}
|
||||||
onSelect={selectRepoTab}
|
onSelect={selectRepoTab}
|
||||||
|
onReorder={reorderRepoTab}
|
||||||
onClose={closeRepoTab}
|
onClose={closeRepoTab}
|
||||||
onContextMenu={openRepoTabContextMenu}
|
onContextMenu={openRepoTabContextMenu}
|
||||||
onAdd={chooseRepositoryFolder}
|
onAdd={chooseRepositoryFolder}
|
||||||
@@ -5500,13 +5596,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if workspaceActive && mergeInProgress}
|
{#if workspaceActive && mergeInProgress}
|
||||||
<section class="notice conflict" role="status">
|
<MergeProgressNotice conflictCount={conflictedFiles.length} {isBusy} language={appLanguage} onResolve={openResolveDialog} onContinue={continueMerge} onAbort={abortMerge} />
|
||||||
<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>
|
|
||||||
{:else if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress}
|
{:else if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress}
|
||||||
<section class="notice conflict" role="alert">
|
<section class="notice conflict" role="alert">
|
||||||
<GitMerge size={17} aria-hidden="true" />
|
<GitMerge size={17} aria-hidden="true" />
|
||||||
@@ -5553,18 +5643,28 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<PullRequestStatusLoader
|
||||||
|
repos={dashboardRepos} language={appLanguage}
|
||||||
|
integrations={gitIntegrationSettings} loadCredential={loadStoredCredential}
|
||||||
|
bind:pullRequestBadges
|
||||||
|
/>
|
||||||
{#if activeView === "management"}
|
{#if activeView === "management"}
|
||||||
<RepositoryDashboard
|
<RepositoryDashboard
|
||||||
repos={dashboardRepos} language={appLanguage} {isBusy}
|
repos={dashboardRepos} language={appLanguage} {isBusy}
|
||||||
integrations={gitIntegrationSettings} loadCredential={loadStoredCredential}
|
{pullRequestBadges}
|
||||||
|
workspaces={workspaceState.workspaces} selectedWorkspace={workspaceState.selectedId}
|
||||||
|
onWorkspaceChange={switchWorkspace} onSaveWorkspace={saveWorkspace} onDeleteWorkspace={deleteWorkspace}
|
||||||
onOpen={selectRepoTab} onAdd={chooseRepositoryFolder} onClone={openCloneDialog}
|
onOpen={selectRepoTab} onAdd={chooseRepositoryFolder} onClone={openCloneDialog}
|
||||||
onInit={initializeRepository}
|
onInit={initializeRepository}
|
||||||
onFavorite={toggleFavoriteRepo} onClose={closeDashboardRepository}
|
onFavorite={toggleFavoriteRepo} onClose={closeDashboardRepository}
|
||||||
onRemoveRecent={removeRepoFromRecent}
|
onRemoveRecent={removeRepoFromRecent}
|
||||||
onOpenPullRequests={(repository, sourceId) => openReviewCenter(repository, sourceId)}
|
onOpenPullRequests={(repository, sourceId) => openReviewCenter(repository, sourceId)}
|
||||||
/>
|
/>
|
||||||
|
{:else if activeView === "issues"}
|
||||||
|
<IssueCenter language={appLanguage} integrations={gitIntegrationSettings} loadCredential={loadStoredCredential} onOpenSettings={openAppSettings} />
|
||||||
{:else if activeView === "review-center"}
|
{:else if activeView === "review-center"}
|
||||||
<ReviewCenter
|
<ReviewCenter
|
||||||
|
localRepositoryPath={activeRepoPath}
|
||||||
language={appLanguage}
|
language={appLanguage}
|
||||||
integrations={gitIntegrationSettings}
|
integrations={gitIntegrationSettings}
|
||||||
initialQuery={reviewCenterInitialQuery}
|
initialQuery={reviewCenterInitialQuery}
|
||||||
|
|||||||
+71
-35
@@ -26,10 +26,13 @@
|
|||||||
--color-bar-text: #f0f1f2;
|
--color-bar-text: #f0f1f2;
|
||||||
--color-bar-muted: #8b8f94;
|
--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;
|
--font-mono: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--color-sync-ahead: #e0a040;
|
||||||
|
--color-sync-behind: #7aacff;
|
||||||
--app-color-scheme: dark;
|
--app-color-scheme: dark;
|
||||||
--app-bg: #222328;
|
--app-bg: #222328;
|
||||||
--app-button-bg: #32363d;
|
--app-button-bg: #32363d;
|
||||||
@@ -149,7 +152,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--color-ink);
|
color: var(--color-ink);
|
||||||
background: var(--app-bg);
|
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;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-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-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 { 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.ahead { color: var(--color-sync-ahead); background: color-mix(in srgb, var(--color-sync-ahead) 13%, transparent); }
|
||||||
.tb-sync.behind { color: #7aacff; background: rgba(122,172,255,0.13); }
|
.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; }
|
.tb-no-repo { color: var(--color-bar-muted); font-size: 12px; font-style: italic; }
|
||||||
|
|
||||||
@@ -842,8 +845,8 @@
|
|||||||
font-size: 9.5px;
|
font-size: 9.5px;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
.repo-action-count.behind { color: #7aacff; }
|
.repo-action-count.behind { color: var(--color-sync-behind); }
|
||||||
.repo-action-count.ahead { color: #e0a040; }
|
.repo-action-count.ahead { color: var(--color-sync-ahead); }
|
||||||
.repo-action.sync-primary.publish-local {
|
.repo-action.sync-primary.publish-local {
|
||||||
color: #f0bd6b;
|
color: #f0bd6b;
|
||||||
background: linear-gradient(180deg, rgba(224,160,64,.1), rgba(224,160,64,.045));
|
background: linear-gradient(180deg, rgba(224,160,64,.1), rgba(224,160,64,.045));
|
||||||
@@ -1305,8 +1308,8 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
.repo-row-meta .ahead { color: #e0a040; background: rgba(224,160,64,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: #7aacff; background: rgba(122,172,255,0.13); }
|
.repo-row-meta .behind { color: var(--color-sync-behind); background: color-mix(in srgb, var(--color-sync-behind) 13%, transparent); }
|
||||||
.repo-row-icon {
|
.repo-row-icon {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
min-width: 36px;
|
min-width: 36px;
|
||||||
@@ -1823,8 +1826,8 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.sync-stats strong:first-of-type { color: #e0a040; background: rgba(224,160,64,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: #7aacff; background: rgba(122,172,255,0.13); }
|
.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 span { color: var(--color-ink-dim); background: rgba(94,110,156,0.13); }
|
||||||
.sync-stats .sync-local-only {
|
.sync-stats .sync-local-only {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -3362,12 +3365,12 @@
|
|||||||
opacity: 0.08;
|
opacity: 0.08;
|
||||||
}
|
}
|
||||||
.graph-svg path.graph-segment-ahead {
|
.graph-svg path.graph-segment-ahead {
|
||||||
stroke: #e0a040;
|
stroke: var(--color-sync-ahead);
|
||||||
filter: drop-shadow(0 0 3px rgba(224,160,64,0.24));
|
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--color-sync-ahead) 24%, transparent));
|
||||||
}
|
}
|
||||||
|
/* Keep the lane color supplied by the graph; dashes indicate behind status. */
|
||||||
.graph-svg path.graph-segment-behind {
|
.graph-svg path.graph-segment-behind {
|
||||||
stroke-dasharray: 4 4;
|
stroke-dasharray: 4 4;
|
||||||
filter: drop-shadow(0 0 3px rgba(122,172,255,0.2));
|
|
||||||
}
|
}
|
||||||
.graph-svg path.graph-ref-connector {
|
.graph-svg path.graph-ref-connector {
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
@@ -3391,12 +3394,12 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
.graph-dot.ahead {
|
.graph-dot.ahead {
|
||||||
background: #e0a040;
|
background: var(--color-sync-ahead);
|
||||||
box-shadow: 0 0 0 1px rgba(224,160,64,0.42), 0 0 10px rgba(224,160,64,0.16);
|
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 {
|
.graph-dot.behind {
|
||||||
background: var(--dot-color, #5a8cf8);
|
background: var(--color-sync-behind);
|
||||||
box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8), 0 0 10px rgba(122,172,255,0.18);
|
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.tip { width: 14px; height: 14px; box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8); }
|
||||||
.graph-dot.merge {
|
.graph-dot.merge {
|
||||||
@@ -3455,10 +3458,10 @@
|
|||||||
}
|
}
|
||||||
.graph-row + .graph-row .commit-body { border-top: 1px solid rgba(226,232,240,0.075); }
|
.graph-row + .graph-row .commit-body { border-top: 1px solid rgba(226,232,240,0.075); }
|
||||||
.graph-row.graph-ahead-row .commit-body {
|
.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 {
|
.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 .commit-body { background: rgba(30,39,57,0.72); }
|
||||||
.graph-row:hover .graph-svg path { opacity: 1; stroke-width: 2.65; }
|
.graph-row:hover .graph-svg path { opacity: 1; stroke-width: 2.65; }
|
||||||
@@ -6739,8 +6742,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-status-spacer { flex: 1 1 auto; }
|
||||||
.workspace-health > span { width: 8px; height: 8px; border-radius: 50%; background: #d9891b; }
|
.workspace-health > span { width: 8px; height: 8px; border-radius: 50%; background: #d9891b; }
|
||||||
.workspace-health.clean > span { background: #2da44e; }
|
.workspace-health.clean > span { background: #2da44e; }
|
||||||
.workspace-statusbar .ahead { color: #d9891b; }
|
.workspace-statusbar .ahead { color: var(--color-sync-ahead); }
|
||||||
.workspace-statusbar .behind { color: var(--color-primary); }
|
.workspace-statusbar .behind { color: var(--color-sync-behind); }
|
||||||
.workspace-statusbar .workspace-local-only {
|
.workspace-statusbar .workspace-local-only {
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border: 1px dashed rgba(224,160,64,.42);
|
border: 1px dashed rgba(224,160,64,.42);
|
||||||
@@ -7793,11 +7796,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-branch svg { color: #0b63e6; }
|
||||||
:root[data-theme="light"] .workspace-statusbar .ahead {
|
:root[data-theme="light"] .workspace-statusbar .ahead {
|
||||||
color: #9a5200;
|
color: var(--color-sync-ahead);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
:root[data-theme="light"] .workspace-statusbar .behind {
|
:root[data-theme="light"] .workspace-statusbar .behind {
|
||||||
color: #0755c8;
|
color: var(--color-sync-behind);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
:root[data-theme="light"] .repo-action.sync-primary.publish-local,
|
:root[data-theme="light"] .repo-action.sync-primary.publish-local,
|
||||||
@@ -9027,30 +9030,24 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
|||||||
color: #d7dae0;
|
color: #d7dae0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keep every dialog close action neutral until it is intentionally targeted. */
|
/* All dialog dismiss buttons share hover and keyboard-focus feedback. */
|
||||||
:root .dialog-close:hover:not(:disabled),
|
:root :is(.dialog-close, .dialog-icon-button, .cred-close, .help-close, [data-dialog-close]):is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]) {
|
||||||
: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;
|
color: #ffffff;
|
||||||
border-color: #f0646d;
|
border-color: #f0646d;
|
||||||
background: #d93641;
|
background: #d93641;
|
||||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
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"] :is(.dialog-close, .dialog-icon-button, .cred-close, .help-close, [data-dialog-close]):is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]) {
|
||||||
: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;
|
color: #ffffff;
|
||||||
border-color: #a9212b;
|
border-color: #a9212b;
|
||||||
background: #c92f3a;
|
background: #c92f3a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root :is(.dialog-close, .dialog-icon-button, .cred-close, .help-close, [data-dialog-close]):is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]) svg {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
/* Keep the repository overview as a compact icon tab. */
|
/* Keep the repository overview as a compact icon tab. */
|
||||||
.repo-tab.management {
|
.repo-tab.management {
|
||||||
flex: 0 0 38px;
|
flex: 0 0 38px;
|
||||||
@@ -9059,3 +9056,42 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
justify-content: center;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Consistent page headers for repositories, pull requests, and issues. */
|
||||||
|
section > header.page-header.page-header {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 54px;
|
||||||
|
height: auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px 24px;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
background: color-mix(in srgb, var(--app-bg) 94%, var(--color-surface));
|
||||||
|
}
|
||||||
|
.page-header .page-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.page-header .page-heading > svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.page-header.page-header .page-heading h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 20px;
|
||||||
|
letter-spacing: normal;
|
||||||
|
}
|
||||||
|
|||||||
+131
-59
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Folder, GitBranch, GitPullRequest, Plus, X } from "@lucide/svelte";
|
import { Columns3, Database, Folder, House, GitPullRequest, Plus, X } from "@lucide/svelte";
|
||||||
|
|
||||||
|
import SelectMenu from "./components/SelectMenu.svelte";
|
||||||
|
|
||||||
interface RepositoryTabItem {
|
interface RepositoryTabItem {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -7,78 +9,148 @@
|
|||||||
branch: string | null;
|
branch: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export let activeView: "management" | "review-center" | "repository" = "management";
|
export let activeView: "management" | "review-center" | "issues" | "repository" = "management";
|
||||||
export let repoTabs: RepositoryTabItem[] = [];
|
export let repoTabs: RepositoryTabItem[] = [];
|
||||||
export let isBusy: boolean = false;
|
export let isBusy: boolean = false;
|
||||||
export let language: "en" | "de" = "en";
|
export let language: "en" | "de" = "en";
|
||||||
export let onOpenManagement: () => void = () => {};
|
export let onOpenManagement: () => void = () => {};
|
||||||
|
export let onOpenRepositories: () => void | Promise<void> = () => {};
|
||||||
|
export let onOpenIssues: () => void = () => {};
|
||||||
export let onOpenReviewCenter: () => void = () => {};
|
export let onOpenReviewCenter: () => void = () => {};
|
||||||
export let isActive: (path: string) => boolean = () => false;
|
export let isActive: (path: string) => boolean = () => false;
|
||||||
export let onSelect: (path: string) => void | Promise<void> = () => {};
|
export let onSelect: (path: string) => void | Promise<void> = () => {};
|
||||||
export let onClose: (path: string, event: MouseEvent) => void | Promise<void> = () => {};
|
export let onClose: (path: string, event: MouseEvent) => void | Promise<void> = () => {};
|
||||||
export let onContextMenu: (path: string, event: MouseEvent) => void = () => {};
|
export let onContextMenu: (path: string, event: MouseEvent) => void = () => {};
|
||||||
export let onAdd: () => void | Promise<void> = () => {};
|
export let onAdd: () => void | Promise<void> = () => {};
|
||||||
|
export let onReorder: (path: string, targetPath: string, after: boolean) => void = () => {};
|
||||||
|
|
||||||
|
export let workspaceId = "";
|
||||||
|
export let workspaceOptions: { value: string; label: string }[] = [];
|
||||||
|
export let onWorkspaceChange: (id: string) => unknown = () => {};
|
||||||
|
|
||||||
|
let navigation: HTMLElement;
|
||||||
|
let drag: { path: string; pointerId: number; startX: number; startScroll: number; source: number; target: number; width: number; centers: number[]; element: HTMLElement } | null = null;
|
||||||
|
let dragging = false;
|
||||||
|
let offset = 0;
|
||||||
|
let suppressClick = false;
|
||||||
|
|
||||||
|
function startDrag(event: PointerEvent, path: string) {
|
||||||
|
if (isBusy || event.button !== 0 || !event.isPrimary) return;
|
||||||
|
const element = event.currentTarget as HTMLElement;
|
||||||
|
const tabs = Array.from(navigation.querySelectorAll<HTMLElement>(".repository-tab"));
|
||||||
|
const source = repoTabs.findIndex(tab => tab.path === path);
|
||||||
|
drag = { path, pointerId: event.pointerId, startX: event.clientX,
|
||||||
|
startScroll: navigation.scrollLeft, source, target: source,
|
||||||
|
width: tabs[source].getBoundingClientRect().width + 4,
|
||||||
|
centers: tabs.map(tab => { const rect = tab.getBoundingClientRect(); return rect.left + rect.width / 2; }), element };
|
||||||
|
suppressClick = false;
|
||||||
|
element.setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveDrag(event: PointerEvent) {
|
||||||
|
if (!drag || event.pointerId !== drag.pointerId) return;
|
||||||
|
if (isBusy) { finishDrag(false); return; }
|
||||||
|
if (!dragging && Math.abs(event.clientX - drag.startX) < 5) return;
|
||||||
|
dragging = true;
|
||||||
|
suppressClick = true;
|
||||||
|
const bounds = navigation.getBoundingClientRect();
|
||||||
|
if (event.clientX < bounds.left + 35) navigation.scrollLeft -= 15;
|
||||||
|
if (event.clientX > bounds.right - 35) navigation.scrollLeft += 15;
|
||||||
|
offset = event.clientX - drag.startX + navigation.scrollLeft - drag.startScroll;
|
||||||
|
const center = drag.centers[drag.source] + offset;
|
||||||
|
let target = drag.source;
|
||||||
|
while (target < drag.centers.length - 1 && center > drag.centers[target + 1]) target++;
|
||||||
|
while (target > 0 && center < drag.centers[target - 1]) target--;
|
||||||
|
drag = { ...drag, target };
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishDrag(commit: boolean) {
|
||||||
|
const current = drag;
|
||||||
|
if (!current) return;
|
||||||
|
drag = null;
|
||||||
|
if (current.element.hasPointerCapture(current.pointerId)) current.element.releasePointerCapture(current.pointerId);
|
||||||
|
if (commit && dragging && !isBusy && current.target !== current.source) {
|
||||||
|
onReorder(current.path, repoTabs[current.target].path, current.target > current.source);
|
||||||
|
}
|
||||||
|
dragging = false;
|
||||||
|
offset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabOffset(index: number, current: typeof drag, moving: boolean, displacement: number) {
|
||||||
|
if (!current || !moving) return 0;
|
||||||
|
if (index === current.source) return displacement;
|
||||||
|
if (current.source < index && index <= current.target) return -current.width;
|
||||||
|
if (current.target <= index && index < current.source) return current.width;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="repo-tabbar" aria-label={language === "de" ? "Repository-Reiter" : "Repository tabs"}>
|
<svelte:window onpointermove={moveDrag} onpointerup={(event) => { if (event.pointerId === drag?.pointerId) finishDrag(true); }} onpointercancel={() => finishDrag(false)} onblur={() => finishDrag(false)} onkeydown={(event) => { if (event.key === "Escape") finishDrag(false); }}/>
|
||||||
<button
|
|
||||||
class="repo-tab management"
|
|
||||||
class:active={activeView === "management"}
|
|
||||||
type="button"
|
|
||||||
onclick={onOpenManagement}
|
|
||||||
disabled={isBusy}
|
|
||||||
title={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
|
|
||||||
aria-label={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
|
|
||||||
>
|
|
||||||
<Folder size={15} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="repo-tabs-scroll">
|
<header class="workspace-navigation">
|
||||||
<div class="repo-tab-wrap review-center-tab" class:active={activeView === "review-center"} role="presentation">
|
<nav class="global-navigation" aria-label={language === "de" ? "Hauptnavigation" : "Main navigation"}>
|
||||||
<button class="repo-tab" type="button" onclick={onOpenReviewCenter} disabled={isBusy} title="Review Center" aria-label="Review Center">
|
<button type="button" class:active={activeView === "management"} aria-current={activeView === "management" ? "page" : undefined} disabled={isBusy} onclick={onOpenManagement}><House size={17}/><span>Dashboard</span></button>
|
||||||
<GitPullRequest size={13} aria-hidden="true" />
|
<button type="button" class:active={activeView === "repository"} aria-current={activeView === "repository" ? "page" : undefined} disabled={isBusy} onclick={onOpenRepositories}><Database size={17}/><span>Repositories</span></button>
|
||||||
<span>PR</span>
|
<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>
|
<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>
|
||||||
</div>
|
</nav>
|
||||||
{#each repoTabs as repo (repo.path)}
|
{#if activeView === "repository"}
|
||||||
<div
|
<div class="repository-row">
|
||||||
class="repo-tab-wrap"
|
<nav bind:this={navigation} class="repository-navigation" class:reordering={dragging} aria-label={language === "de" ? "Geöffnete Repositories" : "Open repositories"}>
|
||||||
class:active={isActive(repo.path)}
|
{#each repoTabs as repo, index (repo.path)}
|
||||||
role="presentation"
|
<div class="repository-tab" class:active={isActive(repo.path)}
|
||||||
oncontextmenu={(event) => onContextMenu(repo.path, event)}
|
class:dragging={dragging && drag?.path === repo.path}
|
||||||
>
|
style:transform={`translateX(${tabOffset(index, drag, dragging, offset)}px)`}
|
||||||
<button
|
role="presentation" oncontextmenu={(event) => onContextMenu(repo.path, event)}>
|
||||||
class="repo-tab"
|
<button class="repository-select" type="button" onpointerdown={(event) => startDrag(event, repo.path)}
|
||||||
type="button"
|
onlostpointercapture={() => { if (drag) finishDrag(false); }}
|
||||||
onclick={() => onSelect(repo.path)}
|
onclick={(event) => { if (suppressClick) { event.preventDefault(); suppressClick = false; return; } onSelect(repo.path); }} disabled={isBusy} title={repo.path} aria-current={isActive(repo.path) ? "page" : undefined}><Folder size={16}/><span>{repo.name}</span></button>
|
||||||
disabled={isBusy}
|
<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>
|
||||||
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>
|
</div>
|
||||||
{/each}
|
{/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>
|
||||||
|
<div class="workspace-picker"><SelectMenu options={workspaceOptions} value={workspaceId} onChange={onWorkspaceChange} disabled={isBusy} ariaLabel={language === "de" ? "Workspace wechseln" : "Switch workspace"} /></div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
<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>
|
</header>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.repository-row{display:flex;min-width:0;align-items:stretch;background:color-mix(in srgb,var(--color-surface) 45%,var(--app-bg))}
|
||||||
|
.workspace-picker{flex:0 0 180px;min-width:0;align-self:center;padding:3px 5px 3px 8px;border-left:1px solid var(--color-border-subtle)}
|
||||||
|
.workspace-picker :global(.select-menu-trigger){min-height:28px;height:28px;font-size:12px}
|
||||||
|
@media(max-width:600px){.workspace-picker{flex-basis:140px}}
|
||||||
|
.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{flex:1;min-width:0;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-tab{position:relative;will-change:transform}
|
||||||
|
/* Animate the preview only. On drop, the new DOM order replaces the
|
||||||
|
transforms in the same render, so resetting them must not animate. */
|
||||||
|
.reordering .repository-tab{transition:transform 160ms ease}
|
||||||
|
.reordering .repository-tab.dragging{z-index:2;transition:none;background:var(--color-surface-raised);border-color:var(--color-accent);box-shadow:0 2px 12px #0005}
|
||||||
|
.repository-navigation.reordering,.reordering .repository-select{cursor:grabbing}
|
||||||
|
.repository-select{touch-action:pan-y;user-select:none}
|
||||||
|
@media(prefers-reduced-motion:reduce){.reordering .repository-tab{transition:none}}
|
||||||
|
.repository-select:not(:disabled){cursor:grab}
|
||||||
|
.reordering .repository-select:not(:disabled){cursor:grabbing}
|
||||||
|
.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>
|
||||||
|
|||||||
@@ -64,8 +64,9 @@
|
|||||||
if (!query) return activeRepositories;
|
if (!query) return activeRepositories;
|
||||||
return activeRepositories.filter((repository) => `${repository.fullName} ${repository.description}`.toLocaleLowerCase().includes(query));
|
return activeRepositories.filter((repository) => `${repository.fullName} ${repository.description}`.toLocaleLowerCase().includes(query));
|
||||||
});
|
});
|
||||||
const azureRepositoryGroups = $derived.by(() => {
|
const usesRepositoryGroups = $derived(activeSource?.provider === "azure-devops" || activeSource?.provider === "gitea");
|
||||||
if (activeSource?.provider !== "azure-devops") return [];
|
const repositoryGroups = $derived.by(() => {
|
||||||
|
if (!usesRepositoryGroups) return [];
|
||||||
const groups = new Map<string, GitIntegrationRepository[]>();
|
const groups = new Map<string, GitIntegrationRepository[]>();
|
||||||
for (const repository of filteredRepositories) {
|
for (const repository of filteredRepositories) {
|
||||||
const separator = repository.fullName.indexOf("/");
|
const separator = repository.fullName.indexOf("/");
|
||||||
@@ -390,8 +391,8 @@
|
|||||||
<div class="repository-state repository-state-error"><strong>{isGerman ? "Repositories konnten nicht geladen werden" : "Could not load repositories"}</strong><span>{repositoryError}</span></div>
|
<div class="repository-state repository-state-error"><strong>{isGerman ? "Repositories konnten nicht geladen werden" : "Could not load repositories"}</strong><span>{repositoryError}</span></div>
|
||||||
{:else if filteredRepositories.length === 0}
|
{:else if filteredRepositories.length === 0}
|
||||||
<div class="repository-state"><GitBranch size={20} /><span>{repositorySearch ? (isGerman ? "Keine passenden Repositories." : "No matching repositories.") : (isGerman ? "Keine Repositories gefunden." : "No repositories found.")}</span></div>
|
<div class="repository-state"><GitBranch size={20} /><span>{repositorySearch ? (isGerman ? "Keine passenden Repositories." : "No matching repositories.") : (isGerman ? "Keine Repositories gefunden." : "No repositories found.")}</span></div>
|
||||||
{:else if activeSource?.provider === "azure-devops"}
|
{:else if usesRepositoryGroups}
|
||||||
{#each azureRepositoryGroups as group (group.project)}
|
{#each repositoryGroups as group (group.project)}
|
||||||
<section class="repository-project-group" aria-label={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>
|
<div class="repository-project-header"><span>{group.project}</span><em>{group.repositories.length}</em></div>
|
||||||
{#each group.repositories as repository (repository.id)}
|
{#each group.repositories as repository (repository.id)}
|
||||||
|
|||||||
@@ -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,123 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
import { CirclePlus, X } from "@lucide/svelte";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
import { createIntegrationIssue, listIntegrationRepositories, listAzureIssueProjects, listAzureIssueTypes } from "../git";
|
||||||
|
import { integrationCredentialKey } from "../integrations";
|
||||||
|
import type { GitIntegrationSource, IntegrationIssue, StoredCredential } from "../types";
|
||||||
|
|
||||||
|
let { source, de, initialRepository = "", loadCredential, onClose, onCreated }: {
|
||||||
|
source: GitIntegrationSource; de: boolean; initialRepository?: string;
|
||||||
|
loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||||
|
onClose: () => void; onCreated: (issue: IntegrationIssue) => void;
|
||||||
|
} = $props();
|
||||||
|
let dialog: HTMLDialogElement;
|
||||||
|
let titleInput: HTMLInputElement;
|
||||||
|
let targets = $state<{ value: string; label: string; group?: string }[]>([]);
|
||||||
|
let repository = $state("");
|
||||||
|
let types = $state<string[]>([]);
|
||||||
|
let workItemType = $state("");
|
||||||
|
let title = $state("");
|
||||||
|
let description = $state("");
|
||||||
|
let loading = $state(true);
|
||||||
|
let typesLoading = $state(false);
|
||||||
|
let busy = $state(false);
|
||||||
|
let loadError = $state("");
|
||||||
|
let typeError = $state("");
|
||||||
|
let error = $state("");
|
||||||
|
let destroyed = false;
|
||||||
|
let typeGeneration = 0;
|
||||||
|
const azure = $derived(source.provider === "azure-devops");
|
||||||
|
const canSubmit = $derived(!loading && !typesLoading && !busy && targets.some(item => item.value === repository) && !!title.trim() && (!azure || types.includes(workItemType)));
|
||||||
|
|
||||||
|
async function credential() {
|
||||||
|
const result = await loadCredential(integrationCredentialKey(source.provider, source.accountId));
|
||||||
|
if (!result?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
async function loadTargets() {
|
||||||
|
loading = true; loadError = "";
|
||||||
|
try {
|
||||||
|
let options: typeof targets;
|
||||||
|
if (azure) {
|
||||||
|
const auth = await credential();
|
||||||
|
const projects = await listAzureIssueProjects(source.baseUrl, auth.username, auth.password);
|
||||||
|
options = projects.map(value => ({ value, label: value }));
|
||||||
|
} else {
|
||||||
|
const repos = await listIntegrationRepositories(source.provider, source.baseUrl, source.accountId);
|
||||||
|
options = [...repos].sort((a,b) => a.fullName.localeCompare(b.fullName)).map(repo => ({value: repo.fullName, label: repo.name, group: repo.fullName.includes("/") ? repo.fullName.slice(0, repo.fullName.lastIndexOf("/")) : undefined}));
|
||||||
|
}
|
||||||
|
if (destroyed) return;
|
||||||
|
targets = options;
|
||||||
|
const preferred = options.some(item => item.value === initialRepository) ? initialRepository : options.length === 1 ? options[0].value : "";
|
||||||
|
await selectTarget(preferred);
|
||||||
|
} catch (cause) { if (!destroyed) loadError = String(cause); }
|
||||||
|
finally { if (!destroyed) loading = false; }
|
||||||
|
}
|
||||||
|
async function selectTarget(value: string) {
|
||||||
|
repository = value; types = []; workItemType = ""; typeError = "";
|
||||||
|
const generation = ++typeGeneration;
|
||||||
|
typesLoading = azure && !!value;
|
||||||
|
if (!typesLoading) return;
|
||||||
|
try {
|
||||||
|
const auth = await credential();
|
||||||
|
const result = await listAzureIssueTypes(source.baseUrl, auth.username, auth.password, value);
|
||||||
|
if (destroyed || generation !== typeGeneration) return;
|
||||||
|
types = result;
|
||||||
|
if (result.length === 1) workItemType = result[0];
|
||||||
|
} catch (cause) { if (!destroyed && generation === typeGeneration) typeError = String(cause); }
|
||||||
|
finally { if (!destroyed && generation === typeGeneration) typesLoading = false; }
|
||||||
|
}
|
||||||
|
async function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
busy = true; error = "";
|
||||||
|
try {
|
||||||
|
const auth = await credential();
|
||||||
|
const issue = await createIntegrationIssue(source.provider, source.baseUrl, auth.username, auth.password, repository, title.trim(), description, workItemType);
|
||||||
|
onCreated(issue);
|
||||||
|
} catch (cause) { if (!destroyed) error = String(cause); }
|
||||||
|
finally { if (!destroyed) busy = false; }
|
||||||
|
}
|
||||||
|
onMount(() => { dialog.showModal(); titleInput.focus(); void loadTargets(); });
|
||||||
|
onDestroy(() => { destroyed = true; typeGeneration++; });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog bind:this={dialog} aria-labelledby="create-issue-title" oncancel={event => { event.preventDefault(); if (!busy) onClose(); }}>
|
||||||
|
<form onsubmit={submit}>
|
||||||
|
<header><CirclePlus size={20}/><div><h2 id="create-issue-title">{de ? "Neues Issue" : "New issue"}</h2><p>{source.label}</p></div><button class="dialog-close" type="button" aria-label={de ? "Schließen" : "Close"} disabled={busy} onclick={onClose}><X size={18}/></button></header>
|
||||||
|
<div class="body">
|
||||||
|
<div class="field"><span>{azure ? (de ? "Projekt" : "Project") : "Repository"}</span>
|
||||||
|
<SelectMenu value={repository} options={targets} showSelectedGroup searchable disabled={loading || busy} ariaLabel={azure ? (de ? "Projekt" : "Project") : "Repository"} placeholder={loading ? (de ? "Wird geladen …" : "Loading …") : (de ? "Bitte auswählen" : "Select an option")} searchPlaceholder={de ? "Suchen …" : "Search …"} onChange={value => void selectTarget(value)}/>
|
||||||
|
</div>
|
||||||
|
{#if loadError}<p class="error" role="alert">{loadError}</p><button type="button" disabled={loading || busy} onclick={loadTargets}>{de ? "Erneut laden" : "Retry"}</button>
|
||||||
|
{:else if !loading && !targets.length}<p>{de ? "Keine Repositories oder Projekte verfügbar." : "No repositories or projects available."}</p>{/if}
|
||||||
|
{#if azure}
|
||||||
|
<div class="field"><span>{de ? "Work-Item-Typ" : "Work item type"}</span><SelectMenu value={workItemType} options={types.map(value => ({value,label:value}))} disabled={!repository || typesLoading || busy} ariaLabel={de ? "Work-Item-Typ" : "Work item type"} placeholder={typesLoading ? (de ? "Wird geladen …" : "Loading …") : (de ? "Typ auswählen" : "Select type")} onChange={value => workItemType = value}/></div>
|
||||||
|
{#if typeError}<p class="error" role="alert">{typeError}</p><button type="button" disabled={typesLoading || busy} onclick={() => selectTarget(repository)}>{de ? "Typen erneut laden" : "Retry types"}</button>
|
||||||
|
{:else if repository && !typesLoading && !types.length}<p>{de ? "Keine Work-Item-Typen verfügbar." : "No work item types available."}</p>{/if}
|
||||||
|
{/if}
|
||||||
|
<label>{de ? "Titel" : "Title"}<input bind:this={titleInput} bind:value={title} disabled={busy} required placeholder={de ? "Was soll erledigt werden?" : "What needs to be done?"}/></label>
|
||||||
|
<label>{de ? "Beschreibung" : "Description"}<textarea bind:value={description} disabled={busy} rows="7" placeholder={de ? "Details zum Issue (optional)" : "Issue details (optional)"}></textarea></label>
|
||||||
|
{#if error}<p class="error" role="alert">{de ? "Issue konnte nicht bestätigt werden." : "Issue creation could not be confirmed."} {error}</p>{/if}
|
||||||
|
</div>
|
||||||
|
<footer><button type="button" disabled={busy} onclick={onClose}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit" disabled={!canSubmit}>{busy ? (de ? "Wird erstellt …" : "Creating …") : (de ? "Issue erstellen" : "Create issue")}</button></footer>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
dialog {margin:auto;width:min(640px,calc(100vw - 32px));max-height:calc(100dvh - 40px);padding:0;border:1px solid var(--color-border);background:var(--app-dialog-bg);color:var(--color-ink);box-shadow:var(--app-dialog-shadow);font:inherit;overflow:auto}
|
||||||
|
dialog::backdrop {background:#0007}
|
||||||
|
header {display:flex;align-items:center;gap:12px;padding:18px 24px;background:var(--app-dialog-chrome);border-bottom:1px solid var(--color-border)}
|
||||||
|
header>div {flex:1} header>:global(svg) {color:var(--color-accent)}
|
||||||
|
h2 {font-size:17px;margin:0} p {margin:4px 0;color:var(--color-ink-dim);font-size:12px}
|
||||||
|
.body {display:grid;gap:18px;padding:22px 24px}.field,label {display:grid;gap:8px;min-width:0;font-size:12px}
|
||||||
|
button,input,textarea {font:inherit;color:var(--color-ink);border:1px solid var(--color-border);background:var(--color-surface);border-radius:0}
|
||||||
|
input,textarea {box-sizing:border-box;width:100%;padding:10px;font-size:13px}textarea {resize:vertical;line-height:1.5}
|
||||||
|
button {padding:8px 12px;cursor:pointer;font-size:12px}button:disabled {opacity:.5;cursor:default}
|
||||||
|
input:focus-visible,textarea:focus-visible,button:focus-visible {outline:2px solid var(--color-accent);outline-offset:2px}
|
||||||
|
.dialog-close {display:grid;place-items:center;padding:6px;border:0;background:transparent}
|
||||||
|
.error {color:var(--color-danger,#e0737b);overflow-wrap:anywhere;line-height:1.5}
|
||||||
|
footer {display:flex;justify-content:flex-end;gap:10px;padding:16px 24px;border-top:1px solid var(--color-border);background:var(--app-dialog-chrome)}
|
||||||
|
.primary {background:var(--color-accent);border-color:var(--color-accent);color:#fff}
|
||||||
|
</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 data-dialog-close 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>
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
<script context="module" lang="ts">
|
||||||
|
const boardCache = new Map<string, IntegrationBoard>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
import { Columns3, ExternalLink, RefreshCw, Search, X } from "@lucide/svelte";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
import { readWorkspacePreferences, writeWorkspacePreferences } from "../workspacePreferences";
|
||||||
|
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 activeUrl = "";
|
||||||
|
let board: IntegrationBoard | null = null;
|
||||||
|
let loading = false;
|
||||||
|
let moving = false;
|
||||||
|
let dragged: { cardId: string; columnId: string } | null = null;
|
||||||
|
let dropColumn = "";
|
||||||
|
$: canMove = board?.columns.some(column => !!column.moveTarget) ?? false;
|
||||||
|
let error = "";
|
||||||
|
let query = "";
|
||||||
|
let repositoryFilter = "";
|
||||||
|
$: if (activeUrl) writeWorkspacePreferences(`${preferenceKey}:filters:${activeUrl}`, { repositoryFilter, query });
|
||||||
|
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)) })) ?? [];
|
||||||
|
onMount(() => {
|
||||||
|
let boardUrl = "";
|
||||||
|
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 && validBoardUrl(boardUrl)) 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 boardLabel(item: IntegrationBoardReference): string {
|
||||||
|
if (source.provider === "azure-devops") {
|
||||||
|
try {
|
||||||
|
const path = new URL(item.webUrl).pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
||||||
|
const index = path.indexOf("_boards");
|
||||||
|
if (index > 0 && path[index + 1] === "board" && path[index + 2] === "t") {
|
||||||
|
const project = path[index - 1];
|
||||||
|
const team = path[index + 3];
|
||||||
|
const name = path[index + 4];
|
||||||
|
if (team && name) {
|
||||||
|
// Use the stable URL names: loaded titles also contain project and team.
|
||||||
|
const defaultTeam = team === project || team === `${project} Team`;
|
||||||
|
return `${project}${defaultTeam ? "" : ` / ${team}`} · ${name}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* Keep the original label for nonstandard board links. */ }
|
||||||
|
}
|
||||||
|
return `${item.scope ? `${item.scope} · ` : ""}${item.title}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (moving) return;
|
||||||
|
const requestedUrl = url.trim();
|
||||||
|
if (!requestedUrl) return;
|
||||||
|
const requestGeneration = ++generation;
|
||||||
|
if (requestedUrl !== activeUrl) {
|
||||||
|
selected = null;
|
||||||
|
const saved = readWorkspacePreferences(`${preferenceKey}:filters:${requestedUrl}`);
|
||||||
|
repositoryFilter = saved.repositoryFilter ?? "";
|
||||||
|
query = saved.query ?? "";
|
||||||
|
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 ?? "" });
|
||||||
|
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 = "";
|
||||||
|
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!);
|
||||||
|
moving = false;
|
||||||
|
if (selected?.id === cardId) selectedColumnId = to;
|
||||||
|
await loadBoard(url);
|
||||||
|
} 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)}`;
|
||||||
|
}
|
||||||
|
} 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); }
|
||||||
|
}
|
||||||
|
</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: boardLabel(item) }))]} ariaLabel={de ? "Board auswählen" : "Select board"} onChange={value => { if (value && !moving) { void loadBoard(value); } }} />
|
||||||
|
<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 && activeUrl && validBoardUrl(activeUrl)}<button class="workspace-button" onclick={() => openOriginal(activeUrl)}><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>{boardLabel({ title: board.title, webUrl: board.webUrl, scope: "" })}</h2></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 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 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}
|
||||||
|
<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} compact />{/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}
|
||||||
|
{@const selectedColumn = board.columns.find(column => column.id === selectedColumnId)}
|
||||||
|
<aside class="issue-detail-drawer" aria-label={de ? "Kartendetails" : "Card details"}>
|
||||||
|
<header class="issue-detail-header">
|
||||||
|
<div><Columns3 size={17} /><strong>{source.label} · {de ? "Karte" : "Card"}</strong></div>
|
||||||
|
<div class="issue-detail-header-actions">
|
||||||
|
{#if selected.webUrl}<button class="workspace-button close-button" onclick={() => openOriginal(selected!.webUrl)} aria-label={de ? "Im Anbieter öffnen" : "Open in provider"}><ExternalLink size={16} /></button>{/if}
|
||||||
|
<button data-dialog-close class="workspace-button close-button" onclick={() => { selected = null; }} aria-label={de ? "Kartendetails schließen" : "Close card details"}><X size={17} /></button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="issue-detail-body">
|
||||||
|
<div class="issue-detail-main">
|
||||||
|
<div class="issue-detail-title"><div>{#if selected.number}<span>#{selected.number}</span>{/if}<h2>{selected.title}</h2></div>
|
||||||
|
{#if selectedColumn}<div class="issue-detail-summary"><span class="issue-status" data-tone={issueTone(selectedColumn.title)}><span class="status-dot"></span>{selectedColumn.title}</span></div>{/if}
|
||||||
|
</div>
|
||||||
|
<section class="issue-detail-description"><h3>{de ? "Beschreibung" : "Description"}</h3><div class="description">{selected.description || (de ? "Weitere Details im Original-Board." : "More details in the original board.")}</div></section>
|
||||||
|
</div>
|
||||||
|
<div class="issue-detail-sidebar">
|
||||||
|
{#if selected.webUrl}<button class="workspace-button inspector-open" onclick={() => openOriginal(selected!.webUrl)}><ExternalLink size={14} />{de ? "Im Browser öffnen" : "Open in browser"}</button>{/if}
|
||||||
|
{#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}
|
||||||
|
<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>
|
||||||
|
{#if selected.repository}<section><h3>Repository</h3><strong class="issue-detail-repo">{selected.repository}</strong></section>{/if}
|
||||||
|
{#if selected.lane}<section><h3>Swimlane</h3><span class="lane">{selected.lane}</span></section>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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 ? "Boards werden automatisch aus der Integration gesucht. Wähle oben ein Board aus, um seine Spalten und Karten zu laden." : "Boards are discovered automatically from the integration. Select a board above to load its columns and cards."}</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,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let names: string[] = [];
|
||||||
|
export let emptyLabel = "—";
|
||||||
|
export let compact = false;
|
||||||
|
$: initials = names[0]?.trim().split(/[\s._-]+/).filter(Boolean).slice(0, 2).map(part => part[0]).join("").toLocaleUpperCase() ?? "";
|
||||||
|
$: avatarText = compact && initials.length === 1 ? names[0]?.trim().slice(0, 2).toLocaleUpperCase() : initials;
|
||||||
|
</script>
|
||||||
|
<span class="issue-person" title={names.join(", ")} aria-label={names.length ? names.join(", ") : undefined}>
|
||||||
|
{#if names.length}<span class="issue-avatar" aria-hidden="true">{avatarText}</span>{#if !compact}<span class="person-name">{names[0]}{names.length > 1 ? ` +${names.length - 1}` : ""}</span>{/if}{:else}<span class="unassigned">{emptyLabel}</span>{/if}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
<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, Plus, FolderGit2, ExternalLink, RefreshCw, Search, Settings2, X, XCircle } from "@lucide/svelte";
|
||||||
|
import "../issueWorkspace.css";
|
||||||
|
import CreateIssueDialog from "./CreateIssueDialog.svelte";
|
||||||
|
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 { readWorkspacePreferences, writeWorkspacePreferences } from "../workspacePreferences";
|
||||||
|
import { listIntegrationIssues, closeIntegrationIssue, listAzureIssueStates, setAzureIssueState, openInBrowser } from "../git";
|
||||||
|
import { configuredIntegrationSources, integrationCredentialKey } from "../integrations";
|
||||||
|
import type { GitIntegrationSettings, GitIntegrationSource, 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 createSource: GitIntegrationSource | null = null;
|
||||||
|
let createKey = "";
|
||||||
|
let createRepository = "";
|
||||||
|
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 azureStates: string[] = [];
|
||||||
|
let statesLoading = false;
|
||||||
|
let statesError = "";
|
||||||
|
let statesGeneration = 0;
|
||||||
|
let lastStateKey = "";
|
||||||
|
let targetState = "";
|
||||||
|
let closedStates = new Map<string, string>();
|
||||||
|
let error = "";
|
||||||
|
let mounted = false;
|
||||||
|
let generation = 0;
|
||||||
|
let lastKey = "";
|
||||||
|
const preferenceKey = "gitty.issues.v1";
|
||||||
|
$: 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 = "";
|
||||||
|
restoreFilters(sourceKey);
|
||||||
|
error = "";
|
||||||
|
if (source) void loadIssues();
|
||||||
|
}
|
||||||
|
$: if (mounted && sourceKey && sourceKey === lastKey) {
|
||||||
|
writeWorkspacePreferences(preferenceKey, { sourceId: source?.id ?? "", viewMode });
|
||||||
|
writeWorkspacePreferences(`${preferenceKey}:${sourceKey}`, { stateFilter, repositoryFilter, query });
|
||||||
|
}
|
||||||
|
$: 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) : "";
|
||||||
|
$: stateKey = source?.provider === "azure-devops" && selected ? JSON.stringify([sourceKey, selected.id]) : "";
|
||||||
|
$: if (stateKey !== lastStateKey) {
|
||||||
|
lastStateKey = stateKey;
|
||||||
|
statesGeneration++;
|
||||||
|
azureStates = [];
|
||||||
|
statesError = "";
|
||||||
|
statesLoading = false;
|
||||||
|
actionError = "";
|
||||||
|
targetState = "";
|
||||||
|
if (stateKey) void loadAzureStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const saved = readWorkspacePreferences(preferenceKey);
|
||||||
|
sourceId = saved.sourceId ?? "";
|
||||||
|
viewMode = saved.viewMode === "board" ? "board" : "list";
|
||||||
|
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++; statesGeneration++; });
|
||||||
|
|
||||||
|
function restoreFilters(key: string) {
|
||||||
|
const saved = readWorkspacePreferences(`${preferenceKey}:${key}`);
|
||||||
|
stateFilter = saved.stateFilter ?? "";
|
||||||
|
repositoryFilter = saved.repositoryFilter ?? "";
|
||||||
|
query = saved.query ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 loadAzureStates() {
|
||||||
|
if (!selected || source?.provider !== "azure-devops") return;
|
||||||
|
const current = source;
|
||||||
|
const issue = selected;
|
||||||
|
const requestGeneration = ++statesGeneration;
|
||||||
|
statesLoading = true;
|
||||||
|
statesError = "";
|
||||||
|
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 states = await withTimeout(listAzureIssueStates(current.baseUrl, credential.username, credential.password, issue.repositoryName, issue.number), 70_000);
|
||||||
|
if (requestGeneration !== statesGeneration) return;
|
||||||
|
azureStates = states;
|
||||||
|
targetState = issue.state;
|
||||||
|
} catch (cause) {
|
||||||
|
if (requestGeneration === statesGeneration) statesError = `${de ? "Status konnten nicht geladen werden." : "Could not load states."} ${String(cause)}`;
|
||||||
|
} finally { if (requestGeneration === statesGeneration) statesLoading = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeIssueState(nextState?: string) {
|
||||||
|
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 = nextState && current.provider === "azure-devops"
|
||||||
|
? await setAzureIssueState(current.baseUrl, credential.username, credential.password, issue.repositoryName, issue.number, nextState)
|
||||||
|
: 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) });
|
||||||
|
if (!nextState) 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 ? "Status konnte nicht geändert werden. Bitte Schreibrechte und Übergangsregeln prüfen." : "Could not change state. Check write permissions and transition rules."} ${String(cause)}`;
|
||||||
|
} finally { closingId = ""; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueCreated(issue: IntegrationIssue) {
|
||||||
|
const key = createKey;
|
||||||
|
const saved = cache.get(key);
|
||||||
|
const next = [issue, ...(saved?.issues ?? []).filter(item => item.id !== issue.id)];
|
||||||
|
cache.set(key, { issues: next, nextCursor: saved?.nextCursor ?? null });
|
||||||
|
createSource = null;
|
||||||
|
if (sourceKey !== key) return;
|
||||||
|
generation++; // Ignore any list response started before creation.
|
||||||
|
loading = false;
|
||||||
|
issues = next;
|
||||||
|
viewMode = "list";
|
||||||
|
repositoryFilter = ""; stateFilter = ""; query = "";
|
||||||
|
const collapsed = new Set(collapsedRepositories);
|
||||||
|
collapsed.delete(JSON.stringify([key, issue.repositoryName]));
|
||||||
|
collapsedRepositories = collapsed;
|
||||||
|
selectedId = issue.id;
|
||||||
|
error = ""; actionError = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 page-header">
|
||||||
|
<div class="page-heading"><CircleDot size={17} /><h1>Issues</h1></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>
|
||||||
|
{#if source}<button class="workspace-button issue-create-button" onclick={() => { createKey = sourceKey; createRepository = repositoryFilter; createSource = source; }}><Plus size={14}/>{de ? "Neues Issue" : "New issue"}</button>{/if}
|
||||||
|
</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} compact />
|
||||||
|
</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 data-dialog-close 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 source?.provider === "azure-devops"}
|
||||||
|
<section>
|
||||||
|
<h3>{de ? "Status ändern" : "Change state"}</h3>
|
||||||
|
<SelectMenu value={targetState} options={azureStates.map(value => ({ value, label: value }))} disabled={statesLoading || !!closingId || loading || !azureStates.length} placeholder={statesLoading ? (de ? "Status werden geladen …" : "Loading states …") : selected.state} ariaLabel={de ? "Neuer Status" : "New state"} onChange={value => { targetState = value; }} />
|
||||||
|
<button class="workspace-button issue-state-action" disabled={statesLoading || !!closingId || loading || !targetState || targetState === selected.state || !azureStates.includes(targetState)} onclick={() => changeIssueState(targetState)}>{closingId === selected.id ? (de ? "Wird gespeichert …" : "Saving …") : (de ? "Status übernehmen" : "Apply state")}</button>
|
||||||
|
{#if statesError}<p class="comment-error" role="alert">{statesError}</p><button class="workspace-button" onclick={loadAzureStates}>{de ? "Erneut versuchen" : "Retry"}</button>{/if}
|
||||||
|
</section>
|
||||||
|
{:else if selected.state !== "closed" && closedStates.get(`${sourceKey}:${selected.id}`) !== selected.state}
|
||||||
|
<button class="workspace-button issue-close-action" disabled={!!closingId || loading} onclick={() => changeIssueState()}><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>
|
||||||
|
|
||||||
|
{#if createSource}
|
||||||
|
<CreateIssueDialog source={createSource} {de} initialRepository={createRepository} {loadCredential} onClose={() => { createSource = null; }} onCreated={issueCreated}/>
|
||||||
|
{/if}
|
||||||
@@ -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">
|
<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";
|
import type { GitFileStatus, PatchApplyAction } from "../types";
|
||||||
|
|
||||||
type PatchLineKind = "context" | "add" | "delete" | "meta";
|
type PatchLineKind = "context" | "add" | "delete" | "meta";
|
||||||
@@ -60,18 +60,26 @@
|
|||||||
const isGerman = $derived(language === "de");
|
const isGerman = $derived(language === "de");
|
||||||
|
|
||||||
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
||||||
|
let patchWidth = $state(0);
|
||||||
let patchScroll = $state<HTMLDivElement | null>(null);
|
let patchScroll = $state<HTMLDivElement | null>(null);
|
||||||
let selectedLineIds = $state<Set<string>>(new Set());
|
let selectedLineIds = $state<Set<string>>(new Set());
|
||||||
let lastSelectedLineId = $state("");
|
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 displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
||||||
let selectableLines = $derived(
|
let selectableLines = $derived(
|
||||||
parsed.hunks.flatMap((hunk) => hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete")),
|
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);
|
let selectedCount = $derived(selectableLines.filter((line) => selectedLineIds.has(line.id)).length);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
activeHunk = 0;
|
||||||
parsed = parsePatch(patch);
|
parsed = parsePatch(patch);
|
||||||
selectedLineIds = new Set();
|
selectedLineIds = new Set();
|
||||||
lastSelectedLineId = "";
|
lastSelectedLineId = "";
|
||||||
@@ -191,6 +199,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleHunkSelection(hunk: PatchHunk) {
|
function toggleHunkSelection(hunk: PatchHunk) {
|
||||||
|
if (isBusy || isLoading) return;
|
||||||
const changed = hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete");
|
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 allSelected = changed.length > 0 && changed.every((line) => selectedLineIds.has(line.id));
|
||||||
const next = new Set(selectedLineIds);
|
const next = new Set(selectedLineIds);
|
||||||
@@ -266,149 +275,105 @@
|
|||||||
|
|
||||||
function scrollToHunk(hunkId: string) {
|
function scrollToHunk(hunkId: string) {
|
||||||
const target = patchScroll?.querySelector<HTMLElement>(`[data-hunk-id="${hunkId}"]`);
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="dialog-backdrop" role="presentation">
|
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
||||||
<div class="dialog line-patch-dialog" role="dialog" aria-modal="true" aria-label="Line patch">
|
<div class="dialog line-patch-dialog" role="dialog" aria-modal="true" aria-label={t("Geänderte Zeilen", "Changed lines")}>
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
<div>
|
<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>
|
||||||
<span class="eyebrow">{scopeLabel}</span>
|
<div class="dialog-header-actions">
|
||||||
<p class="dialog-title" title={displayPath}>{displayPath}</p>
|
<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>
|
||||||
</div>
|
<button class="icon-action" type="button" onclick={onRefresh} disabled={isBusy || isLoading} aria-label={t("Aktualisieren", "Refresh")} title={t("Aktualisieren", "Refresh")}><RefreshCw size={16} /></button>
|
||||||
<div class="dialog-header-actions line-patch-header-actions">
|
<button data-dialog-close class="icon-action" type="button" onclick={onClose} disabled={isBusy} aria-label={t("Schließen", "Close")}><X size={16} /></button>
|
||||||
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="line-patch-body">
|
<div class="line-patch-body">
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<div class="blank-state">
|
<div class="blank-state"><LoaderCircle class="spin" size={18} />{t("Änderungen werden geladen …", "Loading changes …")}</div>
|
||||||
<LoaderCircle class="spin" size={18} aria-hidden="true" />
|
{:else if error}<div class="blank-state" role="alert">{error}</div>
|
||||||
Loading patch...
|
{:else if !patch.trim()}<div class="blank-state">{t("Keine Änderungen für diese Datei vorhanden.", "No changes available for this file.")}</div>
|
||||||
</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 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>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class:active={selectedCount > 0} class="line-patch-selection-bar">
|
<div class="patch-toolbar">
|
||||||
<div>
|
<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>
|
||||||
<MousePointer2 size={14} aria-hidden="true" />
|
<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>
|
||||||
{#if selectedCount > 0}
|
<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>
|
||||||
<strong>{selectedCount} {selectedCount === 1 ? "line" : "lines"} selected</strong>
|
<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>
|
||||||
<span>Shift-click to select a range.</span>
|
|
||||||
{:else}
|
|
||||||
<strong>Select changed lines</strong>
|
|
||||||
<span>Choose individual additions or deletions below.</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{/if}
|
<div class="line-patch-workspace" style={`--patch-visible-width: ${patchWidth}px`}>
|
||||||
</div>
|
<div class="line-patch-scroll" bind:this={patchScroll} bind:clientWidth={patchWidth} onscroll={updateActiveHunk}>
|
||||||
|
{#each parsed.hunks as hunk, index (hunk.id)}
|
||||||
<div class="line-patch-workspace">
|
<section class="line-patch-hunk" data-hunk-id={hunk.id} aria-label={`${t("Abschnitt", "Hunk")} ${index + 1}`}>
|
||||||
<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-hunk-head">
|
<div class="line-patch-hunk-head">
|
||||||
<button
|
<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")}>
|
||||||
class:all={hunkSelectionState(hunk) === "all"}
|
{#if hunkSelectionState(hunk) === "all"}<Check size={11} />{:else if hunkSelectionState(hunk) === "some"}<Minus size={11} />{/if}
|
||||||
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>
|
</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">
|
<div class="line-patch-hunk-actions">
|
||||||
{#if staged}
|
<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 discard" type="button" onclick={() => applyHunkAction("discard-staged", hunk)} disabled={isBusy}>
|
<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>
|
||||||
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}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="line-patch-lines">
|
<div class="line-patch-lines">
|
||||||
{#each hunk.lines as line (line.id)}
|
{#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}`}>
|
<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"}
|
{#if line.kind === "add" || line.kind === "delete"}
|
||||||
<button
|
<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)")}>
|
||||||
class="line-patch-line-select"
|
{#if selectedLineIds.has(line.id)}<Check size={11} />{/if}
|
||||||
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>
|
</button>
|
||||||
{:else}
|
{:else}<span class="line-patch-line-select-placeholder"></span>{/if}
|
||||||
<span class="line-patch-line-select-placeholder"></span>
|
<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>
|
||||||
{/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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<nav class="diff-overview line-patch-overview" aria-label="Change overview">
|
<nav class="diff-overview line-patch-overview" aria-label={t("Änderungsübersicht", "Change overview")}>
|
||||||
{#each parsed.hunks as hunk, index (hunk.id)}
|
{#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}
|
||||||
<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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
||||||
</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,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>
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, Circle, Download, FolderGit2, GitBranch, GitPullRequest, LoaderCircle, Plus, Search, Star, TriangleAlert, Trash2, X } from "@lucide/svelte";
|
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, Circle, Download, FolderGit2, GitBranch, GitPullRequest, LayoutGrid, List, Plus, Search, Star, TriangleAlert, Trash2, X } from "@lucide/svelte";
|
||||||
import { listIntegrationReviewRequests, listRemotes } from "../git";
|
import type { PullRequestBadgeState } from "../pullRequestBadges";
|
||||||
import { configuredIntegrationSources, integrationCredentialKey } from "../integrations";
|
|
||||||
import type { GitIntegrationSettings, GitIntegrationSource, IntegrationReviewRequest, StoredCredential } from "../types";
|
|
||||||
import SelectMenu from "./SelectMenu.svelte";
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
interface DashboardRepository {
|
interface DashboardRepository {
|
||||||
@@ -19,17 +17,7 @@
|
|||||||
recent: boolean;
|
recent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Workspace { id: string; name: string; }
|
import type { Workspace } from "../workspaces";
|
||||||
interface PullRequestBadgeState {
|
|
||||||
status: "loading" | "ready" | "error" | "unavailable";
|
|
||||||
count: number;
|
|
||||||
sourceId: string;
|
|
||||||
repository: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RemoteIdentity { host: string; repository: string; organization: string; }
|
|
||||||
|
|
||||||
export let repos: DashboardRepository[] = [];
|
export let repos: DashboardRepository[] = [];
|
||||||
export let language: "de" | "en" = "en";
|
export let language: "de" | "en" = "en";
|
||||||
export let isBusy = false;
|
export let isBusy = false;
|
||||||
@@ -40,85 +28,70 @@
|
|||||||
export let onInit: () => unknown;
|
export let onInit: () => unknown;
|
||||||
export let onFavorite: (path: string) => unknown;
|
export let onFavorite: (path: string) => unknown;
|
||||||
export let onRemoveRecent: (path: string) => unknown;
|
export let onRemoveRecent: (path: string) => unknown;
|
||||||
export let integrations: GitIntegrationSettings;
|
|
||||||
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
|
|
||||||
export let onOpenPullRequests: (repository: string, sourceId: string) => unknown;
|
export let onOpenPullRequests: (repository: string, sourceId: string) => unknown;
|
||||||
|
|
||||||
const STORAGE_KEY = "gitty.dashboard.v1";
|
const STORAGE_KEY = "gitty.dashboard.v1";
|
||||||
let query = "";
|
let query = "";
|
||||||
let selectedWorkspace = "";
|
let viewMode: "cards" | "list" = "list";
|
||||||
|
export let selectedWorkspace = "";
|
||||||
|
export let onWorkspaceChange: (id: string) => unknown;
|
||||||
|
export let onSaveWorkspace: (id: string, name: string, repositories: string[]) => unknown;
|
||||||
|
export let onDeleteWorkspace: () => unknown;
|
||||||
|
let editingWorkspaceId = "";
|
||||||
let collapsedCategories = new Set<string>();
|
let collapsedCategories = new Set<string>();
|
||||||
let workspaces: Workspace[] = [];
|
export let workspaces: Workspace[] = [];
|
||||||
let assignments: Record<string, string> = {};
|
|
||||||
let workspaceDialog: HTMLDialogElement;
|
let workspaceDialog: HTMLDialogElement;
|
||||||
let workspaceInput: HTMLInputElement;
|
let workspaceInput: HTMLInputElement;
|
||||||
let workspaceName = "";
|
let workspaceName = "";
|
||||||
let workspaceError = "";
|
let workspaceError = "";
|
||||||
let workspaceSelection = new Set<string>();
|
let workspaceSelection = new Set<string>();
|
||||||
let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
|
export let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
|
||||||
let pullRequestLoadGeneration = 0;
|
|
||||||
let lastPullRequestLoadKey = "";
|
|
||||||
|
|
||||||
$: de = language === "de";
|
$: de = language === "de";
|
||||||
$: normalizedQuery = query.trim().toLocaleLowerCase();
|
$: normalizedQuery = query.trim().toLocaleLowerCase();
|
||||||
$: filteredRepos = repos.filter((repo) => {
|
$: workspaceRepos = selectedWorkspace ? repos.filter(repo => workspaces.find(item => item.id === selectedWorkspace)?.repositories.includes(repo.path)) : repos;
|
||||||
|
$: filteredRepos = workspaceRepos.filter((repo) => {
|
||||||
const matchesSearch = !normalizedQuery || `${repo.name} ${repo.path} ${repo.branch ?? ""}`.toLocaleLowerCase().includes(normalizedQuery);
|
const matchesSearch = !normalizedQuery || `${repo.name} ${repo.path} ${repo.branch ?? ""}`.toLocaleLowerCase().includes(normalizedQuery);
|
||||||
return matchesSearch && (!selectedWorkspace || assignments[repo.path] === selectedWorkspace);
|
return matchesSearch;
|
||||||
});
|
});
|
||||||
$: openRepos = filteredRepos.filter((repo) => repo.isOpen);
|
$: openRepos = filteredRepos.filter((repo) => repo.isOpen);
|
||||||
$: favoriteRepos = filteredRepos.filter((repo) => repo.favorite);
|
$: favoriteRepos = filteredRepos.filter((repo) => repo.favorite);
|
||||||
$: recentRepos = filteredRepos.filter((repo) => repo.recent && !repo.isOpen);
|
$: recentRepos = filteredRepos.filter((repo) => (repo.recent || !!selectedWorkspace) && !repo.isOpen);
|
||||||
$: categories = [
|
$: categories = [
|
||||||
{ id: "open", label: "Open", repos: openRepos },
|
{ id: "open", label: "Open", repos: openRepos },
|
||||||
{ id: "favorites", label: de ? "Favoriten" : "Favorites", repos: favoriteRepos },
|
{ id: "favorites", label: de ? "Favoriten" : "Favorites", repos: favoriteRepos },
|
||||||
{ id: "recent", label: "Recent", repos: recentRepos },
|
{ id: "recent", label: selectedWorkspace ? (de ? "Weitere Repositories" : "Other repositories") : "Recent", repos: recentRepos },
|
||||||
];
|
];
|
||||||
$: changedCount = repos.filter((repo) => repo.known && repo.changed > 0).length;
|
$: changedCount = workspaceRepos.filter((repo) => repo.known && repo.changed > 0).length;
|
||||||
$: workspaceOptions = [
|
$: workspaceOptions = [
|
||||||
{ value: "", label: de ? "Alle Workspaces" : "All workspaces" },
|
{ value: "", label: de ? "Alle Repositories" : "All repositories" },
|
||||||
...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name, group: "Workspaces" })),
|
...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name, group: "Workspaces" })),
|
||||||
];
|
];
|
||||||
$: integrationSources = configuredIntegrationSources(integrations);
|
|
||||||
$: pullRequestLoadKey = JSON.stringify({
|
|
||||||
repositories: repos.map((repo) => repo.path),
|
|
||||||
sources: integrationSources.map((source) => [source.id, source.baseUrl]),
|
|
||||||
});
|
|
||||||
$: if (pullRequestLoadKey !== lastPullRequestLoadKey) {
|
|
||||||
lastPullRequestLoadKey = pullRequestLoadKey;
|
|
||||||
void loadPullRequestBadges();
|
|
||||||
}
|
|
||||||
|
|
||||||
loadPreferences();
|
loadPreferences();
|
||||||
|
|
||||||
function loadPreferences() {
|
function loadPreferences() {
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") as { workspaces?: unknown; assignments?: unknown };
|
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") as { workspaces?: unknown; assignments?: unknown; viewMode?: unknown };
|
||||||
if (Array.isArray(saved.workspaces)) {
|
viewMode = saved.viewMode === "cards" ? "cards" : "list";
|
||||||
workspaces = saved.workspaces.filter((item): item is Workspace => Boolean(
|
} catch { /* Optional display preferences. */ }
|
||||||
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() {
|
function savePreferences() {
|
||||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ workspaces, assignments })); }
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ viewMode })); }
|
||||||
catch { /* Preferences remain optional when storage is unavailable. */ }
|
catch { /* Optional display preferences. */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function openWorkspaceDialog() {
|
function setViewMode(mode: "cards" | "list") {
|
||||||
workspaceName = "";
|
viewMode = mode;
|
||||||
|
savePreferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openWorkspaceDialog(edit = false) {
|
||||||
|
const workspace = edit ? workspaces.find(item => item.id === selectedWorkspace) : undefined;
|
||||||
|
editingWorkspaceId = workspace?.id ?? "";
|
||||||
|
workspaceName = workspace?.name ?? "";
|
||||||
workspaceError = "";
|
workspaceError = "";
|
||||||
workspaceSelection = new Set();
|
workspaceSelection = new Set(workspace?.repositories ?? []);
|
||||||
workspaceDialog.showModal();
|
workspaceDialog.showModal();
|
||||||
requestAnimationFrame(() => workspaceInput.focus());
|
requestAnimationFrame(() => workspaceInput.focus());
|
||||||
}
|
}
|
||||||
@@ -137,27 +110,14 @@
|
|||||||
workspaceError = de ? "Bitte einen Namen eingeben." : "Please enter a name.";
|
workspaceError = de ? "Bitte einen Namen eingeben." : "Please enter a name.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (workspaces.some((workspace) => workspace.name.toLocaleLowerCase() === name.toLocaleLowerCase())) {
|
if (workspaces.some((workspace) => workspace.id !== editingWorkspaceId && workspace.name.toLocaleLowerCase() === name.toLocaleLowerCase())) {
|
||||||
workspaceError = de ? "Dieser Workspace existiert bereits." : "This workspace already exists.";
|
workspaceError = de ? "Dieser Workspace existiert bereits." : "This workspace already exists.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const id = `workspace-${crypto.randomUUID()}`;
|
void onSaveWorkspace(editingWorkspaceId, name, [...workspaceSelection]);
|
||||||
workspaces = [...workspaces, { id, name }];
|
|
||||||
assignments = { ...assignments, ...Object.fromEntries([...workspaceSelection].map((path) => [path, id])) };
|
|
||||||
selectedWorkspace = id;
|
|
||||||
savePreferences();
|
|
||||||
workspaceDialog.close();
|
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 openCard(path: string) { if (!isBusy) onOpen(path); }
|
||||||
function closeCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onClose(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 favoriteCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onFavorite(path); }
|
||||||
@@ -168,128 +128,11 @@
|
|||||||
if (!isBusy && badge?.status === "ready") onOpenPullRequests(badge.repository || repo.name, badge.sourceId);
|
if (!isBusy && badge?.status === "ready") onOpenPullRequests(badge.repository || repo.name, badge.sourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function remoteIdentity(value: string): RemoteIdentity | null {
|
function pullRequestTitle(badge: PullRequestBadgeState | undefined): string {
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
let host = "";
|
|
||||||
let path = "";
|
|
||||||
const scp = trimmed.match(/^[^@\s]+@([^:\s]+):(.+)$/);
|
|
||||||
if (scp) {
|
|
||||||
host = scp[1];
|
|
||||||
path = scp[2];
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
|
|
||||||
host = url.hostname;
|
|
||||||
path = url.pathname;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const parts = path.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").split("/").filter(Boolean);
|
|
||||||
const gitIndex = parts.findIndex((part) => part.toLocaleLowerCase() === "_git");
|
|
||||||
const normalizedHost = host.toLocaleLowerCase();
|
|
||||||
if (gitIndex > 0 && parts[gitIndex + 1]) return { host: normalizedHost, repository: `${parts[gitIndex - 1]}/${parts[gitIndex + 1]}`.toLocaleLowerCase(), organization: normalizedHost === "dev.azure.com" ? (parts[0] ?? "").toLocaleLowerCase() : "" };
|
|
||||||
if (parts[0]?.toLocaleLowerCase() === "v3" && parts.length >= 4) return { host: normalizedHost, repository: `${parts[2]}/${parts[3]}`.toLocaleLowerCase(), organization: parts[1].toLocaleLowerCase() };
|
|
||||||
return { host: normalizedHost, repository: parts.join("/").toLocaleLowerCase(), organization: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function sourceMatchesRemote(source: GitIntegrationSource, identity: RemoteIdentity): boolean {
|
|
||||||
try {
|
|
||||||
const sourceUrl = new URL(source.baseUrl);
|
|
||||||
const sourceHost = sourceUrl.hostname.toLocaleLowerCase();
|
|
||||||
const remoteHost = identity.host;
|
|
||||||
if (source.provider === "azure-devops" ? sourceHost.replace(/^ssh\./, "") !== remoteHost.replace(/^ssh\./, "") : sourceHost !== remoteHost) return false;
|
|
||||||
if (source.provider !== "azure-devops") return true;
|
|
||||||
const organization = sourceUrl.hostname.toLocaleLowerCase() === "dev.azure.com"
|
|
||||||
? sourceUrl.pathname.split("/").filter(Boolean)[0]?.toLocaleLowerCase() ?? ""
|
|
||||||
: sourceUrl.hostname.split(".")[0]?.toLocaleLowerCase() ?? "";
|
|
||||||
return !organization || !identity.organization || organization === identity.organization;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestMatchesRepository(request: IntegrationReviewRequest, repository: string): boolean {
|
|
||||||
const requestRepository = request.repositoryName.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLocaleLowerCase();
|
|
||||||
if (!requestRepository) return false;
|
|
||||||
if (requestRepository === repository || repository.endsWith(`/${requestRepository}`) || requestRepository.endsWith(`/${repository}`)) return true;
|
|
||||||
const requestParts = requestRepository.split("/");
|
|
||||||
const remoteParts = repository.split("/");
|
|
||||||
return requestParts[requestParts.length - 1] === remoteParts[remoteParts.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePullRequestBadge(path: string, state: PullRequestBadgeState, generation: number) {
|
|
||||||
if (generation !== pullRequestLoadGeneration) return;
|
|
||||||
pullRequestBadges = { ...pullRequestBadges, [path]: state };
|
|
||||||
}
|
|
||||||
|
|
||||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
||||||
promise.then((value) => { window.clearTimeout(timer); resolve(value); }, (error) => { window.clearTimeout(timer); reject(error); });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPullRequestBadges() {
|
|
||||||
const generation = ++pullRequestLoadGeneration;
|
|
||||||
const sources = integrationSources;
|
|
||||||
pullRequestBadges = Object.fromEntries(repos.map((repo) => [repo.path, {
|
|
||||||
status: sources.length ? "loading" : "unavailable",
|
|
||||||
count: 0,
|
|
||||||
sourceId: "",
|
|
||||||
repository: "",
|
|
||||||
message: sources.length ? "" : (de ? "Keine passende Integration eingerichtet." : "No matching integration configured."),
|
|
||||||
} satisfies PullRequestBadgeState]));
|
|
||||||
if (!repos.length || !sources.length) return;
|
|
||||||
|
|
||||||
const grouped = new Map<string, Array<{ repo: DashboardRepository; identity: RemoteIdentity }>>();
|
|
||||||
await Promise.all(repos.map(async (repo) => {
|
|
||||||
try {
|
|
||||||
const remotes = await listRemotes(repo.path);
|
|
||||||
const identities = remotes
|
|
||||||
.sort((left, right) => Number(right.name === "origin") - Number(left.name === "origin"))
|
|
||||||
.map((remote) => remoteIdentity(remote.fetch_url))
|
|
||||||
.filter((identity): identity is RemoteIdentity => Boolean(identity));
|
|
||||||
const match = identities.map((identity) => ({ identity, source: sources.find((source) => sourceMatchesRemote(source, identity)) })).find((item) => item.source);
|
|
||||||
if (!match?.source) {
|
|
||||||
updatePullRequestBadge(repo.path, { status: "unavailable", count: 0, sourceId: "", repository: "", message: de ? "Keine passende Integration für das Remote gefunden." : "No matching integration for this remote." }, generation);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
grouped.set(match.source.id, [...(grouped.get(match.source.id) ?? []), { repo, identity: match.identity }]);
|
|
||||||
} catch (error) {
|
|
||||||
updatePullRequestBadge(repo.path, { status: "error", count: 0, sourceId: "", repository: "", message: error instanceof Error ? error.message : String(error) }, generation);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
await Promise.all([...grouped.entries()].map(async ([sourceId, entries]) => {
|
|
||||||
const source = sources.find((candidate) => candidate.id === sourceId);
|
|
||||||
if (!source) return;
|
|
||||||
try {
|
|
||||||
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000, de ? "Der System-Schlüsselbund hat nicht geantwortet." : "The system keychain did not respond.");
|
|
||||||
if (!credential?.password) throw new Error(de ? "Kein Integrationstoken gespeichert." : "No integration token stored.");
|
|
||||||
const requests = await withTimeout(listIntegrationReviewRequests(source.provider, source.baseUrl, credential.username, credential.password, "open"), 46_000, de ? `${source.label} hat nicht geantwortet.` : `${source.label} did not respond.`);
|
|
||||||
for (const { repo, identity } of entries) {
|
|
||||||
updatePullRequestBadge(repo.path, {
|
|
||||||
status: "ready",
|
|
||||||
count: requests.filter((request) => requestMatchesRepository(request, identity.repository)).length,
|
|
||||||
sourceId,
|
|
||||||
repository: identity.repository,
|
|
||||||
message: "",
|
|
||||||
}, generation);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
for (const { repo, identity } of entries) updatePullRequestBadge(repo.path, { status: "error", count: 0, sourceId, repository: identity.repository, message }, generation);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function pullRequestTitle(repo: DashboardRepository): string {
|
|
||||||
const badge = pullRequestBadges[repo.path];
|
|
||||||
if (!badge || badge.status === "loading") return de ? "Offene Pull Requests werden geladen" : "Loading open pull requests";
|
if (!badge || badge.status === "loading") return de ? "Offene Pull Requests werden geladen" : "Loading open pull requests";
|
||||||
if (badge.status === "error" || badge.status === "unavailable") return badge.message;
|
if (badge.status === "error" || badge.status === "unavailable") return badge.message;
|
||||||
return de ? `${badge.count} offene Pull Requests` : `${badge.count} open pull requests`;
|
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) {
|
function toggleCategory(id: string) {
|
||||||
const next = new Set(collapsedCategories);
|
const next = new Set(collapsedCategories);
|
||||||
@@ -300,8 +143,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="repo-dashboard" aria-label={de ? "Repository-Verwaltung" : "Repository management"}>
|
<section class="repo-dashboard" aria-label={de ? "Repository-Verwaltung" : "Repository management"}>
|
||||||
<header class="dashboard-header">
|
<header class="dashboard-header page-header">
|
||||||
<h1>Repository Management</h1>
|
<div class="page-heading"><FolderGit2 size={17} /><h1>Dashboard</h1></div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button type="button" onclick={onClone} disabled={isBusy}><Download size={14} />{de ? "Klonen" : "Clone"}</button>
|
<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 type="button" onclick={onInit} disabled={isBusy}><FolderGit2 size={14} />{de ? "Initialisieren" : "Initialize"}</button>
|
||||||
@@ -314,24 +157,30 @@
|
|||||||
<Search size={14} />
|
<Search size={14} />
|
||||||
<input bind:value={query} autocomplete="off" spellcheck="false" placeholder={de ? "Repositories suchen …" : "Search repositories …"} aria-label={de ? "Repositories suchen" : "Search repositories"} />
|
<input bind:value={query} autocomplete="off" spellcheck="false" placeholder={de ? "Repositories suchen …" : "Search repositories …"} aria-label={de ? "Repositories suchen" : "Search repositories"} />
|
||||||
</label>
|
</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">
|
<div class="workspace-tools">
|
||||||
<SelectMenu
|
<SelectMenu
|
||||||
class="workspace-select"
|
class="workspace-select"
|
||||||
value={selectedWorkspace}
|
value={selectedWorkspace}
|
||||||
options={workspaceOptions}
|
options={workspaceOptions}
|
||||||
ariaLabel={de ? "Workspace auswählen" : "Select workspace"}
|
ariaLabel={de ? "Workspace auswählen" : "Select workspace"}
|
||||||
onChange={(value) => { selectedWorkspace = value; }}
|
disabled={isBusy}
|
||||||
|
onChange={onWorkspaceChange}
|
||||||
/>
|
/>
|
||||||
{#if selectedWorkspace}
|
{#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>
|
<button type="button" disabled={isBusy} onclick={() => openWorkspaceDialog(true)}>{de ? "Bearbeiten" : "Edit"}</button>
|
||||||
|
<button class="workspace-delete" type="button" disabled={isBusy} onclick={onDeleteWorkspace} 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}
|
{/if}
|
||||||
<button type="button" onclick={openWorkspaceDialog}><Plus size={14} />Workspace</button>
|
<button type="button" disabled={isBusy} onclick={() => openWorkspaceDialog()}><Plus size={14} />Workspace</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dashboard-summary">{repos.length} Repositories <span>·</span> {changedCount} {de ? "mit Änderungen" : "with changes"}</div>
|
<div class="dashboard-summary">{workspaceRepos.length} Repositories <span>·</span> {changedCount} {de ? "mit Änderungen" : "with changes"}</div>
|
||||||
|
|
||||||
<div class="dashboard-content">
|
<div class="dashboard-content" class:table-view={viewMode === "list"}>
|
||||||
{#each categories as category (category.id)}
|
{#each categories as category (category.id)}
|
||||||
<section class="dashboard-section">
|
<section class="dashboard-section">
|
||||||
<header class="section-header">
|
<header class="section-header">
|
||||||
@@ -342,29 +191,27 @@
|
|||||||
</header>
|
</header>
|
||||||
{#if !collapsedCategories.has(category.id)}
|
{#if !collapsedCategories.has(category.id)}
|
||||||
{#if category.repos.length > 0}
|
{#if category.repos.length > 0}
|
||||||
<div class="repo-grid">
|
<div class="repo-grid" class:list-view={viewMode === "list"} class:tiles-view={viewMode === "cards"}>
|
||||||
{#each category.repos as repo (repo.path)}
|
{#each category.repos as repo (repo.path)}
|
||||||
{@const pullRequests = pullRequestBadges[repo.path]}
|
{@const pullRequests = pullRequestBadges[repo.path]}
|
||||||
<article class="repo-card" class:open={repo.isOpen}>
|
<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} title={repo.path}>
|
<button class="card-main" type="button" onclick={() => openCard(repo.path)} disabled={isBusy}>
|
||||||
<span class="card-title"><FolderGit2 size={15} /><strong>{repo.name}</strong></span>
|
<span class="card-title"><FolderGit2 size={15} /><strong title={repo.path}>{repo.name}</strong></span>
|
||||||
<span class="card-path">{repo.path}</span>
|
<span class="card-branch" title={repo.branch ?? undefined}><GitBranch size={13} />{repo.branch ?? (de ? "Branch unbekannt" : "Unknown branch")}</span>
|
||||||
<span class="card-branch"><GitBranch size={13} />{repo.branch ?? (de ? "Branch unbekannt" : "Unknown branch")}</span>
|
|
||||||
<span class="card-status">
|
<span class="card-status">
|
||||||
{#if !repo.known}<span class="muted"><Circle size={10} />{de ? "Status nicht geladen" : "Status not loaded"}</span>
|
{#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 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}
|
{:else}<span class="clean"><Circle class="filled" size={9} />{de ? "Sauber" : "Clean"}</span>{/if}
|
||||||
{#if repo.ahead > 0}<span class="ahead"><ArrowUp size={13} />{repo.ahead} {de ? "voraus" : "ahead"}</span>{/if}
|
|
||||||
{#if repo.behind > 0}<span class="behind"><ArrowDown size={13} />{repo.behind} {de ? "zurück" : "behind"}</span>{/if}
|
|
||||||
</span>
|
</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>
|
</button>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<button class:has-open-prs={pullRequests?.status === "ready" && pullRequests.count > 0} class:pr-error={pullRequests?.status === "error"} class="pr-badge" type="button" onclick={(event) => openPullRequests(event, repo)} disabled={isBusy || !pullRequests || pullRequests.status !== "ready"} aria-label={`${repo.name}: ${pullRequestTitle(repo)}`} title={pullRequestTitle(repo)}>
|
<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 || pullRequests.status === "loading"}<LoaderCircle class="spin" size={14} />{:else if pullRequests.status === "error"}<TriangleAlert size={14} />{:else}<GitPullRequest size={14} /><span>{pullRequests.status === "ready" ? pullRequests.count : "–"}</span>{/if}
|
{#if pullRequests?.status === "error"}<TriangleAlert size={14} />{:else}<GitPullRequest size={14} /><span>{pullRequests?.status === "ready" ? pullRequests.count : "–"}</span>{/if}
|
||||||
</button>
|
</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>
|
<button class:active={repo.favorite} type="button" onclick={(event) => favoriteCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Favorit umschalten" : "Toggle favorite"}`} aria-pressed={repo.favorite}><Star size={15} /></button>
|
||||||
{#if repo.isOpen}<button type="button" onclick={(event) => closeCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Schließen" : "Close"}`} title={de ? "Repository schließen" : "Close repository"}><X size={15} /></button>{/if}
|
{#if 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}
|
{#if category.id === "recent" && !selectedWorkspace}<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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -376,21 +223,21 @@
|
|||||||
</section>
|
</section>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<footer class="dashboard-footer">{repos.length} {de ? "lokale Repositories" : "local repositories"}</footer>
|
<footer class="dashboard-footer">{workspaceRepos.length} {de ? "lokale Repositories" : "local repositories"}</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<dialog class="workspace-dialog" bind:this={workspaceDialog} aria-labelledby="workspace-title">
|
<dialog class="workspace-dialog" bind:this={workspaceDialog} aria-labelledby="workspace-title">
|
||||||
<form onsubmit={createWorkspace}>
|
<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>
|
<header><h2 id="workspace-title">{editingWorkspaceId ? (de ? "Workspace bearbeiten" : "Edit workspace") : (de ? "Workspace anlegen" : "Create workspace")}</h2><button data-dialog-close type="button" onclick={() => workspaceDialog.close()} aria-label={de ? "Dialog schließen" : "Close dialog"}><X size={14} /></button></header>
|
||||||
<label for="workspace-name">Name</label>
|
<label for="workspace-name">Name</label>
|
||||||
<input id="workspace-name" bind:this={workspaceInput} bind:value={workspaceName} maxlength="64" autocomplete="off" aria-invalid={Boolean(workspaceError)} />
|
<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 workspaceError}<p class="dialog-error" role="alert">{workspaceError}</p>{/if}
|
||||||
{#if repos.length > 0}
|
{#if repos.length > 0}
|
||||||
<fieldset><legend>{de ? "Repositories zuordnen" : "Assign repositories"}</legend><div class="workspace-repos">
|
<fieldset><legend>{de ? "Repositories zuordnen" : "Assign repositories"} · {workspaceSelection.size} {de ? "ausgewählt" : "selected"}</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}
|
{#each repos as repo (repo.path)}<label class:selected={workspaceSelection.has(repo.path)}><input type="checkbox" checked={workspaceSelection.has(repo.path)} onchange={() => toggleWorkspaceRepo(repo.path)} /><span>{repo.name}</span><small>{repo.path}</small></label>{/each}
|
||||||
</div></fieldset>
|
</div></fieldset>
|
||||||
{/if}
|
{/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>
|
<footer><button type="button" onclick={() => workspaceDialog.close()}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit"><Check size={14} />{editingWorkspaceId ? (de ? "Speichern" : "Save") : (de ? "Anlegen" : "Create")}</button></footer>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
@@ -398,11 +245,78 @@
|
|||||||
.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}
|
.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-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-toolbar{display:flex;min-height:48px;align-items:center;gap:8px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-search{display:flex;width:300px;height:30px;align-items:center;gap:8px;padding:0 9px;border:1px solid var(--color-border-input);border-radius:var(--ui-radius-sm);color:var(--color-ink-faint);background:var(--app-input-bg)}.dashboard-search:focus-within{border-color:var(--color-accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--color-primary) 14%,transparent)}.dashboard-search :global(svg){flex:0 0 auto}.dashboard-search input{width:100%;min-width:0;height:100%;padding:0;border:0;border-radius:0;outline:0;color:var(--color-ink);background:transparent;box-shadow:none}.dashboard-search input:focus,.dashboard-search input:focus-visible{border:0;outline:0;box-shadow:none}.workspace-tools{margin-left:auto}.workspace-tools :global(.workspace-select){width:240px}.workspace-tools :global(.workspace-select .select-menu-trigger){height:30px;min-height:30px;padding:0 9px;border-color:var(--color-border-input);background:var(--app-input-bg);font-size:12px;font-weight:500}.workspace-tools .workspace-delete{width:30px;min-width:30px;padding:0;border-color:color-mix(in srgb,#df626b 55%,var(--color-border));color:#df747b;background:color-mix(in srgb,#c92f3a 8%,var(--app-button-bg))}.workspace-tools .workspace-delete:hover:not(:disabled){border-color:#df626b;color:#fff;background:#c93b45;box-shadow:0 0 0 2px color-mix(in srgb,#c92f3a 18%,transparent)}
|
||||||
.dashboard-summary{padding:8px 20px;color:var(--color-ink-muted)}.dashboard-summary span{padding:0 6px;color:var(--color-ink-faint)}.dashboard-content{flex:1;min-height:0;overflow:auto;padding:0 20px 24px}.dashboard-section+.dashboard-section{margin-top:12px}.section-header{min-height:31px}.section-toggle{display:flex;width:100%;min-height:31px;align-items:center;justify-content:flex-start;gap:7px;padding:0;border:0;background:transparent;color:var(--color-ink);text-align:left}.section-toggle:hover:not(:disabled){border:0;background:transparent;color:var(--color-ink)}.section-toggle :global(svg){flex:0 0 auto;color:var(--color-ink-muted)}.section-toggle strong{font-size:13px;line-height:1.2;font-weight:750}.section-toggle span{color:var(--color-ink-faint);font-weight:400}.section-toggle i{height:1px;flex:1;background:var(--color-border-subtle)}.category-empty{margin:0;padding:9px 12px;border:1px solid var(--color-border-subtle);color:var(--color-ink-faint);background:var(--color-surface)}
|
.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-path{width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-branch,.card-status,.card-status>span{display:flex;align-items:center;gap:6px}.card-branch{max-width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-status{flex-wrap:wrap;gap:8px 18px;margin-top:auto}.card-status :global(.filled){fill:currentColor}.changed,.behind{color:#eeb94e}.clean{color:#68c878}.ahead{color:var(--color-accent)}.muted{color:var(--color-ink-muted)}
|
.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)}
|
.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)}
|
.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)}
|
.workspace-repos input[type="checkbox"]{appearance:auto;-webkit-appearance:checkbox;width:16px;min-width:16px;height:16px;margin:0;padding:0;accent-color:var(--color-primary);cursor:pointer}
|
||||||
|
.workspace-repos input[type="checkbox"]:focus-visible{outline:2px solid var(--color-accent);outline-offset:3px}
|
||||||
|
.workspace-repos label{cursor:pointer}
|
||||||
|
.workspace-repos label.selected{background:color-mix(in srgb,var(--color-primary) 14%,var(--app-dialog-bg));box-shadow:inset 3px 0 var(--color-primary)}
|
||||||
|
.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:16px 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: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}}
|
@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>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import { AlertCircle, Check, ExternalLink, GitMerge, LoaderCircle, X } from "@lucide/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";
|
import type { ConflictChoice, ConflictFile, ConflictPart, GitFileStatus, PreparedResolution } from "../types";
|
||||||
|
|
||||||
type ConflictRegion = Extract<ConflictPart, { kind: "conflict" }>;
|
|
||||||
type ResolveSplitRow =
|
type ResolveSplitRow =
|
||||||
| { type: "marker"; conflictIndex: number }
|
| { type: "marker"; conflictIndex: number }
|
||||||
| {
|
| {
|
||||||
@@ -53,9 +52,33 @@
|
|||||||
let resolveContent = $state("");
|
let resolveContent = $state("");
|
||||||
let manualMode = $state(false);
|
let manualMode = $state(false);
|
||||||
let binarySide = $state<"ours" | "theirs" | null>(null);
|
let binarySide = $state<"ours" | "theirs" | null>(null);
|
||||||
let resolveBeforePane = $state<HTMLDivElement | null>(null);
|
let dialogElement: HTMLDivElement;
|
||||||
let resolveAfterPane = $state<HTMLDivElement | null>(null);
|
onMount(() => {
|
||||||
let isSyncingResolveScroll = false;
|
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(() => {
|
$effect(() => {
|
||||||
const c = conflict;
|
const c = conflict;
|
||||||
@@ -63,6 +86,8 @@
|
|||||||
const parts = conflictParts;
|
const parts = conflictParts;
|
||||||
|
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
|
activeConflict = 0;
|
||||||
|
bothMenu = null;
|
||||||
if (!c) {
|
if (!c) {
|
||||||
conflictChoices = [];
|
conflictChoices = [];
|
||||||
resolveContent = "";
|
resolveContent = "";
|
||||||
@@ -95,7 +120,6 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
let conflictRegionCount = $derived(conflictParts.filter((p) => p.kind === "conflict").length);
|
let conflictRegionCount = $derived(conflictParts.filter((p) => p.kind === "conflict").length);
|
||||||
let conflictRegions = $derived(conflictOnlyParts(conflictParts));
|
|
||||||
let resolveSplitRows = $derived(buildResolveSplitRows(conflictParts));
|
let resolveSplitRows = $derived(buildResolveSplitRows(conflictParts));
|
||||||
let unresolvedCount = $derived(manualMode ? 0 : conflictChoices.filter((c) => c == null).length);
|
let unresolvedCount = $derived(manualMode ? 0 : conflictChoices.filter((c) => c == null).length);
|
||||||
let resolvedContent = $derived(manualMode ? resolveContent : buildResolution(conflictParts, conflictChoices));
|
let resolvedContent = $derived(manualMode ? resolveContent : buildResolution(conflictParts, conflictChoices));
|
||||||
@@ -147,10 +171,6 @@
|
|||||||
return out.join("\n");
|
return out.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function conflictOnlyParts(parts: ConflictPart[]): ConflictRegion[] {
|
|
||||||
return parts.filter((part): part is ConflictRegion => part.kind === "conflict");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildResolveSplitRows(parts: ConflictPart[]): ResolveSplitRow[] {
|
function buildResolveSplitRows(parts: ConflictPart[]): ResolveSplitRow[] {
|
||||||
const rows: ResolveSplitRow[] = [];
|
const rows: ResolveSplitRow[] = [];
|
||||||
let leftNum = 0;
|
let leftNum = 0;
|
||||||
@@ -174,7 +194,6 @@
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
rows.push({ type: "marker", conflictIndex: part.index });
|
|
||||||
const count = Math.max(part.oursLines.length, part.theirsLines.length);
|
const count = Math.max(part.oursLines.length, part.theirsLines.length);
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const hasOurs = i < part.oursLines.length;
|
const hasOurs = i < part.oursLines.length;
|
||||||
@@ -192,36 +211,22 @@
|
|||||||
rightKind: hasTheirs ? "theirs" : "empty",
|
rightKind: hasTheirs ? "theirs" : "empty",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
rows.push({ type: "marker", conflictIndex: part.index });
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows;
|
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) {
|
function setConflictChoice(index: number, choice: ConflictChoice) {
|
||||||
|
activeConflict = index;
|
||||||
|
bothMenu = null;
|
||||||
const next = [...conflictChoices];
|
const next = [...conflictChoices];
|
||||||
next[index] = choice;
|
next[index] = choice;
|
||||||
conflictChoices = next;
|
conflictChoices = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAllConflicts(choice: ConflictChoice) {
|
function setAllConflicts(choice: ConflictChoice) {
|
||||||
|
bothMenu = null;
|
||||||
conflictChoices = conflictParts.filter((p) => p.kind === "conflict").map(() => choice);
|
conflictChoices = conflictParts.filter((p) => p.kind === "conflict").map(() => choice);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,266 +263,121 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<svelte:window onkeydown={(event) => { if (event.key === "Escape") { if (bothMenu !== null) bothMenu = null; else if (!isBusy) onClose(); } }} onclick={() => { bothMenu = null; }} />
|
||||||
class="dialog-backdrop"
|
|
||||||
role="presentation"
|
{#snippet bothChoices(index: number)}
|
||||||
>
|
<div class="both-control">
|
||||||
<div
|
<button type="button" aria-haspopup="menu" aria-expanded={bothMenu === index} disabled={isBusy} onclick={(event) => { event.stopPropagation(); bothMenu = bothMenu === index ? null : index; }}>
|
||||||
class="dialog"
|
{index === -1 ? t("Beide", "Both") : t("Beide übernehmen", "Accept both")}<ChevronDown size={14} />
|
||||||
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>
|
|
||||||
</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>
|
</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>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Konflikte schließen" : "Close conflicts"}>
|
</div>
|
||||||
<X size={18} aria-hidden="true" />
|
{/snippet}
|
||||||
</button>
|
|
||||||
|
<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 data-dialog-close class="close-button" type="button" onclick={onClose} disabled={isBusy} aria-label={t("Schließen", "Close")}><X size={22} /></button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if conflictedFiles.length === 0}
|
{#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}
|
{:else}
|
||||||
<div class="dialog-body">
|
<div class="workbench-body">
|
||||||
<aside class="dialog-files" aria-label="Conflicted files">
|
<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)}
|
{#each conflictedFiles as file (file.path)}
|
||||||
<button
|
{@const prepared = preparedResolutions[file.path] != null}
|
||||||
class="dialog-file-row"
|
<button class="file-item" class:selected={conflictTarget === file.path} type="button" onclick={() => onSelectFile(file.path)} disabled={isBusy} title={file.path}>
|
||||||
class:active={conflictTarget === 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>
|
||||||
class:prepared={preparedResolutions[file.path] != null}
|
{#if prepared}<Check class="ready" size={20} />{:else}<span class="open-dot"></span>{/if}
|
||||||
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>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="file-progress">{preparedCount} {t("von", "of")} {conflictedFiles.length} {t("vorbereitet", "prepared")}</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
<main class="conflict-main">
|
||||||
<div class="resolve-editor" aria-label="Conflict editor">
|
<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}
|
{#if !conflict}
|
||||||
<div class="blank-state">Select a file to resolve.</div>
|
<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}
|
{:else if conflict.binary}
|
||||||
<div class="resolve-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="resolve-binary-note">
|
<div class="binary-options">{#each ["ours", "theirs"] as side}
|
||||||
<AlertCircle size={16} aria-hidden="true" />
|
{@const size = side === "ours" ? conflict.ours_size : conflict.theirs_size}
|
||||||
<span>Binary file — pick which version to keep, then mark it resolved.</span>
|
<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>
|
||||||
</div>
|
{/each}</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"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="resolve-toolbar">
|
<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}
|
{#if manualMode}
|
||||||
<button type="button" onclick={() => { manualMode = false; }} disabled={isBusy}>Back to guided</button>
|
<textarea class="manual-editor" aria-label={t("Konflikt manuell bearbeiten", "Edit conflict manually")} bind:value={resolveContent} spellcheck="false" disabled={isBusy}></textarea>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="resolve-toolbar-label">Apply to all:</span>
|
<div class="comparison-scroll" bind:this={comparison}>
|
||||||
<button type="button" onclick={() => setAllConflicts("ours")} disabled={isBusy}>Current</button>
|
<div class="comparison-grid">
|
||||||
<button type="button" onclick={() => setAllConflicts("theirs")} disabled={isBusy}>Incoming</button>
|
<div class="side-heading ours-heading">{t("Aktuell (ours)", "Current (ours)")}</div><div class="side-heading theirs-heading">{t("Eingehend (theirs)", "Incoming (theirs)")}</div>
|
||||||
<button type="button" onclick={() => setAllConflicts("both-ot")} disabled={isBusy}>Both</button>
|
{#each resolveSplitRows as row, i (i)}
|
||||||
<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>
|
|
||||||
|
|
||||||
{#if manualMode}
|
|
||||||
<textarea
|
|
||||||
class="resolve-textarea"
|
|
||||||
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>
|
|
||||||
{/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"}
|
{#if row.type === "marker"}
|
||||||
<div class="resolve-split-marker" class:unresolved={conflictChoices[row.conflictIndex] == null}>Conflict {row.conflictIndex + 1}</div>
|
{@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>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="resolve-num" class:ours={row.leftKind === "ours"} class:empty={row.leftKind === "empty"}>{row.leftNum ?? ""}</div>
|
<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
|
<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>
|
||||||
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}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{/if}
|
||||||
{/each}
|
<section class="result-preview" aria-label={t("Ergebnisvorschau", "Result preview")}>
|
||||||
</div>
|
<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>
|
<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>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{#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>
|
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{#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}
|
||||||
<div class="resolve-conflict" class:unresolved={conflictChoices[part.index] == null}>
|
</main>
|
||||||
<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>
|
<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>
|
||||||
<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}
|
|
||||||
</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>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
import CreateReviewDialog from "./CreateReviewDialog.svelte";
|
||||||
|
import CommentEditor from "./CommentEditor.svelte";
|
||||||
import SelectMenu from "./SelectMenu.svelte";
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
import { cubicOut } from "svelte/easing";
|
import { cubicOut } from "svelte/easing";
|
||||||
import { fly } from "svelte/transition";
|
import { fly } from "svelte/transition";
|
||||||
import {
|
import {
|
||||||
AlertTriangle, AtSign, Bold, Check, ChevronDown, ChevronRight, Code2,
|
AlertTriangle, Check, ChevronDown, ChevronRight,
|
||||||
CircleCheck, Clock3, CircleDotDashed, ExternalLink, GitBranch, GitMerge, GitPullRequest, Inbox, Italic,
|
CircleCheck, Clock3, CircleDotDashed, ExternalLink, GitBranch, GitMerge, GitPullRequest, Inbox,
|
||||||
Link, List, LoaderCircle, PanelRightOpen, RefreshCw, RotateCcw, Search, Settings2, X, XCircle,
|
LoaderCircle, PanelRightOpen, RefreshCw, RotateCcw, Search, Settings2, X, XCircle,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import { addIntegrationReviewComment, getIntegrationReviewDetails, listIntegrationReviewRequests, openInBrowser, runIntegrationReviewAction } from "../git";
|
import { addIntegrationReviewComment, getIntegrationReviewDetails, listIntegrationReviewRequests, openInBrowser, runIntegrationReviewAction } from "../git";
|
||||||
import { configuredIntegrationSources, integrationCredentialKey, providerLabel } from "../integrations";
|
import { configuredIntegrationSources, integrationCredentialKey, providerLabel } from "../integrations";
|
||||||
@@ -16,6 +18,7 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
language: AppLanguage;
|
language: AppLanguage;
|
||||||
|
localRepositoryPath?: string;
|
||||||
integrations: GitIntegrationSettings;
|
integrations: GitIntegrationSettings;
|
||||||
initialQuery?: string;
|
initialQuery?: string;
|
||||||
initialSourceId?: string;
|
initialSourceId?: string;
|
||||||
@@ -31,7 +34,8 @@
|
|||||||
onPushLocalResolution?: () => void | Promise<void>;
|
onPushLocalResolution?: () => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { language = "en", integrations, initialQuery = "", initialSourceId = "", localResolutionRequestId = "", localResolutionPhase = "idle", localResolutionMessage = "", loadCredential, onOpenSettings = () => {}, onStartLocalResolution = () => {}, onOpenLocalResolver = () => {}, onContinueLocalResolution = () => {}, onAbortLocalResolution = () => {}, onPushLocalResolution = () => {} }: Props = $props();
|
let { 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 requests = $state<IntegrationReviewRequest[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let errors = $state<Array<{ source: string; message: string }>>([]);
|
let errors = $state<Array<{ source: string; message: string }>>([]);
|
||||||
@@ -46,7 +50,6 @@
|
|||||||
let actionNotice = $state("");
|
let actionNotice = $state("");
|
||||||
let detailLoadingId = $state("");
|
let detailLoadingId = $state("");
|
||||||
let commentDraft = $state("");
|
let commentDraft = $state("");
|
||||||
let commentTextarea = $state<HTMLTextAreaElement>();
|
|
||||||
let commentPosting = $state(false);
|
let commentPosting = $state(false);
|
||||||
let collapsedRepositories = $state(new Set<string>());
|
let collapsedRepositories = $state(new Set<string>());
|
||||||
let loadGeneration = 0;
|
let loadGeneration = 0;
|
||||||
@@ -235,20 +238,6 @@
|
|||||||
collapsedRepositories = next;
|
collapsedRepositories = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatComment(prefix: string, suffix = prefix) {
|
|
||||||
const textarea = commentTextarea;
|
|
||||||
if (!textarea) return;
|
|
||||||
const start = textarea.selectionStart;
|
|
||||||
const end = textarea.selectionEnd;
|
|
||||||
const selection = commentDraft.slice(start, end);
|
|
||||||
commentDraft = `${commentDraft.slice(0, start)}${prefix}${selection}${suffix}${commentDraft.slice(end)}`;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
textarea.focus();
|
|
||||||
const cursorStart = start + prefix.length;
|
|
||||||
textarea.setSelectionRange(cursorStart, cursorStart + selection.length);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectRequest(request: IntegrationReviewRequest, showDetail = false) {
|
function selectRequest(request: IntegrationReviewRequest, showDetail = false) {
|
||||||
if (selectedId !== request.id) commentDraft = "";
|
if (selectedId !== request.id) commentDraft = "";
|
||||||
selectedId = request.id;
|
selectedId = request.id;
|
||||||
@@ -398,9 +387,24 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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">
|
<section class:has-inspector={detailOpen && !!selected} class="review-center" aria-label="Review Center">
|
||||||
<header class="review-header">
|
<header class="review-header page-header">
|
||||||
<div class="review-heading"><GitPullRequest size={17} /><h1>Review Center</h1></div>
|
<div class="review-heading page-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>
|
</header>
|
||||||
|
|
||||||
{#if sources.length === 0}
|
{#if sources.length === 0}
|
||||||
@@ -455,7 +459,7 @@
|
|||||||
<span>{stateLabel(request.state)}<small title={formatDate(request.updatedAt || request.createdAt)}>{formatRelativeDate(request.updatedAt || request.createdAt)}</small></span>
|
<span>{stateLabel(request.state)}<small title={formatDate(request.updatedAt || request.createdAt)}>{formatRelativeDate(request.updatedAt || request.createdAt)}</small></span>
|
||||||
</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-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="request-author" title={request.author || (de ? "Unbekannt" : "Unknown")} aria-label={request.author || (de ? "Unbekannt" : "Unknown")}><i aria-hidden="true">{initials(request.author)}</i></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="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="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="row-actions">
|
||||||
@@ -490,7 +494,7 @@
|
|||||||
<aside bind:this={detailPanel} class="detail-panel" transition:fly={{ x: 140, duration: 210, easing: cubicOut }}>
|
<aside bind:this={detailPanel} class="detail-panel" transition:fly={{ x: 140, duration: 210, easing: cubicOut }}>
|
||||||
<header class="detail-header">
|
<header class="detail-header">
|
||||||
<div class="detail-provider"><GitPullRequest size={17} /><strong>{providerLabel(selected.provider)} {requestTypeLabel(selected.provider)}</strong></div>
|
<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>
|
<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 data-dialog-close class="icon-button" type="button" aria-label={de ? "Detailansicht schließen" : "Close details"} onclick={() => { detailOpen = false; }}><X size={17} /></button></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="detail-content">
|
<div class="detail-content">
|
||||||
<main class="detail-main">
|
<main class="detail-main">
|
||||||
@@ -511,7 +515,7 @@
|
|||||||
<section class="comments-section">
|
<section class="comments-section">
|
||||||
<header><h3>{de ? "Kommentare" : "Comments"}</h3><span>{selected.comments?.length ?? 0}</span></header>
|
<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}
|
{#if detailLoadingId === selected.id && !(selected.comments?.length)}<div class="comments-loading"><LoaderCircle class="spin" size={13} />{de ? "Kommentare werden geladen …" : "Loading comments …"}</div>{:else if selected.comments?.length}<div class="comment-list">{#each selected.comments as comment (comment.id)}<article><div class="comment-card"><header><span class="avatar">{initials(comment.author)}</span><strong>{comment.author || (de ? "Unbekannt" : "Unknown")}</strong><time>{formatRelativeDate(comment.createdAt)}</time>{#if comment.author === selected.author}<span class="owner-badge" title={de ? "Autor dieses Requests" : "Author of this request"}>{de ? "Autor" : "Author"}</span>{/if}</header><p>{comment.body}</p></div></article>{/each}</div>{:else}<p class="no-comments">{de ? "Noch keine Kommentare." : "No comments yet."}</p>{/if}
|
||||||
<form class="comment-composer" onsubmit={(event) => { event.preventDefault(); void submitComment(); }}><textarea bind:this={commentTextarea} bind:value={commentDraft} maxlength="100000" rows="4" placeholder={de ? "Kommentar hinzufügen …" : "Add a comment …"}></textarea><footer><div class="format-actions"><button type="button" title={de ? "Fett" : "Bold"} onclick={() => formatComment("**")}><Bold size={14} /></button><button type="button" title={de ? "Kursiv" : "Italic"} onclick={() => formatComment("_")}><Italic size={14} /></button><button type="button" title="Code" onclick={() => formatComment("`", "`")}><Code2 size={14} /></button><button type="button" title="Link" onclick={() => formatComment("[", "](url)")}><Link size={14} /></button><button type="button" title={de ? "Liste" : "List"} onclick={() => formatComment("- ", "")}><List size={14} /></button><button type="button" title={de ? "Erwähnen" : "Mention"} onclick={() => formatComment("@", "")}><AtSign size={14} /></button></div><button class="submit-comment" type="submit" disabled={!commentDraft.trim() || commentPosting}>{#if commentPosting}<LoaderCircle class="spin" size={13} />{/if}{de ? "Kommentar senden" : "Add comment"}</button></footer></form>
|
<div class="review-comment-editor"><CommentEditor bind:value={commentDraft} language={de ? "de" : "en"} busy={commentPosting} onSend={submitComment} /></div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<aside class="detail-sidebar">
|
<aside class="detail-sidebar">
|
||||||
@@ -528,11 +532,12 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<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}
|
.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}
|
.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}
|
.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}
|
.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)}
|
.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}.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}
|
.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)}
|
.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: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}}
|
||||||
@@ -542,12 +547,12 @@
|
|||||||
@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}}
|
@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}
|
.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))}
|
.provider-action{overflow:visible;border-radius:2px;box-shadow:0 1px 0 #0005}.provider-button{border-radius:2px 0 0 2px;font-weight:600}.action-toggle{border-radius:0 2px 2px 0;background:color-mix(in srgb,var(--color-accent) 14%,var(--color-surface))}.panel-button{margin-left:2px;border-radius:2px}.review-layout.inspector-open{grid-template-columns:minmax(580px,1fr) minmax(420px,44%)}.detail-header{background:linear-gradient(180deg,color-mix(in srgb,var(--color-surface-raised) 72%,var(--color-surface)),var(--color-surface))}
|
||||||
.comments-section{padding:15px 0;border-bottom:1px solid var(--color-border-subtle)}.comments-section>header{display:flex;align-items:center;gap:7px;margin-bottom:10px}.comments-section h3{margin:0;font-size:11px}.comments-section>header>span{display:grid;min-width:18px;height:18px;place-items:center;border-radius:9px;color:var(--color-ink-faint);background:var(--color-surface-raised);font-size:9px}.comments-loading,.no-comments{margin:8px 0;color:var(--color-ink-faint);font-size:10px}.comments-loading{display:flex;align-items:center;gap:6px}.comment-list{display:grid;gap:10px;margin-bottom:12px}.comment-list article{display:flex;align-items:flex-start;gap:9px}.comment-list article>div{min-width:0;flex:1;padding:9px 10px;border:1px solid var(--color-border-subtle);border-radius:2px;background:var(--app-bg)}.comment-list article header{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}.comment-list article strong{font-size:10.5px}.comment-list article time{color:var(--color-ink-faint);font-size:9px}.comment-list article p{margin:0;white-space:pre-wrap;color:var(--color-ink-muted);font-size:10.5px;line-height:1.5}.comment-composer{display:flex;align-items:flex-start;gap:9px;margin-top:10px}.comment-composer textarea{box-sizing:border-box;width:100%;min-height:82px;resize:vertical;padding:9px 10px;border:1px solid var(--color-border-input);outline:0;color:var(--color-ink);background:var(--app-input-bg);font:inherit;line-height:1.45}.comment-composer textarea:focus{border-color:var(--color-accent)}.comment-composer button{display:inline-flex;height:29px;align-items:center;gap:6px;padding:0 10px;border:1px solid #4fa565;border-radius:2px;color:#63c783;background:color-mix(in srgb,#4fa565 12%,var(--color-surface))}.comment-composer button:hover:not(:disabled){color:#fff;background:#397849}
|
.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: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}}
|
@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}
|
.comment-list{max-height:230px;overflow-y:auto;overscroll-behavior:contain;padding-right:5px;scrollbar-gutter:stable}.comment-list::-webkit-scrollbar{width:7px}.comment-list::-webkit-scrollbar-thumb{border:2px solid transparent;border-radius:4px;background:var(--color-border-input);background-clip:padding-box}.comment-list::-webkit-scrollbar-track{background:transparent}
|
||||||
.detail-panel{display:flex;flex-direction:column;overflow:hidden}.detail-header{flex:0 0 auto}.detail-content{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden}.detail-summary,.merge-summary,.description,.detail-actions{flex:0 0 auto}.description p{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:3;line-clamp:3}.comments-section{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden}.comments-section>header,.comment-composer{flex:0 0 auto}.comment-list{min-height:0;max-height:none;flex:1;overflow-y:auto}.comment-composer textarea{min-height:68px;max-height:92px}.detail-actions{margin-bottom:12px}
|
.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-main .comment-composer{flex:0 0 auto}.detail-sidebar{display:flex;min-height:0;flex-direction:column;gap:0;overflow:hidden;padding:18px 0;border-left:1px solid var(--color-border-subtle);padding-left:22px;background:var(--color-surface)}.detail-sidebar .detail-actions{display:grid;grid-template-columns:1fr;gap:6px;margin:0 0 8px}.detail-sidebar section{padding:17px 0;border-bottom:1px solid var(--color-border-subtle)}.detail-sidebar section header{display:flex;align-items:center;justify-content:space-between;gap:12px}.detail-sidebar section h3{margin:0;color:var(--color-ink-muted);font-size:11px;font-weight:560}.people{display:grid;grid-template-columns:24px minmax(0,1fr);align-items:center;gap:8px;margin-top:12px}.people.compact{grid-template-columns:24px}
|
.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: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}}
|
@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}
|
.comment-list{align-content:start;grid-auto-rows:max-content}
|
||||||
@@ -567,20 +572,19 @@
|
|||||||
.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}
|
.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-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>.merge-summary{min-height:43px;margin:11px 0 0;padding:0 10px;border:1px solid color-mix(in srgb,#63c783 48%,var(--color-border));background:color-mix(in srgb,#63c783 6%,var(--color-surface))}.detail-main>.merge-summary.conflict{border-color:color-mix(in srgb,#d6a64f 55%,var(--color-border));color:#d6a64f;background:color-mix(in srgb,#d6a64f 7%,var(--color-surface))}.merge-spacer{flex:1}.detail-change-stats{display:flex;align-items:center;gap:7px;white-space:nowrap;font-size:9px}.detail-change-stats b{color:#63c783}.detail-change-stats em{color:#e0737b;font-style:normal}.detail-change-stats span{color:var(--color-ink-faint)}
|
||||||
.detail-main>.description{padding:12px 0}.detail-main>.comments-section{padding:12px 0 14px}.comment-list{gap:8px}.comment-list article{gap:8px}.comment-list article>div{padding:8px 9px;border-radius:0;background:color-mix(in srgb,var(--color-surface) 48%,var(--app-bg))}.comment-composer{margin-top:9px;padding-top:9px;border-top:1px solid var(--color-border-subtle)}.comment-composer textarea{min-height:72px;max-height:92px}.comment-composer button{color:var(--color-accent);border-color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 6%,var(--color-surface))}
|
.detail-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))}
|
.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))}
|
.local-resolution{margin:8px 0 0;border-color:color-mix(in srgb,#d6a64f 55%,var(--color-border));background:color-mix(in srgb,#d6a64f 7%,var(--color-surface))}.local-resolution>div>:global(svg){color:#d6a64f}.local-resolution button{color:#e0aa55;border-color:#b88432;background:color-mix(in srgb,#b88432 10%,var(--color-surface))}
|
||||||
@media(max-width:1280px){.review-toolbar{grid-template-columns:auto minmax(180px,1fr) 155px auto}.state-tabs button{min-width:52px;padding:0 6px}.table-head,.request-row{grid-template-columns:80px minmax(250px,1.5fr) 110px minmax(190px,1fr) 180px}.table-head>span:nth-child(4),.collaborators{display:none}.request-groups{min-width:900px}.detail-panel{width:58%;min-width:680px}}
|
@media(max-width: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: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}}
|
@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. */
|
/* 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-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}
|
.review-toolbar{min-height:55px;grid-template-columns:350px minmax(220px,1fr) 160px 34px;gap:10px;padding:0 16px;border-bottom-color:var(--color-border);background:color-mix(in srgb,var(--app-bg) 88%,var(--color-surface))}.state-tabs{gap:8px}.state-tabs button{min-width:72px;justify-content:flex-start;padding:0 8px;color:var(--color-ink-muted);font-size:11px}.state-tabs button.active{color:var(--color-ink)}.state-tabs button.active:after{right:0;left:0;height:2px}.state-tabs span{min-width:17px;height:17px;margin-left:auto;border:0;background:var(--color-surface-raised)}.review-search,.group-actions .icon-button{height:34px;background:color-mix(in srgb,var(--app-input-bg) 92%,#11171c)}.review-search{padding:0 11px}.group-actions .icon-button{width:34px}
|
||||||
.table-head,.request-row{grid-template-columns:90px minmax(255px,1.65fr) 105px 120px minmax(180px,1fr) 150px}.table-head{min-height:37px;padding:0 16px;background:color-mix(in srgb,var(--color-surface-raised) 88%,var(--app-bg));font-size:8.5px}.request-groups{min-width:900px}.group-header{height:45px;padding:0 17px;background:color-mix(in srgb,var(--app-bg) 72%,var(--color-surface))}.group-header strong{font-size:11.5px}.request-row{min-height:78px;padding:0 16px}.request-row.selected{background:linear-gradient(90deg,color-mix(in srgb,var(--color-accent) 7%,var(--color-surface-raised)),color-mix(in srgb,var(--color-surface-raised) 72%,var(--app-bg)))}.request-title{gap:8px}.request-title strong{font-size:12px}.request-status{font-size:10.5px}.request-author i,.avatar,.collaborators i{width:28px;height:28px;background:color-mix(in srgb,var(--color-accent) 58%,#26333a)}.provider-button,.action-toggle,.panel-button{height:34px}.provider-button{min-width:102px}.action-toggle{width:34px}.panel-button{width:34px}.action-menu{top:38px;width:154px}.action-menu button{height:36px}
|
.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-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-main>.merge-summary{min-height:55px;margin:0 0 4px;padding:0 12px;border-color:color-mix(in srgb,#63c783 65%,var(--color-border));background:color-mix(in srgb,#63c783 5%,var(--app-bg))}.detail-main>.description{padding:14px 0 18px}.description h3,.comments-section h3{font-size:11.5px}.description p{font-size:10.5px}.detail-main>.comments-section{padding:13px 0 0}.comments-section>header{min-height:28px;margin:0 0 8px}.comments-section>header>span{border-radius:1px}.comment-list{gap:10px;padding-right:0}.comment-list article{display:block}.comment-card{padding:0!important;border:1px solid var(--color-border)!important;background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))!important}.comment-card header{min-height:42px;margin:0!important;padding:0 10px;border-bottom:0}.comment-card header .avatar{width:25px;height:25px;margin-right:2px}.comment-card header strong{font-size:10.5px}.comment-card header time{margin-left:auto}.owner-badge{padding:3px 6px;border:1px solid var(--color-border);color:var(--color-ink-faint);font-size:8.5px}.comment-card p{padding:0 44px 13px!important;color:var(--color-ink)!important}
|
||||||
.comment-composer{display:grid;gap:0;margin:0;padding:14px 0 18px;border-top:1px solid var(--color-border-subtle)}.comment-composer textarea{min-height:86px;max-height:120px;resize:none;padding:11px 12px;border-bottom:0;background:color-mix(in srgb,var(--app-input-bg) 94%,#11171c)}.comment-composer footer{display:flex;min-height:39px;align-items:center;justify-content:space-between;border:1px solid var(--color-border-input);border-top:0;background:color-mix(in srgb,var(--app-bg) 85%,var(--color-surface))}.format-actions{display:flex;align-items:center;padding-left:5px}.comment-composer .format-actions button{display:grid;width:30px;height:30px;place-items:center;padding:0;border:0;color:var(--color-ink-muted);background:transparent}.comment-composer .format-actions button:hover{color:var(--color-ink);background:var(--color-surface-hover)}.comment-composer .submit-comment{height:29px;margin-right:6px;padding:0 11px;border:1px solid var(--color-accent);border-radius:0;color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 5%,var(--app-bg))}.comment-composer .submit-comment:hover:not(:disabled){color:#fff;background:color-mix(in srgb,var(--color-accent) 72%,var(--app-bg))}
|
|
||||||
.detail-sidebar{padding:28px 17px 16px;border-left-color:var(--color-border);background:color-mix(in srgb,var(--color-surface) 46%,var(--app-bg))}.detail-sidebar .detail-actions{gap:10px;margin:0 0 12px}.detail-action{height:39px;font-size:11px}.detail-sidebar section{padding:17px 0}.detail-sidebar section h3{font-size:11.5px}.people{margin-top:12px}.detail-meta{gap:11px}
|
.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: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: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}}
|
||||||
@@ -591,12 +595,12 @@
|
|||||||
.review-center.has-inspector{--inspector-width:44%}
|
.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-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-heading h1,.detail-provider strong{font-size:14px}.detail-provider>:global(svg){display:none}.detail-header{gap:4px}.detail-header-actions{display:contents}.detail-header-actions button:first-child{margin-right:auto}.detail-provider{flex:none}
|
||||||
.review-toolbar{height:60px;min-height:60px}.review-search input{font-size:13px}.review-center .state-tabs button{font-size:13px}.state-tabs span{font-size:11px}.table-pane{padding:8px 7px 0;box-sizing:border-box}.table-head{padding:0 16px;height:37px}.table-head,.request-row{grid-template-columns:100px minmax(210px,1fr) 106px 120px 165px 145px}.table-head>span{font-size:10px}.request-groups{min-width:900px}.group-header{height:44px;padding:0 12px}.group-header strong{font-size:13px}.request-row{padding:0 10px;min-height:78px}.request-row.selected{outline-color:color-mix(in srgb,var(--color-accent) 50%,var(--color-border));background:color-mix(in srgb,var(--app-bg) 93%,var(--color-accent));box-shadow:none}.request-row.selected:before{width:2px;box-shadow:none}.request-status{font-size:13px;font-weight:400}.request-status>span{font-weight:400}.request-status small{font-size:11px}.request-title strong{font-size:13px;font-weight:650}.request-title code{font-size:12px}.change-stats{font-size:12px}.request-author{font-size:12px}.repo-branch>strong{font-size:12px;font-weight:500}.branch-route code{padding:0;background:none;font-size:11px}.branch-route{gap:6px}
|
.review-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:68px;padding:0 8px;font-size:13px;font-weight:500}.provider-button.merge-primary>:global(svg){display:none}.provider-action .action-toggle{width:32px;background:transparent}.row-actions{gap:7px}.panel-button{margin:0;width:32px}.action-menu{width:154px;padding:4px;background:color-mix(in srgb,var(--app-bg) 90%,var(--color-surface-raised));border:1px solid var(--color-border);box-shadow:0 6px 12px #0005}.action-menu button{font-size:13px;gap:10px;height:37px;padding:0 10px}.action-menu button.danger{margin:0;border-top:0;color:var(--color-ink)}.action-menu button.danger>:global(svg){color:#e7767e}.action-menu .menu-merge>:global(svg),.action-menu .menu-approve>:global(svg),.action-menu .menu-provider>:global(svg){color:var(--color-ink-muted)}
|
.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}.comment-composer{margin-top:auto;padding:16px 0 26px}.comment-composer textarea{font-size:13px;min-height:86px;height:86px;max-height:86px;border-bottom:1px solid var(--color-border-input);background:transparent}.comment-composer footer{margin-top:8px;border:0;background:transparent;min-height:39px}.comment-composer .submit-comment{height:37px;margin:0;font-size:13px}.format-actions{gap:4px;padding:0}.comment-composer .format-actions button{width:27px}.detail-sidebar .detail-actions{gap:15px;margin-bottom:27px}.review-center .detail-action{font-size:13px;position:relative}.detail-action>:global(svg){position:absolute;left:12px;width:18px;height:18px}.detail-action.primary-action{background:color-mix(in srgb,#63c783 4%,transparent)}.detail-sidebar section{padding:16px 0;border-top:1px solid var(--review-divider);border-bottom:0}.detail-sidebar section h3{font-size:14px;font-weight:600}.people.compact{display:flex;gap:10px}.people strong,.detail-meta strong,.detail-meta span{font-size:12px}.detail-meta strong,.detail-meta span{display:flex;align-items:center;gap:10px}.detail-meta :global(svg){flex:none;color:var(--color-ink-muted)}
|
.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}.format-actions{gap:0}.comment-composer .format-actions button{width:23px}}
|
@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:900px){.review-center.has-inspector{--inspector-width:100%}.detail-content{display:grid;grid-template-columns:minmax(0,1fr) 200px}.detail-sidebar{display:flex}.detail-main{height:auto}.detail-panel{inset:0 0 0 auto;min-width:0}.group-actions .icon-button{display:flex!important}}
|
||||||
@media(max-width:520px){.detail-content{display:flex;flex-direction:column}.detail-main{flex:1;min-height:0}.detail-sidebar{flex:none;padding:8px 14px;border-top:1px solid var(--review-divider);border-left:0}.detail-sidebar section{display:none}.detail-sidebar .detail-actions{grid-template-columns:1fr 1fr;gap:6px;margin:0}.detail-action{height:32px}.detail-header{padding:0 14px}.detail-main>.description{padding:10px 0}.comment-composer{padding-bottom:8px}.detail-title h2{white-space:normal}}
|
@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. */
|
/* The inspector overlays an unchanged background; only viewport size reflows it. */
|
||||||
@@ -628,4 +632,5 @@
|
|||||||
.review-center .action-menu button.danger>:global(svg){color:#e7767e}
|
.review-center .action-menu button.danger>:global(svg){color:#e7767e}
|
||||||
.review-center .action-menu button.menu-conflict{color:#e2ac58}
|
.review-center .action-menu button.menu-conflict{color:#e2ac58}
|
||||||
.review-center .action-menu button:hover:not(:disabled){background:var(--color-surface-hover)}
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
import { Check, ChevronDown } from "@lucide/svelte";
|
import { Check, ChevronDown, Search } from "@lucide/svelte";
|
||||||
|
|
||||||
export interface SelectMenuOption {
|
export interface SelectMenuOption {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -17,6 +17,9 @@
|
|||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
showSelectedGroup?: boolean;
|
showSelectedGroup?: boolean;
|
||||||
|
searchable?: boolean;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
emptyText?: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,9 +31,16 @@
|
|||||||
ariaLabel = "",
|
ariaLabel = "",
|
||||||
class: className = "",
|
class: className = "",
|
||||||
showSelectedGroup = false,
|
showSelectedGroup = false,
|
||||||
|
searchable = false,
|
||||||
|
searchPlaceholder = "Search…",
|
||||||
|
emptyText = "No results",
|
||||||
onChange,
|
onChange,
|
||||||
}: Props = $props();
|
}: 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 root = $state<HTMLDivElement>();
|
||||||
let trigger = $state<HTMLButtonElement>();
|
let trigger = $state<HTMLButtonElement>();
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
@@ -39,10 +49,10 @@
|
|||||||
const menuId = `select-menu-${Math.random().toString(36).slice(2)}`;
|
const menuId = `select-menu-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
let selectedOption = $derived(options.find((option) => option.value === value));
|
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 {
|
function groupCount(group: string): number {
|
||||||
return options.filter((option) => option.group === group).length;
|
return visibleOptions.filter((option) => option.group === group).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function positionMenu() {
|
function positionMenu() {
|
||||||
@@ -50,8 +60,8 @@
|
|||||||
const rect = trigger.getBoundingClientRect();
|
const rect = trigger.getBoundingClientRect();
|
||||||
const viewportGap = 8;
|
const viewportGap = 8;
|
||||||
const menuGap = 5;
|
const menuGap = 5;
|
||||||
const groupHeaderCount = new Set(options.map((option) => option.group).filter(Boolean)).size;
|
const groupHeaderCount = new Set(visibleOptions.map((option) => option.group).filter(Boolean)).size;
|
||||||
const desiredHeight = Math.min(300, options.length * 32 + groupHeaderCount * 36 + 12);
|
const desiredHeight = Math.min(300, visibleOptions.length * 32 + (searchable ? 46 : 0) + groupHeaderCount * 36 + 12);
|
||||||
const spaceBelow = window.innerHeight - rect.bottom - viewportGap;
|
const spaceBelow = window.innerHeight - rect.bottom - viewportGap;
|
||||||
const spaceAbove = rect.top - viewportGap;
|
const spaceAbove = rect.top - viewportGap;
|
||||||
const openAbove = spaceBelow < Math.min(desiredHeight, 180) && spaceAbove > spaceBelow;
|
const openAbove = spaceBelow < Math.min(desiredHeight, 180) && spaceAbove > spaceBelow;
|
||||||
@@ -63,12 +73,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function show() {
|
async function show() {
|
||||||
if (disabled || enabledIndices.length === 0) return;
|
if (disabled || !options.some(option => !option.disabled)) return;
|
||||||
const selectedIndex = options.findIndex((option) => option.value === value && !option.disabled);
|
search = "";
|
||||||
|
const selectedIndex = visibleOptions.findIndex((option) => option.value === value && !option.disabled);
|
||||||
activeIndex = selectedIndex >= 0 ? selectedIndex : enabledIndices[0];
|
activeIndex = selectedIndex >= 0 ? selectedIndex : enabledIndices[0];
|
||||||
open = true;
|
open = true;
|
||||||
await tick();
|
await tick();
|
||||||
positionMenu();
|
positionMenu();
|
||||||
|
if (searchable) searchInput?.focus();
|
||||||
document.getElementById(`${menuId}-option-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
|
document.getElementById(`${menuId}-option-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +89,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function choose(index: number) {
|
function choose(index: number) {
|
||||||
const option = options[index];
|
const option = visibleOptions[index];
|
||||||
if (!option || option.disabled) return;
|
if (!option || option.disabled) return;
|
||||||
onChange(option.value);
|
onChange(option.value);
|
||||||
close();
|
close();
|
||||||
@@ -102,6 +114,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
const editingSearch = event.target === searchInput;
|
||||||
|
if (editingSearch && [" ", "Home", "End"].includes(event.key)) return;
|
||||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
moveActive(event.key === "ArrowDown" ? 1 : -1);
|
moveActive(event.key === "ArrowDown" ? 1 : -1);
|
||||||
@@ -148,9 +162,14 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<div id={menuId} class="select-menu-popup" style={menuStyle} role="listbox" aria-label={ariaLabel || undefined}>
|
<div class="select-menu-popup" class:searchable style={menuStyle}>
|
||||||
{#each options as option, index (`${option.value}:${index}`)}
|
{#if searchable}
|
||||||
{#if option.group && (index === 0 || options[index - 1]?.group !== option.group)}
|
<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">
|
<div class="select-menu-group" role="presentation">
|
||||||
<span>{option.group}</span>
|
<span>{option.group}</span>
|
||||||
<small>{groupCount(option.group)}</small>
|
<small>{groupCount(option.group)}</small>
|
||||||
@@ -173,5 +192,16 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{#if searchable && !visibleOptions.length}<div class="select-empty" role="status">{emptyText}</div>{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -675,3 +675,56 @@ export function resolveConflictSide(
|
|||||||
): Promise<GitStatus> {
|
): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("resolve_conflict_side", { path, file, side });
|
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});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAzureIssueStates(baseUrl: string, username: string, token: string, repository: string, number: number): Promise<string[]> {
|
||||||
|
return invoke("list_azure_issue_states", { baseUrl, username, token, repository, number });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAzureIssueState(baseUrl: string, username: string, token: string, repository: string, number: number, state: string): Promise<string> {
|
||||||
|
return invoke("set_azure_issue_state", { baseUrl, username, token, repository, number, state });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAzureIssueProjects(baseUrl: string, username: string, token: string): Promise<string[]> {
|
||||||
|
return invoke("list_azure_issue_projects", { baseUrl, username, token });
|
||||||
|
}
|
||||||
|
export function listAzureIssueTypes(baseUrl: string, username: string, token: string, project: string): Promise<string[]> {
|
||||||
|
return invoke("list_azure_issue_types", { baseUrl, username, token, project });
|
||||||
|
}
|
||||||
|
export function createIntegrationIssue(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, title: string, description: string, workItemType: string): Promise<import("./types").IntegrationIssue> {
|
||||||
|
return invoke("create_integration_issue", { provider, baseUrl, username, token, repository, title, description, workItemType });
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,258 @@
|
|||||||
|
/* 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:0;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:0;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,.issue-center .board-select button,.issue-center .repository-select button {border-radius:0;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:0;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:0;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: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:0;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:0;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:12px 24px 10px;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:-.25px}
|
||||||
|
.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:10px;flex-shrink:0}
|
||||||
|
.issue-center .board-tools .workspace-search {width:360px;max-width:100%}
|
||||||
|
.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 {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:1px solid var(--issue-line);border-radius:0;background:transparent;overflow:hidden}
|
||||||
|
.issue-center .board-column>header {display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:34px;padding:8px 14px;flex-shrink:0;border-bottom:1px solid var(--issue-line);background:color-mix(in srgb,var(--color-surface) 35%,transparent)}
|
||||||
|
.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:13px;height:13px;flex:0 0 13px;border:2px solid var(--status-color);border-radius:50%;background:transparent}
|
||||||
|
.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 {display:flex;flex-direction:column;gap:8px;padding:10px;min-height:0;overflow:auto;flex:1}
|
||||||
|
.issue-center .board-card {display:flex;flex-shrink:0;flex-direction:column;align-items:flex-start;gap:5px;width:100%;text-align:left;padding:9px 11px;margin:0;border:1px solid var(--issue-line);border-radius:0;box-shadow:0 1px 2px rgb(0 0 0 / 12%);background:var(--color-surface);color:var(--color-ink);overflow-wrap:anywhere}
|
||||||
|
.issue-center .board-card:hover {background:var(--color-surface-hover);border-color:color-mix(in srgb,var(--color-ink-dim) 55%,var(--issue-line))}
|
||||||
|
.issue-center .board-card.chosen {background:color-mix(in srgb,var(--color-accent) 9%,var(--color-surface));border-color:var(--color-accent);box-shadow:inset 2px 0 var(--color-accent)}
|
||||||
|
.issue-center .board-card strong {font-size:13px;line-height:1.45;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 .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 {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}
|
||||||
|
}
|
||||||
|
@media(prefers-reduced-motion:reduce) {.issue-center button {transition:none}}
|
||||||
|
.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) {border-radius:0}
|
||||||
|
.issue-center .workspace-symbol {border-radius:0}
|
||||||
|
.issue-center .board-column {border-radius:0}
|
||||||
|
.issue-center .board-card {border-radius:0}
|
||||||
|
.issue-center .empty-symbol {border-radius:0}
|
||||||
|
|
||||||
|
.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:0}
|
||||||
|
/* Both tabs, including their shared navigation and dropdowns, use square corners. */
|
||||||
|
.issue-center :not(.status-dot):not(.column-dot) {border-radius:0 !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}
|
||||||
|
|
||||||
|
@media(max-width:800px) {
|
||||||
|
.issue-center .board-directory {padding:14px 16px 10px;gap:8px}
|
||||||
|
.issue-center .board-discovery-notice {padding:8px 16px}
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-center .issue-state-action {width:100%;margin-top:8px;justify-content:center}
|
||||||
|
|
||||||
|
.issue-center .workspace-button.issue-create-button {margin-left:auto;flex-shrink:0;background:var(--color-primary);border-color:var(--color-primary);color:#fff}
|
||||||
|
.issue-center .workspace-button.issue-create-button:hover:not(:disabled) {background:var(--color-primary);border-color:var(--color-primary);color:#fff;filter:brightness(1.08)}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface PullRequestBadgeState {
|
||||||
|
status: "loading" | "ready" | "error" | "unavailable";
|
||||||
|
count: number;
|
||||||
|
sourceId: string;
|
||||||
|
repository: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -448,3 +448,46 @@ export interface StoredCredential {
|
|||||||
password: string;
|
password: string;
|
||||||
mode?: "credentials" | "token";
|
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 }
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Optional UI preferences must never prevent the workspace from opening.
|
||||||
|
export function readWorkspacePreferences(key: string): Record<string, string> {
|
||||||
|
try {
|
||||||
|
const value: unknown = JSON.parse(localStorage.getItem(key) ?? "{}");
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||||
|
return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
|
||||||
|
} catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeWorkspacePreferences(key: string, value: Record<string, string>) {
|
||||||
|
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* Storage may be unavailable or full. */ }
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export interface Workspace {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
repositories: string[];
|
||||||
|
openPaths: string[];
|
||||||
|
activePath: string;
|
||||||
|
}
|
||||||
|
export interface WorkspaceState {
|
||||||
|
selectedId: string;
|
||||||
|
workspaces: Workspace[];
|
||||||
|
defaultSession: { openPaths: string[]; activePath: string };
|
||||||
|
}
|
||||||
|
export const WORKSPACES_KEY = "gitlite.workspaces.v1";
|
||||||
|
const paths = (value: unknown): string[] => Array.isArray(value)
|
||||||
|
? [...new Set(value.filter((path): path is string => typeof path === "string" && !!path.trim()))] : [];
|
||||||
|
export function readWorkspaces(storage: Pick<Storage, "getItem">, openPaths: string[]): WorkspaceState {
|
||||||
|
const fallback: WorkspaceState = { selectedId: "", workspaces: [], defaultSession: { openPaths, activePath: "" } };
|
||||||
|
try {
|
||||||
|
const raw = storage.getItem(WORKSPACES_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const saved = JSON.parse(raw);
|
||||||
|
const workspaces: Workspace[] = Array.isArray(saved.workspaces) ? saved.workspaces.flatMap((item: any) => {
|
||||||
|
if (!item || typeof item.id !== "string" || !item.id.startsWith("workspace-") || typeof item.name !== "string" || !item.name.trim()) return [];
|
||||||
|
const repositories = paths(item.repositories);
|
||||||
|
const openPaths = paths(item.openPaths).filter(path => repositories.includes(path));
|
||||||
|
return [{ id: item.id, name: item.name, repositories, openPaths,
|
||||||
|
activePath: openPaths.includes(item.activePath) ? item.activePath : openPaths[0] ?? "" }];
|
||||||
|
}) : [];
|
||||||
|
const defaultPaths = paths(saved.defaultSession?.openPaths);
|
||||||
|
return { workspaces, selectedId: workspaces.some(item => item.id === saved.selectedId) ? saved.selectedId : "",
|
||||||
|
defaultSession: { openPaths: defaultPaths, activePath: defaultPaths.includes(saved.defaultSession?.activePath) ? saved.defaultSession.activePath : defaultPaths[0] ?? "" } };
|
||||||
|
}
|
||||||
|
const legacy = JSON.parse(storage.getItem("gitty.dashboard.v1") ?? "{}");
|
||||||
|
if (Array.isArray(legacy.workspaces)) fallback.workspaces = legacy.workspaces.flatMap((item: any) => {
|
||||||
|
if (!item || typeof item.id !== "string" || !item.id.startsWith("workspace-") || typeof item.name !== "string" || !item.name.trim()) return [];
|
||||||
|
const repositories = Object.entries(legacy.assignments ?? {}).filter(([, id]) => id === item.id).map(([path]) => path);
|
||||||
|
const tabs = openPaths.filter(path => repositories.includes(path));
|
||||||
|
return [{ ...item, repositories, openPaths: tabs, activePath: tabs[0] ?? "" }];
|
||||||
|
});
|
||||||
|
} catch { /* Invalid optional preferences must not block startup. */ }
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user