Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c652b5df8 | ||
|
|
200f01185a | ||
|
|
814f0e3a40 | ||
|
|
cd224fcc55 | ||
|
|
298bda2b54 | ||
|
|
b7a5bfe29f | ||
|
|
ee35169feb | ||
|
|
c7d6fa680a | ||
|
|
f7f85d387d | ||
|
|
0539020545 | ||
|
|
80c7cf7280 | ||
|
|
1b2460761f | ||
|
|
9e373e77d1 |
Generated
+25
-2
@@ -1,18 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.9.2",
|
"version": "2026.9.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.9.2",
|
"version": "2026.9.3",
|
||||||
"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.2",
|
"version": "2026.9.3",
|
||||||
"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"
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
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 +14,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,53 @@
|
|||||||
|
use super::*;
|
||||||
|
use super::issue_comments::{comment_url, request, comment_client};
|
||||||
|
use serde_json::{Value,json};
|
||||||
|
fn read(response: reqwest::blocking::Response, provider: &str) -> Result<Value,String> {
|
||||||
|
if !response.status().is_success() {return Err(response_error(response,provider));}
|
||||||
|
response.json().map_err(|e|e.to_string())
|
||||||
|
}
|
||||||
|
fn completed_state(value: &Value) -> Result<String,String> {
|
||||||
|
let states:Vec<_>=value["value"].as_array().into_iter().flatten().filter(|v|v["category"]=="Completed").filter_map(|v|v["name"].as_str()).filter(|s|!s.is_empty()).collect();
|
||||||
|
if states.len()!=1 {return Err("No unique completed state is configured for this work item type. Open the issue in Azure to select its state.".into());}
|
||||||
|
Ok(states[0].into())
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn close_integration_issue(provider:String,base_url:String,username:String,token:String,repository:String,number:u64) -> Result<String,String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let client=comment_client()?;
|
||||||
|
let mut url=comment_url(&provider,&base_url,&repository,number)?;
|
||||||
|
url.path_segments_mut().map_err(|_|"Invalid issue URL.")?.pop();
|
||||||
|
if provider=="azure-devops" {
|
||||||
|
url.set_query(Some("api-version=7.1"));
|
||||||
|
let item=read(request(&client,reqwest::Method::GET,url.clone(),&provider,&username,&token)?.send().map_err(|e|e.to_string())?,&provider)?;
|
||||||
|
let kind=value_string(&item,&["fields","System.WorkItemType"]);
|
||||||
|
let mut states_url=reqwest::Url::parse(&normalized_base_url(&base_url)?).map_err(|e|e.to_string())?;
|
||||||
|
states_url.path_segments_mut().map_err(|_|"Invalid Azure URL.")?.pop_if_empty().extend([&repository,"_apis","wit","workitemtypes",&kind,"states"]);
|
||||||
|
states_url.set_query(Some("api-version=7.1"));
|
||||||
|
let states=read(request(&client,reqwest::Method::GET,states_url,&provider,&username,&token)?.send().map_err(|e|e.to_string())?,&provider)?;
|
||||||
|
let state=completed_state(&states)?;
|
||||||
|
if item["fields"]["System.State"]==state {return Ok(state);}
|
||||||
|
let rev=item["rev"].as_u64().ok_or("Azure returned no revision.")?;
|
||||||
|
let payload=json!([{"op":"test","path":"/rev","value":rev},{"op":"add","path":"/fields/System.State","value":state}]);
|
||||||
|
let result=read(request(&client,reqwest::Method::PATCH,url,&provider,&username,&token)?.header("Content-Type","application/json-patch+json").json(&payload).send().map_err(|e|e.to_string())?,&provider)?;
|
||||||
|
let actual=value_string(&result,&["fields","System.State"]);
|
||||||
|
if actual!=state {return Err("Azure did not confirm the completed state.".into());}
|
||||||
|
Ok(actual)
|
||||||
|
} else {
|
||||||
|
let gitlab=provider.starts_with("gitlab");
|
||||||
|
let method=if gitlab {reqwest::Method::PUT}else{reqwest::Method::PATCH};
|
||||||
|
let payload=if gitlab {json!({"state_event":"close"})}else{json!({"state":"closed"})};
|
||||||
|
let result=read(request(&client,method,url,&provider,&username,&token)?.json(&payload).send().map_err(|e|format!("Closing was not confirmed: {e}"))?,&provider)?;
|
||||||
|
let state=value_string(&result,&["state"]);
|
||||||
|
if state!="closed" {return Err("The provider did not confirm the closed state.".into());}
|
||||||
|
Ok(state)
|
||||||
|
}
|
||||||
|
}).await.map_err(|e|e.to_string())?
|
||||||
|
}
|
||||||
|
#[cfg(test)] mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test] fn uses_custom_completed_state_and_rejects_ambiguous_configuration() {
|
||||||
|
assert_eq!(completed_state(&json!({"value":[{"name":"Review","category":"Resolved"},{"name":"Delivered","category":"Completed"}]})).unwrap(),"Delivered");
|
||||||
|
assert!(completed_state(&json!({"value":[]})).is_err());
|
||||||
|
assert!(completed_state(&json!({"value":[{"name":"A","category":"Completed"},{"name":"B","category":"Completed"}]})).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
use super::*;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct IssueComment { id: String, author: String, body: String, created_at: String, body_html: bool }
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct IssueCommentPage { comments: Vec<IssueComment>, next_cursor: Option<String> }
|
||||||
|
|
||||||
|
pub(super) fn comment_url(provider: &str, base: &str, repository: &str, number: u64) -> Result<reqwest::Url, String> {
|
||||||
|
if repository.trim().is_empty() || number == 0 { return Err("Missing issue repository or number.".into()); }
|
||||||
|
let base = normalized_base_url(base)?;
|
||||||
|
let mut url = reqwest::Url::parse(if provider == "github" { "https://api.github.com" } else { &base }).map_err(|e|e.to_string())?;
|
||||||
|
let num = number.to_string();
|
||||||
|
let mut path = url.path_segments_mut().map_err(|_|"Invalid API URL.")?;
|
||||||
|
path.pop_if_empty();
|
||||||
|
match provider {
|
||||||
|
"github" | "gitea" => {
|
||||||
|
let (owner, repo) = repository.split_once('/').filter(|(a,b)|!a.is_empty() && !b.is_empty() && !b.contains('/')).ok_or("Invalid issue repository.")?;
|
||||||
|
if provider == "gitea" { path.extend(["api","v1"]); }
|
||||||
|
path.extend(["repos",owner,repo,"issues",&num,"comments"]);
|
||||||
|
},
|
||||||
|
"gitlab" | "gitlab-self-hosted" => { path.extend(["api","v4","projects",repository,"issues",&num,"notes"]); },
|
||||||
|
"azure-devops" => { path.extend([repository,"_apis","wit","workItems",&num,"comments"]); },
|
||||||
|
_ => return Err("Unsupported issue provider.".into()),
|
||||||
|
}
|
||||||
|
drop(path);
|
||||||
|
if provider == "azure-devops" { url.query_pairs_mut().append_pair("api-version","7.1-preview.4"); }
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
pub(super) fn request(client: &Client, method: reqwest::Method, url: reqwest::Url, provider: &str, username: &str, token: &str) -> Result<reqwest::blocking::RequestBuilder, String> {
|
||||||
|
if token.trim().is_empty() { return Err("No token stored for this integration.".into()); }
|
||||||
|
let req = client.request(method,url).header(USER_AGENT,"Gitty").header(ACCEPT,"application/json");
|
||||||
|
Ok(match provider {
|
||||||
|
"github" => req.bearer_auth(token),
|
||||||
|
"gitea" => req.header("Authorization",format!("token {token}")),
|
||||||
|
"gitlab" | "gitlab-self-hosted" => req.header("PRIVATE-TOKEN",token),
|
||||||
|
_ => req.basic_auth(if username.is_empty(){"gitty"}else{username},Some(token)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn parse_comment(v: &Value, provider: &str) -> Option<IssueComment> {
|
||||||
|
if v["system"] == true || v["isDeleted"] == true { return None; }
|
||||||
|
let azure = provider == "azure-devops";
|
||||||
|
let id = if azure && v["id"].is_null() { json_id(&json!({"id":v["commentId"]})) } else { json_id(v) };
|
||||||
|
Some(IssueComment { id, author: value_string(v, if azure { &["createdBy","displayName"] } else if provider.starts_with("gitlab") { &["author","username"] } else { &["user","login"] }), body:value_string(v,&[if azure{"text"}else{"body"}]), created_at:value_string(v,&[if azure{"createdDate"}else{"created_at"}]), body_html: azure && (v["format"] == "html" || v["format"] == 0) })
|
||||||
|
}
|
||||||
|
pub(super) fn comment_client() -> Result<Client,String> {
|
||||||
|
Client::builder().timeout(Duration::from_secs(30)).connect_timeout(Duration::from_secs(7)).redirect(reqwest::redirect::Policy::none()).build().map_err(|e|e.to_string())
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_integration_issue_comments(provider:String, base_url:String, username:String, token:String, repository:String, number:u64, cursor:Option<String>) -> Result<IssueCommentPage,String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let client=comment_client()?;
|
||||||
|
let mut url=comment_url(&provider,&base_url,&repository,number)?;
|
||||||
|
let page=if provider!="azure-devops" { cursor.as_deref().unwrap_or("1").parse::<u32>().map_err(|_|"Invalid comment page.")?.max(1) } else {1};
|
||||||
|
if provider=="azure-devops" {
|
||||||
|
url.query_pairs_mut().append_pair("$top","100").append_pair("order","asc").append_pair("includeDeleted","false");
|
||||||
|
if let Some(cursor)=cursor {url.query_pairs_mut().append_pair("continuationToken",&cursor);}
|
||||||
|
} else {url.query_pairs_mut().append_pair("page",&page.to_string()).append_pair("per_page","100").append_pair("limit","100").append_pair("sort","asc").append_pair("order_by","created_at");}
|
||||||
|
let response=request(&client,reqwest::Method::GET,url,&provider,&username,&token)?.send().map_err(|e|e.to_string())?;
|
||||||
|
if !response.status().is_success(){return Err(response_error(response,&provider));}
|
||||||
|
let linked_next=response.headers().get("link").and_then(|h|h.to_str().ok()).map(|s|s.contains("rel=\"next\""));
|
||||||
|
let value:Value=response.json().map_err(|e|e.to_string())?;
|
||||||
|
let items=if provider=="azure-devops" {value["comments"].as_array()}else{value.as_array()}.ok_or("Invalid comment list.")?;
|
||||||
|
let next_cursor=if provider=="azure-devops" {value["continuationToken"].as_str().filter(|s|!s.is_empty()).map(str::to_string)}else if linked_next.unwrap_or(items.len()==100) {Some(page.saturating_add(1).to_string())}else{None};
|
||||||
|
Ok(IssueCommentPage{comments:items.iter().filter_map(|v|parse_comment(v,&provider)).collect(),next_cursor})
|
||||||
|
}).await.map_err(|e|e.to_string())?
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn add_integration_issue_comment(provider:String, base_url:String, username:String, token:String, repository:String, number:u64, body:String) -> Result<IssueComment,String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
if body.trim().is_empty() || body.len()>100_000 {return Err("Comment must contain between 1 and 100,000 bytes.".into());}
|
||||||
|
let mut url=comment_url(&provider,&base_url,&repository,number)?;
|
||||||
|
if provider=="azure-devops" {url.query_pairs_mut().append_pair("format","markdown");}
|
||||||
|
let payload=if provider=="azure-devops"{json!({"text":body})}else{json!({"body":body})};
|
||||||
|
let response=request(&comment_client()?,reqwest::Method::POST,url,&provider,&username,&token)?.json(&payload).send().map_err(|e|format!("Comment delivery not confirmed: {e}"))?;
|
||||||
|
if !response.status().is_success(){return Err(response_error(response,&provider));}
|
||||||
|
parse_comment(&response.json::<Value>().map_err(|e|e.to_string())?,&provider).ok_or("Invalid posted comment.".into())
|
||||||
|
}).await.map_err(|e|e.to_string())?
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test] fn issue_endpoints_use_issue_notes_and_encode_project_paths() {
|
||||||
|
assert_eq!(comment_url("gitlab","https://git.test","team/sub/repo",4).unwrap().as_str(),"https://git.test/api/v4/projects/team%2Fsub%2Frepo/issues/4/notes");
|
||||||
|
assert_eq!(comment_url("azure-devops","https://dev.azure.com/org","My Project",4).unwrap().path(),"/org/My%20Project/_apis/wit/workItems/4/comments");
|
||||||
|
assert!(comment_url("gitea","https://git.test","owner/repo/invalid",4).is_err());
|
||||||
|
}
|
||||||
|
#[test] fn comments_filter_system_entries_and_preserve_html_format() {
|
||||||
|
assert!(parse_comment(&json!({"system":true}),"gitlab").is_none());
|
||||||
|
let c=parse_comment(&json!({"commentId":7,"text":"<p>Hello</p>","format":"html","createdBy":{"displayName":"Alex"}}),"azure-devops").unwrap();
|
||||||
|
assert_eq!(c.id,"7"); assert!(c.body_html); assert_eq!(c.author,"Alex");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct IntegrationIssue {
|
||||||
|
id: String,
|
||||||
|
number: u64,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
description_html: bool,
|
||||||
|
repository_name: String,
|
||||||
|
author: String,
|
||||||
|
state: String,
|
||||||
|
labels: Vec<String>,
|
||||||
|
assignees: Vec<String>,
|
||||||
|
web_url: String,
|
||||||
|
updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct IssuePage {
|
||||||
|
issues: Vec<IntegrationIssue>,
|
||||||
|
next_cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_issue(value: &serde_json::Value, provider: &str) -> Option<IntegrationIssue> {
|
||||||
|
if value.get("pull_request").is_some_and(|v| !v.is_null()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let gitlab = provider.starts_with("gitlab");
|
||||||
|
let number = value_u64(value, if gitlab { "iid" } else { "number" });
|
||||||
|
if number == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let reference = value_string(value, &["references", "full"]);
|
||||||
|
let repository_name = if gitlab {
|
||||||
|
reference
|
||||||
|
.rsplit_once('#')
|
||||||
|
.map(|(repo, _)| repo.to_string())
|
||||||
|
.unwrap_or_else(|| value_u64(value, "project_id").to_string())
|
||||||
|
} else {
|
||||||
|
let name = value_string(value, &["repository", "full_name"]);
|
||||||
|
if name.is_empty() {
|
||||||
|
github_repository_name(&value_string(value, &["repository_url"]))
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Some(IntegrationIssue {
|
||||||
|
id: format!("{provider}:{}", value_u64(value, "id")),
|
||||||
|
number,
|
||||||
|
title: value_string(value, &["title"]),
|
||||||
|
description: value_string(value, &[if gitlab { "description" } else { "body" }]),
|
||||||
|
description_html: false,
|
||||||
|
repository_name,
|
||||||
|
author: value_string(
|
||||||
|
value,
|
||||||
|
if gitlab {
|
||||||
|
&["author", "username"]
|
||||||
|
} else {
|
||||||
|
&["user", "login"]
|
||||||
|
},
|
||||||
|
),
|
||||||
|
state: match value_string(value, &["state"]).as_str() {
|
||||||
|
"opened" => "open".into(),
|
||||||
|
state => state.to_string(),
|
||||||
|
},
|
||||||
|
labels: value
|
||||||
|
.get("labels")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|v| {
|
||||||
|
v.as_str()
|
||||||
|
.or_else(|| v.get("name").and_then(|n| n.as_str()))
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
assignees: review_names(
|
||||||
|
value,
|
||||||
|
"assignees",
|
||||||
|
if gitlab { "username" } else { "login" },
|
||||||
|
),
|
||||||
|
web_url: value_string(value, &[if gitlab { "web_url" } else { "html_url" }]),
|
||||||
|
updated_at: value_string(value, &["updated_at"]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_response(
|
||||||
|
request: reqwest::blocking::RequestBuilder,
|
||||||
|
provider: &str,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let response = request
|
||||||
|
.send()
|
||||||
|
.map_err(|err| format!("Could not reach {provider}: {err}"))?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(response_error(response, provider));
|
||||||
|
}
|
||||||
|
response
|
||||||
|
.json()
|
||||||
|
.map_err(|err| format!("Invalid {provider} issue response: {err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn azure_issue(value: &serde_json::Value, base: &str) -> IntegrationIssue {
|
||||||
|
let field = |name: &str| value_string(value, &["fields", name]);
|
||||||
|
let number = value_u64(value, "id");
|
||||||
|
let assigned = value_string(value, &["fields", "System.AssignedTo", "displayName"]);
|
||||||
|
IntegrationIssue {
|
||||||
|
id: format!("azure-devops:{number}"),
|
||||||
|
number,
|
||||||
|
title: field("System.Title"),
|
||||||
|
description: field("System.Description"),
|
||||||
|
description_html: true,
|
||||||
|
repository_name: field("System.TeamProject"),
|
||||||
|
author: value_string(value, &["fields", "System.CreatedBy", "displayName"]),
|
||||||
|
state: field("System.State"),
|
||||||
|
labels: field("System.Tags")
|
||||||
|
.split(';')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect(),
|
||||||
|
assignees: if assigned.is_empty() {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
vec![assigned]
|
||||||
|
},
|
||||||
|
web_url: format!("{base}/_workitems/edit/{number}"),
|
||||||
|
updated_at: field("System.ChangedDate"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn azure_issues(
|
||||||
|
client: &Client,
|
||||||
|
base: &str,
|
||||||
|
username: &str,
|
||||||
|
token: &str,
|
||||||
|
cursor: Option<u64>,
|
||||||
|
) -> Result<IssuePage, String> {
|
||||||
|
let condition = cursor
|
||||||
|
.map(|id| format!("[System.Id] < {id}"))
|
||||||
|
.unwrap_or_else(|| "[System.Id] > 0".into());
|
||||||
|
let payload = json_response(client.post(format!("{base}/_apis/wit/wiql"))
|
||||||
|
.basic_auth(username, Some(token)).query(&[("api-version", "7.1"), ("$top", "101")])
|
||||||
|
.json(&serde_json::json!({"query": format!("SELECT [System.Id] FROM WorkItems WHERE {condition} ORDER BY [System.Id] DESC")})), "Azure DevOps")?;
|
||||||
|
let ids: Vec<u64> = payload
|
||||||
|
.get("workItems")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.ok_or("Azure DevOps returned no work item list.")?
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.get("id").and_then(|id| id.as_u64()))
|
||||||
|
.collect();
|
||||||
|
let next_cursor = if ids.len() > 100 {
|
||||||
|
Some(ids[99].to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(IssuePage {
|
||||||
|
issues: vec![],
|
||||||
|
next_cursor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let ids = ids
|
||||||
|
.iter()
|
||||||
|
.take(100)
|
||||||
|
.map(u64::to_string)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
let payload = json_response(
|
||||||
|
client
|
||||||
|
.get(format!("{base}/_apis/wit/workitems"))
|
||||||
|
.basic_auth(username, Some(token))
|
||||||
|
.query(&[("api-version", "7.1"), ("ids", ids.as_str())]),
|
||||||
|
"Azure DevOps",
|
||||||
|
)?;
|
||||||
|
let mut issues: Vec<_> = payload
|
||||||
|
.get("value")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.ok_or("Azure DevOps returned no work item details.")?
|
||||||
|
.iter()
|
||||||
|
.map(|v| azure_issue(v, base))
|
||||||
|
.collect();
|
||||||
|
issues.sort_by_key(|issue| std::cmp::Reverse(issue.number));
|
||||||
|
Ok(IssuePage {
|
||||||
|
issues,
|
||||||
|
next_cursor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_integration_issues(
|
||||||
|
provider: String,
|
||||||
|
base_url: String,
|
||||||
|
username: String,
|
||||||
|
token: String,
|
||||||
|
cursor: Option<String>,
|
||||||
|
) -> Result<IssuePage, String> {
|
||||||
|
tokio::time::timeout(
|
||||||
|
REVIEW_REQUEST_TIMEOUT,
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
if token.trim().is_empty() {
|
||||||
|
return Err("No token is stored for this integration.".into());
|
||||||
|
}
|
||||||
|
let cursor = cursor
|
||||||
|
.map(|c| {
|
||||||
|
c.parse::<u64>()
|
||||||
|
.map_err(|_| "Invalid issue cursor.".to_string())
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
let base = normalized_base_url(&base_url)?;
|
||||||
|
let client = client()?;
|
||||||
|
if provider == "azure-devops" {
|
||||||
|
return azure_issues(
|
||||||
|
&client,
|
||||||
|
&base,
|
||||||
|
if username.is_empty() {
|
||||||
|
"gitty"
|
||||||
|
} else {
|
||||||
|
&username
|
||||||
|
},
|
||||||
|
&token,
|
||||||
|
cursor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let page = cursor.unwrap_or(1).max(1).to_string();
|
||||||
|
let request = match provider.as_str() {
|
||||||
|
"github" => client
|
||||||
|
.get(format!("{}/issues", github_api_base_url(&base)?))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.query(&[
|
||||||
|
("filter", "all"),
|
||||||
|
("state", "all"),
|
||||||
|
("sort", "created"),
|
||||||
|
("direction", "desc"),
|
||||||
|
("per_page", "100"),
|
||||||
|
("page", page.as_str()),
|
||||||
|
]),
|
||||||
|
"gitlab" | "gitlab-self-hosted" => client
|
||||||
|
.get(format!("{base}/api/v4/issues"))
|
||||||
|
.header("PRIVATE-TOKEN", &token)
|
||||||
|
.query(&[
|
||||||
|
("scope", "all"),
|
||||||
|
("state", "all"),
|
||||||
|
("order_by", "created_at"),
|
||||||
|
("sort", "desc"),
|
||||||
|
("per_page", "100"),
|
||||||
|
("page", page.as_str()),
|
||||||
|
]),
|
||||||
|
"gitea" => client
|
||||||
|
.get(format!("{base}/api/v1/repos/issues/search"))
|
||||||
|
.header("Authorization", format!("token {token}"))
|
||||||
|
.query(&[
|
||||||
|
("type", "issues"),
|
||||||
|
("state", "all"),
|
||||||
|
("limit", "100"),
|
||||||
|
("page", page.as_str()),
|
||||||
|
]),
|
||||||
|
_ => return Err("Unsupported integration provider.".into()),
|
||||||
|
}
|
||||||
|
.header(USER_AGENT, "Gitty")
|
||||||
|
.header(ACCEPT, "application/json");
|
||||||
|
let response = request
|
||||||
|
.send()
|
||||||
|
.map_err(|err| format!("Could not reach {provider}: {err}"))?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(response_error(response, &provider));
|
||||||
|
}
|
||||||
|
let linked_next = response
|
||||||
|
.headers()
|
||||||
|
.get("link")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|v| v.contains("rel=\"next\""));
|
||||||
|
let values: Vec<serde_json::Value> = response
|
||||||
|
.json()
|
||||||
|
.map_err(|err| format!("Invalid issue list: {err}"))?;
|
||||||
|
let next_cursor = if linked_next.unwrap_or(values.len() == 100) {
|
||||||
|
Some(cursor.unwrap_or(1).max(1).saturating_add(1).to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok(IssuePage {
|
||||||
|
issues: values
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| parse_issue(v, &provider))
|
||||||
|
.collect(),
|
||||||
|
next_cursor,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "The issue API did not respond within 35 seconds.".to_string())?
|
||||||
|
.map_err(|err| format!("Could not load issues: {err}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
#[test]
|
||||||
|
fn excludes_pull_requests_and_maps_github_issues() {
|
||||||
|
assert!(parse_issue(&json!({"number": 2, "pull_request": {}}), "github").is_none());
|
||||||
|
let issue = parse_issue(&json!({"id": 4, "number": 2, "repository_url": "https://api.github.com/repos/team/app", "labels": [{"name":"bug"}], "state":"open"}), "github").unwrap();
|
||||||
|
assert_eq!(issue.repository_name, "team/app");
|
||||||
|
assert_eq!(issue.labels, ["bug"]);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn maps_both_gitlab_variants_and_gitea() {
|
||||||
|
for provider in ["gitlab", "gitlab-self-hosted"] {
|
||||||
|
let issue = parse_issue(&json!({"id": 8,"iid": 3,"references":{"full":"team/app#3"},"state":"opened","labels":["bug"]}), provider).unwrap();
|
||||||
|
assert_eq!(issue.repository_name, "team/app");
|
||||||
|
assert_eq!(issue.state, "open");
|
||||||
|
}
|
||||||
|
let issue = parse_issue(
|
||||||
|
&json!({"id":9,"number":4,"repository":{"full_name":"team/app"},"pull_request":null}),
|
||||||
|
"gitea",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(issue.repository_name, "team/app");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn preserves_custom_azure_states_and_project_identity() {
|
||||||
|
let issue = azure_issue(
|
||||||
|
&json!({"id":42,"fields":{"System.State":"Ready for QA","System.TeamProject":"Project","System.Tags":"bug; urgent"}}),
|
||||||
|
"https://dev.azure.com/org",
|
||||||
|
);
|
||||||
|
assert_eq!(issue.state, "Ready for QA");
|
||||||
|
assert_eq!(issue.repository_name, "Project");
|
||||||
|
assert_eq!(issue.labels, ["bug", "urgent"]);
|
||||||
|
assert_eq!(
|
||||||
|
issue.web_url,
|
||||||
|
"https://dev.azure.com/org/_workitems/edit/42"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-1
@@ -34,8 +34,9 @@ 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,
|
run_integration_review_action, list_integration_issues, get_integration_board, list_integration_boards, move_integration_board_card, list_integration_issue_comments, add_integration_issue_comment, close_integration_issue,
|
||||||
};
|
};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -437,8 +438,17 @@ 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,
|
||||||
|
close_integration_issue,
|
||||||
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.2",
|
"version": "2026.9.3",
|
||||||
"identifier": "com.gitty",
|
"identifier": "com.gitty",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run prepare:lfs && npm run dev",
|
"beforeDevCommand": "npm run prepare:lfs && npm run dev",
|
||||||
|
|||||||
+20
-2
@@ -10,6 +10,10 @@
|
|||||||
|
|
||||||
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 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";
|
||||||
@@ -219,7 +223,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";
|
||||||
@@ -5392,7 +5396,13 @@
|
|||||||
{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}
|
||||||
onClose={closeRepoTab}
|
onClose={closeRepoTab}
|
||||||
@@ -5548,18 +5558,26 @@
|
|||||||
</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}
|
||||||
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}
|
||||||
|
|||||||
+2
-1
@@ -26,6 +26,7 @@
|
|||||||
--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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,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;
|
||||||
|
|||||||
+45
-60
@@ -1,5 +1,5 @@
|
|||||||
<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";
|
||||||
|
|
||||||
interface RepositoryTabItem {
|
interface RepositoryTabItem {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -7,11 +7,13 @@
|
|||||||
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> = () => {};
|
||||||
@@ -20,65 +22,48 @@
|
|||||||
export let onAdd: () => void | Promise<void> = () => {};
|
export let onAdd: () => void | Promise<void> = () => {};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="repo-tabbar" aria-label={language === "de" ? "Repository-Reiter" : "Repository tabs"}>
|
<header class="workspace-navigation">
|
||||||
<button
|
<nav class="global-navigation" aria-label={language === "de" ? "Hauptnavigation" : "Main navigation"}>
|
||||||
class="repo-tab management"
|
<button type="button" class:active={activeView === "management"} aria-current={activeView === "management" ? "page" : undefined} disabled={isBusy} onclick={onOpenManagement}><House size={17}/><span>Dashboard</span></button>
|
||||||
class:active={activeView === "management"}
|
<button type="button" class:active={activeView === "repository"} aria-current={activeView === "repository" ? "page" : undefined} disabled={isBusy} onclick={onOpenRepositories}><Database size={17}/><span>Repositories</span></button>
|
||||||
type="button"
|
<button type="button" class:active={activeView === "review-center"} aria-current={activeView === "review-center" ? "page" : undefined} disabled={isBusy} onclick={onOpenReviewCenter}><GitPullRequest size={17}/><span>Pull Requests</span></button>
|
||||||
onclick={onOpenManagement}
|
<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>
|
||||||
disabled={isBusy}
|
</nav>
|
||||||
title={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
|
{#if activeView === "repository"}
|
||||||
aria-label={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
|
<nav class="repository-navigation" aria-label={language === "de" ? "Geöffnete Repositories" : "Open repositories"}>
|
||||||
>
|
|
||||||
<Folder size={15} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="repo-tabs-scroll">
|
|
||||||
<div class="repo-tab-wrap review-center-tab" class:active={activeView === "review-center"} role="presentation">
|
|
||||||
<button class="repo-tab" type="button" onclick={onOpenReviewCenter} disabled={isBusy} title="Review Center" aria-label="Review Center">
|
|
||||||
<GitPullRequest size={13} aria-hidden="true" />
|
|
||||||
<span>PR</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{#each repoTabs as repo (repo.path)}
|
{#each repoTabs as repo (repo.path)}
|
||||||
<div
|
<div class="repository-tab" class:active={isActive(repo.path)} role="presentation" oncontextmenu={(event) => onContextMenu(repo.path, event)}>
|
||||||
class="repo-tab-wrap"
|
<button class="repository-select" type="button" onclick={() => onSelect(repo.path)} disabled={isBusy} title={repo.path} aria-current={isActive(repo.path) ? "page" : undefined}><Folder size={16}/><span>{repo.name}</span></button>
|
||||||
class:active={isActive(repo.path)}
|
<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>
|
||||||
role="presentation"
|
|
||||||
oncontextmenu={(event) => onContextMenu(repo.path, event)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="repo-tab"
|
|
||||||
type="button"
|
|
||||||
onclick={() => onSelect(repo.path)}
|
|
||||||
disabled={isBusy}
|
|
||||||
title={repo.path}
|
|
||||||
>
|
|
||||||
<GitBranch size={13} aria-hidden="true" />
|
|
||||||
<span>{repo.name}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="repo-tab-close"
|
|
||||||
type="button"
|
|
||||||
onclick={(event) => onClose(repo.path, event)}
|
|
||||||
disabled={isBusy}
|
|
||||||
aria-label={language === "de" ? `${repo.name} schließen` : `Close ${repo.name}`}
|
|
||||||
title={language === "de" ? "Repository-Tab schließen" : "Close repository tab"}
|
|
||||||
>
|
|
||||||
<X size={11} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
<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>
|
||||||
<button
|
{/if}
|
||||||
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>
|
||||||
|
.workspace-navigation{flex:0 0 auto;min-width:0;background:var(--app-bg);color:var(--color-ink-muted);font-family:var(--font-sans);border-bottom:1px solid var(--color-border-subtle)}
|
||||||
|
.global-navigation{display:flex;align-items:stretch;gap:2px;min-height:38px;padding:0 5px;overflow-x:auto;scrollbar-width:thin;border-bottom:1px solid var(--color-border-subtle)}
|
||||||
|
button{font:inherit;cursor:pointer;color:inherit;background:transparent;border:0;box-shadow:none}
|
||||||
|
button:disabled{opacity:.45;cursor:default}
|
||||||
|
button:focus-visible{outline:2px solid var(--color-accent);outline-offset:-3px}
|
||||||
|
.global-navigation button{position:relative;display:flex;flex:0 0 auto;align-items:center;gap:7px;padding:0 10px;min-height:38px;font-size:13px;white-space:nowrap}
|
||||||
|
.global-navigation button:hover:not(:disabled){background:var(--color-surface-hover);color:var(--color-ink)}
|
||||||
|
.global-navigation button.active{color:var(--color-ink);font-weight:600}
|
||||||
|
.global-navigation button.active::after{position:absolute;content:"";height:2px;bottom:0;left:12px;right:12px;background:var(--color-accent)}
|
||||||
|
.global-navigation button.active>:global(svg){color:var(--color-accent)}
|
||||||
|
.repository-navigation{display:flex;align-items:stretch;gap:4px;min-height:34px;padding:4px 5px 0;overflow-x:auto;scrollbar-width:thin;background:color-mix(in srgb,var(--color-surface) 45%,var(--app-bg))}
|
||||||
|
.repository-tab{display:flex;flex:0 0 auto;align-items:center;min-width:100px;max-width:250px;border:1px solid var(--color-border);border-bottom:0;border-radius:5px 5px 0 0;background:var(--app-bg)}
|
||||||
|
.repository-tab.active{background:var(--color-surface-raised);border-color:var(--color-border-input);color:var(--color-ink)}
|
||||||
|
.repository-tab:hover{background:var(--color-surface-hover)}
|
||||||
|
.repository-select{display:flex;flex:1;min-width:0;align-items:center;gap:7px;min-height:29px;padding:0 9px;font-size:12px;text-align:left}
|
||||||
|
.repository-select span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.repository-select>:global(svg){flex-shrink:0}
|
||||||
|
.repository-tab.active .repository-select{font-weight:600}
|
||||||
|
.repository-close{display:grid;place-items:center;flex:0 0 24px;height:24px;margin-right:2px;color:var(--color-ink-dim)}
|
||||||
|
.repository-close:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-hover)}
|
||||||
|
.repository-add{display:grid;place-items:center;flex:0 0 32px;min-height:29px;border:1px solid var(--color-border);border-bottom:0;border-radius:5px 5px 0 0}
|
||||||
|
.repository-add:hover:not(:disabled){color:var(--color-accent);background:var(--color-surface-hover)}
|
||||||
|
@media(max-width:640px){.global-navigation{gap:0;padding:0 4px}.global-navigation button{gap:7px;padding:0 10px;font-size:12px;min-height:38px}.global-navigation{min-height:38px}}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from "svelte";
|
||||||
|
import { marked } from "marked";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import { Heading1, Heading2, Heading3, Bold, Italic, Strikethrough, Quote, Code2, Link, List, ListOrdered, ListTodo, Table2, AtSign, MessageSquare, CaseSensitive, WrapText, Send } from "@lucide/svelte";
|
||||||
|
import { openInBrowser } from "../git";
|
||||||
|
export let value = "";
|
||||||
|
export let language: "de" | "en" = "en";
|
||||||
|
export let disabled = false;
|
||||||
|
export let busy = false;
|
||||||
|
export let onSend: () => void | Promise<void>;
|
||||||
|
let textarea: HTMLTextAreaElement;
|
||||||
|
let preview = false;
|
||||||
|
let monospace = false;
|
||||||
|
let wrap = true;
|
||||||
|
let linkError = "";
|
||||||
|
$: de = language === "de";
|
||||||
|
$: rendered = preview ? DOMPurify.sanitize(marked.parse(value, { async: false, gfm: true, breaks: true }), {
|
||||||
|
ALLOWED_TAGS: ["p","br","strong","em","del","s","blockquote","pre","code","h1","h2","h3","h4","h5","h6","ul","ol","li","a","table","thead","tbody","tr","th","td","hr","input"],
|
||||||
|
ALLOWED_ATTR: ["href","title","type","checked","disabled","align"],
|
||||||
|
}) : "";
|
||||||
|
$: tools = [
|
||||||
|
{icon:Heading1,label:de?"Überschrift 1":"Heading 1",prefix:"# ",line:true},
|
||||||
|
{icon:Heading2,label:de?"Überschrift 2":"Heading 2",prefix:"## ",line:true},
|
||||||
|
{icon:Heading3,label:de?"Überschrift 3":"Heading 3",prefix:"### ",line:true},
|
||||||
|
{icon:Bold,label:de?"Fett":"Bold",prefix:"**",suffix:"**",group:true},
|
||||||
|
{icon:Italic,label:de?"Kursiv":"Italic",prefix:"_",suffix:"_"},
|
||||||
|
{icon:Strikethrough,label:de?"Durchgestrichen":"Strikethrough",prefix:"~~",suffix:"~~"},
|
||||||
|
{icon:Quote,label:de?"Zitat":"Quote",prefix:"> ",line:true,group:true},
|
||||||
|
{icon:Code2,label:"Code",prefix:"`",suffix:"`"},
|
||||||
|
{icon:Link,label:"Link",prefix:"[",suffix:"](https://example.com)"},
|
||||||
|
{icon:List,label:de?"Aufzählung":"Bullet list",prefix:"- ",line:true,group:true},
|
||||||
|
{icon:ListOrdered,label:de?"Nummerierte Liste":"Numbered list",prefix:"1. ",line:true},
|
||||||
|
{icon:ListTodo,label:de?"Aufgabenliste":"Task list",prefix:"- [ ] ",line:true},
|
||||||
|
{icon:Table2,label:de?"Tabelle":"Table",prefix:"\n| A | B |\n| --- | --- |\n| | |\n"},
|
||||||
|
{icon:AtSign,label:de?"Erwähnung einfügen":"Insert mention",prefix:"@",suffix:"",group:true},
|
||||||
|
{icon:MessageSquare,label:de?"Issue-/PR-Verweis einfügen":"Insert issue/PR reference",prefix:"#",suffix:""},
|
||||||
|
];
|
||||||
|
async function format(prefix: string, suffix = "", line = false) {
|
||||||
|
if (disabled || busy) return;
|
||||||
|
preview = false;
|
||||||
|
await tick();
|
||||||
|
let start = textarea.selectionStart, end = textarea.selectionEnd;
|
||||||
|
if (line) {
|
||||||
|
start = value.lastIndexOf("\n", start - 1) + 1;
|
||||||
|
const selected = value.slice(start, end);
|
||||||
|
const replacement = selected.split("\n").map((text, i) => `${prefix === "1. " ? `${i + 1}. ` : prefix}${text}`).join("\n");
|
||||||
|
value = value.slice(0,start) + replacement + value.slice(end);
|
||||||
|
await tick(); textarea.focus(); textarea.setSelectionRange(start, start+replacement.length);
|
||||||
|
} else {
|
||||||
|
const selected = value.slice(start,end);
|
||||||
|
// Multi-line code selections become fenced blocks.
|
||||||
|
if (prefix === "`" && selected.includes("\n")) { prefix = "\n```\n"; suffix = "\n```\n"; }
|
||||||
|
value = value.slice(0,start)+prefix+selected+suffix+value.slice(end);
|
||||||
|
await tick(); textarea.focus(); textarea.setSelectionRange(start+prefix.length,start+prefix.length+selected.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function keyboard(event: KeyboardEvent) {
|
||||||
|
if (!(event.ctrlKey || event.metaKey)) return;
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if (key === "b" || key === "i" || key === "k") { event.preventDefault(); void format(key === "b" ? "**" : key === "i" ? "_" : "[",key === "b" ? "**" : key === "i" ? "_" : "](https://example.com)"); }
|
||||||
|
if (key === "enter") { event.preventDefault(); if (value.trim() && !busy && !disabled) void onSend(); }
|
||||||
|
}
|
||||||
|
function previewLinks(node: HTMLElement) {
|
||||||
|
node.addEventListener("click", previewClick);
|
||||||
|
return { destroy() { node.removeEventListener("click", previewClick); } };
|
||||||
|
}
|
||||||
|
async function previewClick(event: MouseEvent) {
|
||||||
|
const anchor = event.target instanceof Element ? event.target.closest("a") : null;
|
||||||
|
if (!anchor) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const href = anchor.getAttribute("href") ?? "";
|
||||||
|
if (!/^https?:\/\//i.test(href)) return;
|
||||||
|
try { await openInBrowser(href); } catch (cause) { linkError = String(cause); }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<div class="md-editor">
|
||||||
|
<div class="md-tabs" role="group" aria-label={de ? "Editor-Ansicht" : "Editor view"}>
|
||||||
|
<button type="button" class:active={!preview} aria-pressed={!preview} onclick={() => { preview = false; }}>{de ? "Schreiben" : "Write"}</button>
|
||||||
|
<button type="button" class:active={preview} aria-pressed={preview} onclick={() => { preview = true; }}>{de ? "Vorschau" : "Preview"}</button>
|
||||||
|
</div>
|
||||||
|
<div class="md-toolbar" role="group" aria-label={de ? "Markdown-Formatierung" : "Markdown formatting"}>
|
||||||
|
{#each tools as tool}<button type="button" class:group-start={tool.group} title={tool.label} aria-label={tool.label} disabled={disabled || busy || preview} onmousedown={event => event.preventDefault()} onclick={() => format(tool.prefix,tool.suffix,tool.line)}><svelte:component this={tool.icon} size={15} strokeWidth={1.7} /></button>{/each}
|
||||||
|
<span class="md-spacer"></span>
|
||||||
|
<button type="button" title={de ? "Monospace-Schrift" : "Monospace font"} aria-label={de ? "Monospace-Schrift" : "Monospace font"} aria-pressed={monospace} onclick={() => { monospace = !monospace; }}><CaseSensitive size={16} /></button>
|
||||||
|
<button type="button" title={de ? "Zeilenumbruch" : "Line wrap"} aria-label={de ? "Zeilenumbruch" : "Line wrap"} aria-pressed={wrap} onclick={() => { wrap = !wrap; }}><WrapText size={16} /></button>
|
||||||
|
</div>
|
||||||
|
{#if preview}
|
||||||
|
<!-- Rendered Markdown is restricted to safe content tags and sanitized before insertion. -->
|
||||||
|
<div class="md-preview" role="region" aria-label={de ? "Kommentarvorschau" : "Comment preview"} use:previewLinks>
|
||||||
|
{#if value.trim()}{@html rendered}{:else}<span>{de ? "Noch nichts zum Anzeigen." : "Nothing to preview yet."}</span>{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<textarea bind:this={textarea} bind:value class:monospace wrap={wrap ? "soft" : "off"} maxlength="100000" rows="5" disabled={disabled || busy} aria-label={de ? "Kommentar schreiben" : "Write comment"} placeholder={de ? "Kommentar hinzufügen …" : "Leave a comment …"} onkeydown={keyboard}></textarea>
|
||||||
|
{/if}
|
||||||
|
{#if linkError}<small role="alert">{linkError}</small>{/if}
|
||||||
|
<div class="md-footer"><small>Markdown <span>· Ctrl/⌘ + Enter</span></small><button class="md-send" type="button" disabled={!value.trim() || disabled || busy} onclick={() => onSend()}><Send size={13} />{busy ? (de ? "Wird gesendet …" : "Sending …") : (de ? "Kommentar senden" : "Post comment")}</button></div>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.md-editor{width:100%;min-width:0;color:var(--color-ink);font:400 12px/1.5 var(--font-sans)}
|
||||||
|
.md-editor button{display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:0;padding:0;border:0;border-radius:0;color:var(--color-ink-muted);background:transparent;font:inherit;cursor:pointer;box-shadow:none}
|
||||||
|
.md-editor button:disabled{opacity:.4;cursor:default}.md-editor button:focus-visible{outline:2px solid var(--color-accent);outline-offset:1px}.md-editor button:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-hover)}
|
||||||
|
.md-tabs{display:flex;border-bottom:1px solid var(--color-border);gap:1px}.md-tabs button{position:relative;height:36px;padding:0 12px;border:1px solid transparent;border-bottom:0;font-size:12px}.md-tabs button.active{border-color:var(--color-border);color:var(--color-ink);background:var(--app-bg);font-weight:600}.md-tabs button.active:after{content:"";position:absolute;bottom:-1px;left:0;right:0;height:1px;background:var(--app-bg)}
|
||||||
|
.md-toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:2px;padding:10px 2px;row-gap:6px}.md-toolbar button{width:25px;height:25px;flex-shrink:0}.md-toolbar .group-start{border-left:1px solid var(--color-border);margin-left:4px;padding-left:5px;width:30px}.md-spacer{flex:1}.md-toolbar button[aria-pressed=true]{color:var(--color-accent)}
|
||||||
|
.md-editor textarea,.md-preview{box-sizing:border-box;width:100%;min-height:132px;max-height:300px;margin:0;padding:11px 12px;border:1px solid var(--color-border-input);border-radius:3px;color:var(--color-ink);background:var(--app-input-bg);font:400 12px/1.6 var(--font-sans);overflow:auto;resize:vertical}.md-editor textarea:focus{outline:none;box-shadow:none;border-color:var(--color-accent)}.md-editor textarea.monospace{font-family:var(--font-mono)}.md-editor textarea::placeholder{color:var(--color-ink-faint)}.md-preview{overflow-wrap:anywhere;resize:none}.md-preview>span{color:var(--color-ink-faint)}
|
||||||
|
.md-preview :global(p){margin:0 0 10px;white-space:normal}.md-preview :global(h1),.md-preview :global(h2),.md-preview :global(h3){margin:12px 0 8px;line-height:1.3}.md-preview :global(h1){font-size:22px}.md-preview :global(h2){font-size:18px}.md-preview :global(h3){font-size:15px}.md-preview :global(ul){list-style:disc;padding-left:24px}.md-preview :global(ol){list-style:decimal;padding-left:24px}.md-preview :global(blockquote){border-left:3px solid var(--color-accent);padding-left:12px;margin:10px 0;color:var(--color-ink-muted)}.md-preview :global(pre){overflow:auto;background:var(--color-surface);padding:10px}.md-preview :global(code){font-family:var(--font-mono)}.md-preview :global(table){border-collapse:collapse;max-width:100%;margin:10px 0}.md-preview :global(th),.md-preview :global(td){border:1px solid var(--color-border);padding:5px 9px;font:inherit}.md-preview :global(a){color:var(--color-accent);text-decoration:underline}.md-preview :global(input[type=checkbox]){width:auto;margin-right:6px}
|
||||||
|
.md-footer{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:9px}.md-footer small{font-size:10px;color:var(--color-ink-faint)}.md-editor .md-send{height:32px;padding:0 10px;border:1px solid var(--color-accent);color:var(--color-accent);font-size:11px;white-space:nowrap}.md-editor .md-send:hover:not(:disabled){background:color-mix(in srgb,var(--color-accent) 12%,transparent)}
|
||||||
|
@media(max-width:420px){.md-footer small span{display:none}}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { GitPullRequest, X, LoaderCircle, ArrowRight } from "@lucide/svelte";
|
||||||
|
import { createIntegrationReviewRequest, listIntegrationRepositories, listIntegrationRepositoryBranches, listRemotes, listBranches } from "../git";
|
||||||
|
import { integrationCredentialKey } from "../integrations";
|
||||||
|
import type { GitIntegrationSource, GitIntegrationRepository, IntegrationReviewRequest, StoredCredential } from "../types";
|
||||||
|
|
||||||
|
let { source, de, localRepositoryPath = "", loadCredential, onClose, onCreated }: {
|
||||||
|
source: GitIntegrationSource; de: boolean; localRepositoryPath?: string;
|
||||||
|
loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||||
|
onClose: () => void; onCreated: (request: IntegrationReviewRequest) => void;
|
||||||
|
} = $props();
|
||||||
|
let dialog: HTMLDialogElement;
|
||||||
|
let repositories = $state<GitIntegrationRepository[]>([]);
|
||||||
|
let repositoryId = $state("");
|
||||||
|
let sourceBranch = $state("");
|
||||||
|
let targetBranch = $state("");
|
||||||
|
let branches = $state<string[]>([]);
|
||||||
|
let defaultBranch = $state("");
|
||||||
|
let branchesLoading = $state(false);
|
||||||
|
let branchError = $state("");
|
||||||
|
let branchGeneration = 0;
|
||||||
|
const branchOptions = $derived(branches.map(name => ({value:name,label:name,group:name === defaultBranch ? (de ? "Standardbranch" : "Default branch") : undefined})));
|
||||||
|
$effect(() => {
|
||||||
|
const repository = repositories.find(item => item.id === repositoryId);
|
||||||
|
void loadRepositoryBranches(repository);
|
||||||
|
});
|
||||||
|
async function loadRepositoryBranches(repository?: GitIntegrationRepository) {
|
||||||
|
const generation = ++branchGeneration;
|
||||||
|
branches = []; sourceBranch = ""; targetBranch = ""; defaultBranch = ""; branchError = "";
|
||||||
|
branchesLoading = !!repository;
|
||||||
|
if (!repository) return;
|
||||||
|
try {
|
||||||
|
const credential = await loadCredential(integrationCredentialKey(source.provider, source.accountId));
|
||||||
|
if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
|
||||||
|
const result = await listIntegrationRepositoryBranches(source.provider, source.baseUrl, credential.username, credential.password, repository);
|
||||||
|
if (generation !== branchGeneration) return;
|
||||||
|
branches = result.branches;
|
||||||
|
defaultBranch = result.defaultBranch;
|
||||||
|
targetBranch = branches.includes(defaultBranch) ? defaultBranch : "";
|
||||||
|
// Only suggest a local branch when its remote matches this exact repository.
|
||||||
|
if (localRepositoryPath) {
|
||||||
|
try {
|
||||||
|
const remotes = await listRemotes(localRepositoryPath);
|
||||||
|
const clean = (url: string) => url.trim().replace(/\.git\/?$/, "").replace(/\/$/, "");
|
||||||
|
const matches = remotes.some(remote => [repository.cloneUrl, repository.sshUrl].filter(Boolean).some(url => clean(url) === clean(remote.fetch_url) || clean(url) === clean(remote.push_url)));
|
||||||
|
if (matches) {
|
||||||
|
const localBranches = await listBranches(localRepositoryPath);
|
||||||
|
if (generation !== branchGeneration) return;
|
||||||
|
const current = localBranches.find(branch => branch.current)?.name;
|
||||||
|
if (current && branches.includes(current) && current !== targetBranch) sourceBranch = current;
|
||||||
|
}
|
||||||
|
} catch { /* Remote branch selection remains available without local metadata. */ }
|
||||||
|
}
|
||||||
|
if (generation !== branchGeneration) return;
|
||||||
|
if (!sourceBranch && branches.length === 2 && targetBranch) sourceBranch = branches.find(name => name !== targetBranch) ?? "";
|
||||||
|
} catch (cause) {
|
||||||
|
if (generation === branchGeneration) branchError = String(cause);
|
||||||
|
} finally { if (generation === branchGeneration) branchesLoading = false; }
|
||||||
|
}
|
||||||
|
let title = $state("");
|
||||||
|
let description = $state("");
|
||||||
|
let loading = $state(true);
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state("");
|
||||||
|
const mr = $derived(source.provider.startsWith("gitlab"));
|
||||||
|
const heading = $derived(de ? (mr ? "Merge Request erstellen" : "Pull Request erstellen") : (mr ? "Create merge request" : "Create pull request"));
|
||||||
|
const normalizeBranch = (branch: string) => branch.trim().replace(/^refs\/heads\//, "");
|
||||||
|
const sameBranch = $derived(!!sourceBranch.trim() && normalizeBranch(sourceBranch) === normalizeBranch(targetBranch));
|
||||||
|
const valid = $derived(!branchesLoading && !branchError && branches.includes(sourceBranch) && branches.includes(targetBranch) && repositoryId && title.trim() && sourceBranch.trim() && targetBranch.trim() && !sameBranch);
|
||||||
|
|
||||||
|
onMount(() => { dialog.showModal(); void loadRepositories(); });
|
||||||
|
async function loadRepositories() {
|
||||||
|
loading = true; error = "";
|
||||||
|
try {
|
||||||
|
repositories = (await listIntegrationRepositories(source.provider, source.baseUrl, source.accountId)).sort((a,b) => a.fullName.localeCompare(b.fullName));
|
||||||
|
if (repositories.length === 1) repositoryId = repositories[0].id;
|
||||||
|
} catch (cause) { error = String(cause); }
|
||||||
|
finally { loading = false; }
|
||||||
|
}
|
||||||
|
async function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy || !valid) return;
|
||||||
|
const repository = repositories.find(item => item.id === repositoryId);
|
||||||
|
if (!repository) return;
|
||||||
|
busy = true; error = "";
|
||||||
|
try {
|
||||||
|
const credential = await loadCredential(integrationCredentialKey(source.provider, source.accountId));
|
||||||
|
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
|
||||||
|
const request = await createIntegrationReviewRequest(source.provider, source.baseUrl, credential.username, credential.password, repository, normalizeBranch(sourceBranch), normalizeBranch(targetBranch), title.trim(), description);
|
||||||
|
onCreated(request);
|
||||||
|
} catch (cause) { error = cause instanceof Error ? cause.message : String(cause); }
|
||||||
|
finally { busy = false; }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog bind:this={dialog} aria-labelledby="create-review-title" oncancel={(event) => { event.preventDefault(); if (!busy) onClose(); }} onclick={(event) => { if (event.target === dialog && !busy) { const rect = dialog.getBoundingClientRect(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) onClose(); } }}>
|
||||||
|
<form onsubmit={submit}>
|
||||||
|
<header><div class="heading-icon"><GitPullRequest size={19} /></div><div><h2 id="create-review-title">{heading}</h2><p>{source.label}</p></div><button class="close" type="button" aria-label={de ? "Schließen" : "Close"} disabled={busy} onclick={onClose}><X size={18}/></button></header>
|
||||||
|
<div class="body">
|
||||||
|
{#if error}<div class="error" role="alert">{error}{#if !repositories.length && !loading}<button type="button" onclick={loadRepositories}>{de ? "Erneut laden" : "Retry"}</button>{/if}</div>{/if}
|
||||||
|
<div class="repository-field"><div class="field-heading"><span>Repository</span><small>{loading ? "…" : `${repositories.length} ${de ? "verfügbar" : "available"}`}</small></div>
|
||||||
|
<SelectMenu value={repositoryId} options={repositories.map(repository => ({value:repository.id,label:repository.fullName}))} disabled={loading || busy} ariaLabel="Repository" placeholder={loading ? (de ? "Repositories werden geladen …" : "Loading repositories…") : (de ? "Repository auswählen" : "Select repository")} searchable searchPlaceholder={de ? "Repositories durchsuchen …" : "Search repositories…"} emptyText={de ? "Keine passenden Repositories" : "No matching repositories"} onChange={value => repositoryId = value}/>
|
||||||
|
</div>
|
||||||
|
{#if !loading && !error && !repositories.length}<p>{de ? "Keine Repositories für diese Integration gefunden." : "No repositories found for this integration."}</p>{/if}
|
||||||
|
<div class="branches">
|
||||||
|
<div class="repository-field"><span>{de ? "Quellbranch" : "Source branch"}</span><SelectMenu value={sourceBranch} options={branchOptions.map(option => ({...option,disabled:option.value === targetBranch}))} disabled={busy || branchesLoading || !repositoryId} ariaLabel={de ? "Quellbranch" : "Source branch"} placeholder={branchesLoading ? (de ? "Lädt …" : "Loading…") : (de ? "Quellbranch auswählen" : "Select source branch")} searchable searchPlaceholder={de ? "Branches durchsuchen …" : "Search branches…"} emptyText={de ? "Keine Branches gefunden" : "No branches found"} onChange={value => sourceBranch = value}/></div>
|
||||||
|
<ArrowRight size={16}/>
|
||||||
|
<div class="repository-field"><span>{de ? "Zielbranch" : "Target branch"}</span><SelectMenu value={targetBranch} options={branchOptions.map(option => ({...option,disabled:option.value === sourceBranch}))} disabled={busy || branchesLoading || !repositoryId} ariaLabel={de ? "Zielbranch" : "Target branch"} placeholder={branchesLoading ? (de ? "Lädt …" : "Loading…") : (de ? "Zielbranch auswählen" : "Select target branch")} searchable searchPlaceholder={de ? "Branches durchsuchen …" : "Search branches…"} emptyText={de ? "Keine Branches gefunden" : "No branches found"} onChange={value => targetBranch = value}/></div>
|
||||||
|
</div>
|
||||||
|
{#if branchError}<div class="error" role="alert">{branchError}<button type="button" onclick={() => loadRepositoryBranches(repositories.find(item => item.id === repositoryId))}>{de ? "Erneut laden" : "Retry"}</button></div>{:else if repositoryId && !branchesLoading && !branches.length}<p>{de ? "Dieses Repository hat noch keine Branches." : "This repository has no branches yet."}</p>{/if}
|
||||||
|
{#if sameBranch}<p class="validation">{de ? "Quell- und Zielbranch müssen unterschiedlich sein." : "Source and target branches must be different."}</p>{/if}
|
||||||
|
<p class="hint">{de ? "Beide Branches müssen bereits in diesem Repository gepusht sein." : "Both branches must already be pushed to this repository."}</p>
|
||||||
|
<label>{de ? "Titel" : "Title"}<input bind:value={title} disabled={busy} placeholder={de ? "Was ändert sich?" : "What is changing?"} required /></label>
|
||||||
|
<label>{de ? "Beschreibung" : "Description"}<textarea bind:value={description} disabled={busy} rows="7" placeholder={de ? "Beschreibe deine Änderungen … (Markdown unterstützt)" : "Describe your changes… (Markdown supported)"}></textarea></label>
|
||||||
|
</div>
|
||||||
|
<footer><button type="button" disabled={busy} onclick={onClose}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit" disabled={busy || loading || !valid}>{#if busy}<LoaderCircle class="spin" size={15}/>{:else}<GitPullRequest size={15}/>{/if}{busy ? (de ? "Wird erstellt …" : "Creating…") : heading}</button></footer>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.repository-field{min-width:0;display:grid;gap:9px}.field-heading{display:flex;align-items:center;justify-content:space-between;font-weight:600}.field-heading small{font-size:10px;font-weight:400;color:var(--color-ink-faint)}
|
||||||
|
dialog{margin:auto;width:min(640px,calc(100vw - 40px));max-height:calc(100vh - 48px);padding:0;border:1px solid var(--color-border-subtle);border-radius:14px;background:var(--app-dialog-bg);color:var(--color-ink);box-shadow:var(--app-dialog-shadow);font-family:inherit;font-size:12px;overflow:auto}dialog::backdrop{background:#0007;backdrop-filter:blur(3px)}header{display:flex;align-items:center;gap:12px;padding:22px 26px;background:var(--app-dialog-chrome);border-bottom:1px solid var(--color-border-subtle)}h2{margin:0;font-size:16px;font-weight:650}p{margin:5px 0 0;color:var(--color-ink-muted)}.heading-icon{display:grid;place-items:center;width:38px;height:38px;border-radius:10px;background:color-mix(in srgb,var(--color-accent) 12%,transparent);color:var(--color-accent)}button,input,textarea{font:inherit}button{display:inline-flex;justify-content:center;align-items:center;gap:8px;border:1px solid var(--color-border-subtle);border-radius:7px;padding:9px 13px;background:var(--color-surface);color:var(--color-ink);cursor:pointer}button:disabled{opacity:.5;cursor:default}.close{margin-left:auto;border:0;padding:6px}.body{display:grid;gap:20px;padding:24px 26px}label{display:grid;gap:8px;font-weight:600;min-width:0}input,textarea{box-sizing:border-box;width:100%;padding:10px 11px;border:1px solid var(--color-border-subtle);border-radius:7px;background:var(--app-bg);color:var(--color-ink);font-weight:400}input:focus,textarea:focus{outline:2px solid var(--color-accent);outline-offset:1px}textarea{resize:vertical;min-height:110px;line-height:1.6}.branches{padding:16px;background:color-mix(in srgb,var(--color-accent) 3%,var(--app-bg));border:1px solid var(--color-border-subtle);display:grid;grid-template-columns:minmax(0,1fr) 16px minmax(0,1fr);gap:12px;align-items:end}.branches>:global(svg){margin-bottom:12px;color:var(--color-accent)}.hint{margin-top:-8px;font-size:11px;line-height:1.5}.error,.validation{color:var(--color-danger,#e76767);line-height:1.5}.error{padding:12px;border-radius:7px;background:color-mix(in srgb,var(--color-danger,#e76767) 9%,transparent);overflow-wrap:anywhere}.error button{margin-left:8px}footer{display:flex;justify-content:flex-end;gap:9px;padding:16px 26px;background:var(--app-dialog-chrome);border-top:1px solid var(--color-border-subtle)}.primary{background:var(--color-accent);border-color:var(--color-accent);color:white}.primary:enabled:hover{filter:brightness(1.08)}:global(.spin){animation:rotate 1s linear infinite}@keyframes rotate{to{transform:rotate(360deg)}}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<script context="module" lang="ts">
|
||||||
|
const boardCache = new Map<string, IntegrationBoard>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
import { Columns3, ExternalLink, Link2, RefreshCw, Search, X } from "@lucide/svelte";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
import IssueLabels from "./IssueLabels.svelte";
|
||||||
|
import IssueAssignees from "./IssueAssignees.svelte";
|
||||||
|
import { issueTone } from "../issuePresentation";
|
||||||
|
import { getIntegrationBoard, moveIntegrationBoardCard, listIntegrationBoards, openInBrowser } from "../git";
|
||||||
|
import { integrationCredentialKey } from "../integrations";
|
||||||
|
import type { GitIntegrationSource, IntegrationBoard, IntegrationBoardCard, IntegrationBoardReference, StoredCredential } from "../types";
|
||||||
|
|
||||||
|
export let source: GitIntegrationSource;
|
||||||
|
export let language: "de" | "en" = "en";
|
||||||
|
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||||
|
const preferenceKey = `gitty.board.v1:${JSON.stringify([source.id, source.baseUrl])}`;
|
||||||
|
const directoryKey = `${preferenceKey}:directory`;
|
||||||
|
let savedBoards: IntegrationBoardReference[] = [];
|
||||||
|
let discovering = false;
|
||||||
|
let discoveryWarnings: string[] = [];
|
||||||
|
let destroyed = false;
|
||||||
|
let connectOpen = true;
|
||||||
|
let boardUrl = "";
|
||||||
|
let activeUrl = "";
|
||||||
|
let board: IntegrationBoard | null = null;
|
||||||
|
let loading = false;
|
||||||
|
let moving = false;
|
||||||
|
let dragged: { cardId: string; columnId: string } | null = null;
|
||||||
|
let dropColumn = "";
|
||||||
|
let moveMessage = "";
|
||||||
|
$: canMove = board?.columns.some(column => !!column.moveTarget) ?? false;
|
||||||
|
let error = "";
|
||||||
|
let query = "";
|
||||||
|
let repositoryFilter = "";
|
||||||
|
let selected: IntegrationBoardCard | null = null;
|
||||||
|
let selectedColumnId = "";
|
||||||
|
let generation = 0;
|
||||||
|
$: de = language === "de";
|
||||||
|
$: normalizedQuery = query.trim().toLocaleLowerCase();
|
||||||
|
$: repositoryOptions = [{ value: "", label: de ? "Alle Repositories" : "All repositories" }, ...[...new Set(board?.columns.flatMap(column => column.cards.map(card => card.repository)).filter(Boolean) ?? [])].sort().map(value => ({ value, label: value }))];
|
||||||
|
$: columns = board?.columns.map(column => ({ ...column, cards: column.cards.filter(card => (!repositoryFilter || card.repository === repositoryFilter) &&
|
||||||
|
`${card.title} ${card.number} ${card.repository} ${card.labels.join(" ")} ${card.assignees.join(" ")} ${card.lane}`.toLocaleLowerCase().includes(normalizedQuery)) })) ?? [];
|
||||||
|
$: placeholder = source.provider === "github" ? "https://github.com/orgs/team/projects/1/views/1"
|
||||||
|
: source.provider.startsWith("gitlab") ? `${source.baseUrl}/group/project/-/boards/1`
|
||||||
|
: source.provider === "gitea" ? `${source.baseUrl}/owner/repo/projects/1`
|
||||||
|
: `${source.baseUrl}/Project/_boards/board/t/Team/Stories`;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
try { boardUrl = localStorage.getItem(preferenceKey) ?? ""; } catch { /* Optional preference. */ }
|
||||||
|
try {
|
||||||
|
const saved: unknown = JSON.parse(localStorage.getItem(directoryKey) ?? "[]");
|
||||||
|
if (Array.isArray(saved)) savedBoards = saved.filter(item => item && typeof item.title === "string" && typeof item.webUrl === "string" && typeof item.scope === "string" && validBoardUrl(item.webUrl));
|
||||||
|
} catch { /* Invalid optional preference. */ }
|
||||||
|
if (boardUrl && validBoardUrl(boardUrl)) rememberBoard({ title: boardUrl, webUrl: boardUrl, scope: "" });
|
||||||
|
void discoverBoards();
|
||||||
|
if (boardUrl) { connectOpen = false; void loadBoard(boardUrl); }
|
||||||
|
const timer = window.setInterval(() => { if (activeUrl && !loading && !moving) void loadBoard(activeUrl); }, 180_000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
});
|
||||||
|
onDestroy(() => { generation++; destroyed = true; });
|
||||||
|
|
||||||
|
function validBoardUrl(url: string) {
|
||||||
|
try { const parsed = new URL(url); return parsed.origin === new URL(source.baseUrl).origin && !parsed.username && !parsed.password; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberBoard(item: IntegrationBoardReference) {
|
||||||
|
if (!validBoardUrl(item.webUrl)) return;
|
||||||
|
savedBoards = [...savedBoards.filter(board => board.webUrl !== item.webUrl), item];
|
||||||
|
try { localStorage.setItem(directoryKey, JSON.stringify(savedBoards)); } catch { /* Optional preference. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function boardError(message: string) {
|
||||||
|
if (de && message.includes("publishes no Projects/Columns API")) return "Dieser Gitea-Server bietet keine Projects-/Columns-API an. Boards sind im Browser verfügbar, können aber mit dem API-Token weder automatisch gefunden noch importiert werden. Bitte das Original-Board öffnen.";
|
||||||
|
if (de && message.includes("Gitea returned 404")) return "Gitea meldet 404. Bitte Board-Link und Token-Zugriff prüfen. Möglicherweise unterstützt der Server die Projects-/Columns-API nicht.";
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverBoards() {
|
||||||
|
if (discovering) return;
|
||||||
|
discovering = true;
|
||||||
|
discoveryWarnings = [];
|
||||||
|
try {
|
||||||
|
const credential = await timeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000);
|
||||||
|
if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
|
||||||
|
const directory = await timeout(listIntegrationBoards(source.provider, source.baseUrl, credential.username, credential.password), 75_000);
|
||||||
|
if (destroyed) return;
|
||||||
|
for (const item of directory.boards) rememberBoard(item);
|
||||||
|
discoveryWarnings = directory.warnings.map(boardError);
|
||||||
|
} catch (cause) {
|
||||||
|
if (!destroyed) discoveryWarnings = [boardError(String(cause))];
|
||||||
|
} finally { if (!destroyed) discovering = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBoard(url: string, collapseEditor = false) {
|
||||||
|
if (moving) return;
|
||||||
|
const requestedUrl = url.trim();
|
||||||
|
if (!requestedUrl) return;
|
||||||
|
const requestGeneration = ++generation;
|
||||||
|
if (requestedUrl !== activeUrl) {
|
||||||
|
selected = null;
|
||||||
|
repositoryFilter = "";
|
||||||
|
board = boardCache.get(`${preferenceKey}:${requestedUrl}`) ?? null;
|
||||||
|
}
|
||||||
|
activeUrl = requestedUrl;
|
||||||
|
loading = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
const parsed = new URL(requestedUrl);
|
||||||
|
if (parsed.origin !== new URL(source.baseUrl).origin || parsed.username || parsed.password) {
|
||||||
|
throw new Error(de ? "Der Board-Link muss zur ausgewählten Integration gehören." : "The board link must belong to the selected integration.");
|
||||||
|
}
|
||||||
|
if (!savedBoards.some(item => item.webUrl === requestedUrl)) rememberBoard({ title: requestedUrl, webUrl: requestedUrl, scope: "" });
|
||||||
|
const credential = await timeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000);
|
||||||
|
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token stored for this integration.");
|
||||||
|
const result = await timeout(getIntegrationBoard(source.provider, source.baseUrl, credential.username, credential.password, requestedUrl), 70_000);
|
||||||
|
if (requestGeneration !== generation) return;
|
||||||
|
board = result;
|
||||||
|
rememberBoard({ title: result.title, webUrl: requestedUrl, scope: savedBoards.find(item => item.webUrl === requestedUrl)?.scope ?? "" });
|
||||||
|
if (collapseEditor && boardUrl.trim() === requestedUrl) connectOpen = false;
|
||||||
|
boardCache.set(`${preferenceKey}:${requestedUrl}`, result);
|
||||||
|
selected = selected ? result.columns.flatMap(column => column.cards).find(card => card.id === selected!.id) ?? null : null;
|
||||||
|
try { localStorage.setItem(preferenceKey, requestedUrl); } catch { /* Keep the board in memory. */ }
|
||||||
|
} catch (cause) {
|
||||||
|
if (requestGeneration === generation) error = boardError(cause instanceof Error ? cause.message : String(cause));
|
||||||
|
} finally {
|
||||||
|
if (requestGeneration === generation) loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveCard(cardId: string, from: string, to: string) {
|
||||||
|
if (moving || loading || from === to || !board?.columns.find(column => column.id === to)?.moveTarget) return;
|
||||||
|
const previousBoard = board;
|
||||||
|
const card = previousBoard.columns.find(column => column.id === from)?.cards.find(card => card.id === cardId);
|
||||||
|
if (!card) return;
|
||||||
|
const previousSelectedColumn = selected?.id === cardId ? selectedColumnId : from;
|
||||||
|
const url = activeUrl;
|
||||||
|
const requestGeneration = generation;
|
||||||
|
// Keep the original arrays intact for an exact rollback, including card order.
|
||||||
|
board = {
|
||||||
|
...previousBoard,
|
||||||
|
columns: previousBoard.columns.map(column => {
|
||||||
|
const remove = column.id === from || (source.provider.startsWith("gitlab") && (to === "open" || to === "closed"));
|
||||||
|
const cards = remove ? column.cards.filter(item => item.id !== cardId) : column.cards;
|
||||||
|
return { ...column, cards: column.id === to && !cards.some(item => item.id === cardId) ? [...cards, card] : cards };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
if (selected?.id === cardId) selectedColumnId = to;
|
||||||
|
moving = true;
|
||||||
|
dragged = null;
|
||||||
|
dropColumn = "";
|
||||||
|
error = "";
|
||||||
|
moveMessage = de ? "Verschiebung wird gespeichert …" : "Saving move …";
|
||||||
|
try {
|
||||||
|
const credential = await timeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000);
|
||||||
|
if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
|
||||||
|
await moveIntegrationBoardCard(source.provider, source.baseUrl, credential.username, credential.password, url, cardId, from, to);
|
||||||
|
// A successful mutation invalidates the cached snapshot, even if reloading fails.
|
||||||
|
boardCache.delete(`${preferenceKey}:${url}`);
|
||||||
|
if (destroyed || generation !== requestGeneration) return;
|
||||||
|
boardCache.set(`${preferenceKey}:${url}`, board!);
|
||||||
|
moveMessage = de ? "Verschoben. Board wird aktualisiert …" : "Moved. Refreshing board …";
|
||||||
|
moving = false;
|
||||||
|
if (selected?.id === cardId) selectedColumnId = to;
|
||||||
|
await loadBoard(url);
|
||||||
|
if (!destroyed) moveMessage = error ? (de ? "Verschoben; der angezeigte Stand konnte noch nicht aktualisiert werden." : "Moved; the displayed board could not be refreshed yet.") : (de ? "Karte verschoben." : "Card moved.");
|
||||||
|
} catch (cause) {
|
||||||
|
if (!destroyed && generation === requestGeneration) {
|
||||||
|
board = previousBoard;
|
||||||
|
if (selected?.id === cardId) selectedColumnId = previousSelectedColumn;
|
||||||
|
error = `${de ? "Verschieben nicht bestätigt. Bitte aktualisieren und Schreibrechte prüfen." : "Move not confirmed. Refresh and check write access."} ${String(cause)}`;
|
||||||
|
moveMessage = "";
|
||||||
|
}
|
||||||
|
} finally { if (!destroyed) moving = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function timeout<T>(promise: Promise<T>, duration: number): Promise<T> {
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
try {
|
||||||
|
return await Promise.race([promise, new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error(de ? "Das Board hat nicht rechtzeitig geantwortet." : "The board did not respond in time.")), duration);
|
||||||
|
})]);
|
||||||
|
} finally { clearTimeout(timer!); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openOriginal(url: string) {
|
||||||
|
try { await openInBrowser(url); } catch (cause) { error = String(cause); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function notice(text: string) {
|
||||||
|
if (!de) return text;
|
||||||
|
if (text.startsWith("Team scope")) return "Team-Zuordnung, Spalten, geteilte Spalten und Swimlane-Namen werden übernommen. Temporäre Browser-Filter und das Alterslimit für abgeschlossene Karten werden nicht angewendet.";
|
||||||
|
if (text.startsWith("Saved board scope")) return "Die gespeicherte Board-Konfiguration wird angewendet. Temporäre Suchfilter aus dem Browser werden nicht übernommen.";
|
||||||
|
if (text.startsWith("Archived cards")) return "Archivierte Karten werden ausgeblendet. Die Spalten entsprechen den Auswahloptionen des Board-Feldes.";
|
||||||
|
if (text.startsWith("The saved GitHub view filter")) return `Der gespeicherte GitHub-Ansichtsfilter wird nicht angewendet. Alle nicht archivierten Projektkarten erscheinen in ihren ursprünglichen Spalten. ${text.split(": ")[1]?.split(". All non-archived")[0] ?? ""}`;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={event => { if (event.key === "Escape") selected = null; }} />
|
||||||
|
|
||||||
|
<section class="board-view" aria-label={de ? "Kanban-Board" : "Kanban board"}>
|
||||||
|
<div class="board-directory">
|
||||||
|
<SelectMenu class="board-select" value={activeUrl} options={[{ value: "", label: de ? "Board auswählen" : "Select board" }, ...savedBoards.map(item => ({ value: item.webUrl, label: `${item.scope ? `${item.scope} · ` : ""}${item.title}` }))]} ariaLabel={de ? "Board auswählen" : "Select board"} onChange={value => { if (value && !moving) { boardUrl = value; void loadBoard(value, true); } }} />
|
||||||
|
<button class="workspace-button" disabled={discovering} onclick={discoverBoards}><RefreshCw size={14} />{de ? "Boards suchen" : "Find boards"}</button>
|
||||||
|
<small aria-live="polite">{discovering ? (de ? "Boards werden im Hintergrund gesucht …" : "Finding boards in background …") : `${savedBoards.length} Boards`}</small>
|
||||||
|
{#if !board && boardUrl && validBoardUrl(boardUrl)}<button class="workspace-button" onclick={() => openOriginal(boardUrl)}><ExternalLink size={14} />{de ? "Original öffnen" : "Open original"}</button>{/if}
|
||||||
|
</div>
|
||||||
|
{#if discoveryWarnings.length}<details class="board-discovery-notice" open={!savedBoards.length}><summary>{de ? "Hinweise zur Board-Suche" : "Board discovery notices"} ({discoveryWarnings.length})</summary>{#each discoveryWarnings as warning}<p>{warning}</p>{/each}</details>{/if}
|
||||||
|
{#if board}
|
||||||
|
<div class="board-header">
|
||||||
|
<div class="board-title"><h2>{board.title}</h2><small>{source.label} <span>·</span> {canMove ? (de ? "Karten zwischen Spalten verschieben" : "Move cards between columns") : (de ? "Lesende Ansicht" : "Read-only view")}{#if loading} <span>·</span> {de ? "Aktualisierung läuft …" : "Updating …"}{/if}</small></div>
|
||||||
|
<div class="board-tools">
|
||||||
|
<SelectMenu class="repository-select" value={repositoryFilter} options={repositoryOptions} ariaLabel={de ? "Repository filtern" : "Filter repository"} onChange={value => { repositoryFilter = value; selected = null; }} />
|
||||||
|
<label class="workspace-search"><Search size={15} /><input bind:value={query} aria-label={de ? "Board durchsuchen" : "Search board"} placeholder={de ? "Karten suchen …" : "Search cards …"} /></label>
|
||||||
|
<button class="workspace-button" class:active={connectOpen} aria-expanded={connectOpen} onclick={() => { connectOpen = !connectOpen; }}><Link2 size={14} />{de ? "Board wechseln" : "Change board"}</button>
|
||||||
|
<button class="workspace-button icon-button" disabled={loading || moving} onclick={() => loadBoard(activeUrl)} aria-label={de ? "Board aktualisieren" : "Refresh board"}><RefreshCw size={15} /></button>
|
||||||
|
<button class="workspace-button" onclick={() => openOriginal(board!.webUrl)}><ExternalLink size={14} />{de ? "Original öffnen" : "Open original"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if connectOpen || !board}
|
||||||
|
<form class="board-connect" onsubmit={event => { event.preventDefault(); void loadBoard(boardUrl, true); }}>
|
||||||
|
<label for="native-board-url">{de ? "Original-Board" : "Original board"}</label>
|
||||||
|
<div class="board-connect-fields"><input id="native-board-url" type="url" bind:value={boardUrl} {placeholder} required aria-label={de ? "Board-Link" : "Board URL"} /><button class="workspace-button" type="submit" disabled={moving || !boardUrl.trim()}>{de ? "Board übernehmen" : "Load board"}</button></div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{#if moveMessage}<p class="board-notice" role="status">{moveMessage}</p>{/if}
|
||||||
|
{#if error}<div class="workspace-notice error" role="alert">{error}{#if board}<p>{de ? "Der zuletzt geladene Stand bleibt sichtbar." : "The last loaded board remains visible."}</p>{/if}</div>{/if}
|
||||||
|
{#if board}
|
||||||
|
{#if board.notice}<p class="board-notice">{notice(board.notice)}</p>{/if}
|
||||||
|
<div class="board-body">
|
||||||
|
<div class="board-columns" aria-label={de ? "Board-Spalten" : "Board columns"}>
|
||||||
|
{#each columns as column (column.id)}
|
||||||
|
<section class="board-column" class:drop-target={dropColumn === column.id} aria-label={column.title}
|
||||||
|
ondragover={event => { if (dragged && column.moveTarget && !moving && !loading && dragged.columnId !== column.id) { event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; dropColumn = column.id; } }}
|
||||||
|
ondragleave={event => { if (!(event.relatedTarget instanceof Node) || !event.currentTarget.contains(event.relatedTarget)) dropColumn = ""; }}
|
||||||
|
ondrop={event => { event.preventDefault(); if (dragged) void moveCard(dragged.cardId, dragged.columnId, column.id); dropColumn = ""; }}>
|
||||||
|
<header><h3><span class="column-dot" data-tone={issueTone(column.title)}></span>{column.title}</h3><span class="column-count">{column.cards.length}{#if column.limit}<span class="wip-limit"> · WIP {column.limit}</span>{/if}</span></header>
|
||||||
|
<div class="column-cards">
|
||||||
|
{#each column.cards as card (`${column.id}:${card.id}`)}
|
||||||
|
<button class="board-card" draggable={!!column.moveTarget && !moving && !loading}
|
||||||
|
ondragstart={event => { if (moving || loading || !column.moveTarget) { event.preventDefault(); return; } dragged = { cardId: card.id, columnId: column.id }; if (event.dataTransfer) { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", card.id); } }}
|
||||||
|
ondragend={() => { dragged = null; dropColumn = ""; }} class:chosen={selected?.id === card.id} aria-pressed={selected?.id === card.id} onclick={() => { selected = card; selectedColumnId = column.id; }}>
|
||||||
|
{#if card.repository || card.number}<small class="card-reference">{card.repository}<span class="issue-number">{card.number ? `#${card.number}` : ""}</span></small>{/if}
|
||||||
|
<strong>{card.title}</strong>
|
||||||
|
{#if card.lane}<span class="lane">{card.lane}</span>{/if}
|
||||||
|
<IssueLabels labels={card.labels} />
|
||||||
|
{#if card.assignees.length}<IssueAssignees names={card.assignees} />{/if}
|
||||||
|
</button>
|
||||||
|
{:else}<p class="column-empty">{de ? "Keine Karten" : "No cards"}</p>{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else}<p class="column-empty">{de ? "Dieses Board hat keine Spalten." : "This board has no columns."}</p>{/each}
|
||||||
|
</div>
|
||||||
|
{#if selected}
|
||||||
|
<aside class="issue-inspector board-inspector" aria-label={de ? "Kartendetails" : "Card details"}>
|
||||||
|
<div class="inspector-top"><span>{selected.repository} <span class="issue-number">{selected.number ? `#${selected.number}` : ""}</span></span><button class="workspace-button close-button" onclick={() => { selected = null; }} aria-label={de ? "Kartendetails schließen" : "Close card details"}><X size={16} /></button></div>
|
||||||
|
<h2>{selected.title}</h2>
|
||||||
|
{#if canMove}
|
||||||
|
{@const currentColumn = board.columns.find(column => column.id === selectedColumnId && column.cards.some(card => card.id === selected?.id))}
|
||||||
|
{#if currentColumn?.moveTarget}
|
||||||
|
<div class="card-move-control"><small>{de ? "In Spalte verschieben" : "Move to column"}</small><SelectMenu value={currentColumn.id} options={board.columns.filter(column => column.moveTarget).map(column => ({ value: column.id, label: column.title }))} ariaLabel={de ? "Karte verschieben" : "Move card"} onChange={value => { if (selected && currentColumn) void moveCard(selected.id, currentColumn.id, value); }} /></div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
<div class="inspector-properties"><IssueAssignees names={selected.assignees} />{#if selected.lane}<span class="lane">{selected.lane}</span>{/if}</div>
|
||||||
|
{#if selected.labels.length}<div class="inspector-labels"><small>Labels</small><IssueLabels labels={selected.labels} /></div>{/if}
|
||||||
|
<div class="inspector-description"><h3>{de ? "Beschreibung" : "Description"}</h3><div class="description">{selected.description || (de ? "Weitere Details im Original-Board." : "More details in the original board.")}</div></div>
|
||||||
|
{#if selected.webUrl}<button class="workspace-button inspector-open" onclick={() => openOriginal(selected!.webUrl)}><ExternalLink size={14} />{de ? "Karte öffnen" : "Open card"}</button>{/if}
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="workspace-empty"><span class="empty-symbol"><Columns3 size={26} /></span><h2>{loading ? (de ? "Board wird im Hintergrund geladen" : "Loading board in background") : (de ? "Dein vorhandenes Board übernehmen" : "Load your existing board")}</h2><p>{de ? "Wähle oben ein gefundenes Board aus oder füge einen Board-Link ein. Spalten und Karten werden aus der Integration gelesen; der Link wird für diese Integration gespeichert." : "Select a discovered board above or paste a board link. Columns and cards come from the integration; the link is saved for this integration."}</p>
|
||||||
|
{#if source.provider === "gitea"}<p>{de ? "Gitea benötigt eine Serverversion mit Projects-/Columns-API." : "Gitea requires a server version with the Projects/Columns API."}</p>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let names: string[] = [];
|
||||||
|
export let emptyLabel = "—";
|
||||||
|
$: initials = names[0]?.trim().split(/[\s._-]+/).filter(Boolean).slice(0, 2).map(part => part[0]).join("").toLocaleUpperCase() ?? "";
|
||||||
|
</script>
|
||||||
|
<span class="issue-person" title={names.join(", ")}>
|
||||||
|
{#if names.length}<span class="issue-avatar" aria-hidden="true">{initials}</span><span class="person-name">{names[0]}{names.length > 1 ? ` +${names.length - 1}` : ""}</span>{:else}<span class="unassigned">{emptyLabel}</span>{/if}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
<script context="module" lang="ts">
|
||||||
|
const cache = new Map<string, { issues: IntegrationIssue[]; nextCursor: string | null }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy, tick } from "svelte";
|
||||||
|
import { fly } from "svelte/transition";
|
||||||
|
import { cubicOut } from "svelte/easing";
|
||||||
|
import { ChevronRight, CircleDot, FolderGit2, ExternalLink, RefreshCw, Search, Settings2, X, XCircle } from "@lucide/svelte";
|
||||||
|
import "../issueWorkspace.css";
|
||||||
|
import IssueComments from "./IssueComments.svelte";
|
||||||
|
import IssueLabels from "./IssueLabels.svelte";
|
||||||
|
import IssueAssignees from "./IssueAssignees.svelte";
|
||||||
|
import { issueTone, issueStateLabel } from "../issuePresentation";
|
||||||
|
import IntegrationBoardView from "./IntegrationBoardView.svelte";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
import { listIntegrationIssues, closeIntegrationIssue, openInBrowser } from "../git";
|
||||||
|
import { configuredIntegrationSources, integrationCredentialKey } from "../integrations";
|
||||||
|
import type { GitIntegrationSettings, IntegrationIssue, StoredCredential } from "../types";
|
||||||
|
|
||||||
|
export let language: "de" | "en" = "en";
|
||||||
|
export let integrations: GitIntegrationSettings;
|
||||||
|
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||||
|
export let onOpenSettings: () => void;
|
||||||
|
|
||||||
|
let viewMode: "list" | "board" = "list";
|
||||||
|
let sourceId = "";
|
||||||
|
let issues: IntegrationIssue[] = [];
|
||||||
|
let nextCursor: string | null = null;
|
||||||
|
let query = "";
|
||||||
|
let stateFilter = "";
|
||||||
|
let repositoryFilter = "";
|
||||||
|
let collapsedRepositories = new Set<string>();
|
||||||
|
let selectedId = "";
|
||||||
|
let detailTrigger: HTMLElement | null = null;
|
||||||
|
let detailPanel: HTMLElement | undefined;
|
||||||
|
let reduceMotion = false;
|
||||||
|
let loading = false;
|
||||||
|
let closingId = "";
|
||||||
|
let closingKey = "";
|
||||||
|
let actionError = "";
|
||||||
|
let closedStates = new Map<string, string>();
|
||||||
|
let error = "";
|
||||||
|
let mounted = false;
|
||||||
|
let generation = 0;
|
||||||
|
let lastKey = "";
|
||||||
|
$: de = language === "de";
|
||||||
|
$: sources = configuredIntegrationSources(integrations);
|
||||||
|
$: source = sources.find(item => item.id === sourceId) ?? sources[0];
|
||||||
|
$: sourceKey = source ? JSON.stringify([source.id, source.provider, source.accountId, source.baseUrl]) : "";
|
||||||
|
$: if (mounted && sourceKey !== lastKey) {
|
||||||
|
lastKey = sourceKey;
|
||||||
|
generation++;
|
||||||
|
loading = false;
|
||||||
|
const saved = cache.get(sourceKey);
|
||||||
|
issues = saved?.issues ?? [];
|
||||||
|
nextCursor = saved?.nextCursor ?? null;
|
||||||
|
selectedId = "";
|
||||||
|
stateFilter = "";
|
||||||
|
repositoryFilter = "";
|
||||||
|
error = "";
|
||||||
|
if (source) void loadIssues();
|
||||||
|
}
|
||||||
|
$: normalizedQuery = query.trim().toLocaleLowerCase();
|
||||||
|
$: filtered = issues.filter(issue => (!repositoryFilter || issue.repositoryName === repositoryFilter) && (!stateFilter || issue.state === stateFilter) &&
|
||||||
|
`${issue.title} ${issue.number} ${issue.repositoryName} ${issue.author} ${issue.labels.join(" ")} ${issue.assignees.join(" ")}`.toLocaleLowerCase().includes(normalizedQuery));
|
||||||
|
$: repositoryOptions = [{ value: "", label: de ? "Alle Repositories" : "All repositories" }, ...[...new Set(issues.map(issue => issue.repositoryName))].sort().map(value => ({ value, label: value || (de ? "Ohne Repository" : "No repository") }))];
|
||||||
|
$: groups = [...new Set(filtered.map(issue => issue.repositoryName))].sort().map(repository => ({ repository, issues: filtered.filter(issue => issue.repositoryName === repository) }));
|
||||||
|
$: stateOptions = [{ value: "", label: de ? "Alle Status" : "All states" }, ...[...new Set(issues.map(issue => issue.state))].sort().map(value => ({ value, label: value }))];
|
||||||
|
$: selected = issues.find(issue => issue.id === selectedId);
|
||||||
|
$: description = selected ? plainDescription(selected) : "";
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
mounted = true;
|
||||||
|
reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
const closeDetailsOutside = (event: PointerEvent) => {
|
||||||
|
if (selectedId && detailPanel && event.target instanceof Node && !detailPanel.contains(event.target)) {
|
||||||
|
selectedId = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("pointerdown", closeDetailsOutside, true);
|
||||||
|
const timer = window.setInterval(() => { if (!loading && !closingId && source) void loadIssues(); }, 180_000);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
window.removeEventListener("pointerdown", closeDetailsOutside, true);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
onDestroy(() => { generation++; });
|
||||||
|
|
||||||
|
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
try {
|
||||||
|
return await Promise.race([promise, new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error(de ? "Zeitüberschreitung beim Laden." : "Loading timed out.")), ms);
|
||||||
|
})]);
|
||||||
|
} finally { clearTimeout(timer!); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadIssues(more = false) {
|
||||||
|
if (!source || loading || (closingId && closingKey === sourceKey)) return;
|
||||||
|
const current = source;
|
||||||
|
const key = sourceKey;
|
||||||
|
const requestGeneration = ++generation;
|
||||||
|
loading = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
const credential = await withTimeout(loadCredential(integrationCredentialKey(current.provider, current.accountId)), 15_000);
|
||||||
|
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token stored for this integration.");
|
||||||
|
const page = await withTimeout(listIntegrationIssues(current.provider, current.baseUrl, credential.username, credential.password, more ? nextCursor : null), 40_000);
|
||||||
|
if (generation !== requestGeneration) return;
|
||||||
|
issues = [...new Map([...(more ? issues : []), ...page.issues].map(issue => [issue.id, issue])).values()];
|
||||||
|
nextCursor = page.nextCursor;
|
||||||
|
cache.set(key, { issues, nextCursor });
|
||||||
|
} catch (cause) {
|
||||||
|
if (generation === requestGeneration) error = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
} finally {
|
||||||
|
if (generation === requestGeneration) loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeDetails() {
|
||||||
|
selectedId = "";
|
||||||
|
await tick();
|
||||||
|
detailTrigger?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRepository(repository: string) {
|
||||||
|
const key = JSON.stringify([sourceKey, repository]);
|
||||||
|
const next = new Set(collapsedRepositories);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
collapsedRepositories = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainDescription(issue: IntegrationIssue) {
|
||||||
|
if (!issue.descriptionHtml) return issue.description;
|
||||||
|
// Azure returns HTML. Display inert text, never inject provider HTML into the app.
|
||||||
|
const document = new DOMParser().parseFromString(issue.description, "text/html");
|
||||||
|
document.querySelectorAll("script, style").forEach(element => element.remove());
|
||||||
|
document.querySelectorAll("br").forEach(element => element.replaceWith("\n"));
|
||||||
|
document.querySelectorAll("p, div, li").forEach(element => element.append("\n"));
|
||||||
|
return document.body.textContent?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeIssue() {
|
||||||
|
if (!selected || !source || closingId || loading) return;
|
||||||
|
const issue = selected;
|
||||||
|
const current = source;
|
||||||
|
const key = sourceKey;
|
||||||
|
closingId = issue.id;
|
||||||
|
closingKey = key;
|
||||||
|
actionError = "";
|
||||||
|
try {
|
||||||
|
const credential = await withTimeout(loadCredential(integrationCredentialKey(current.provider, current.accountId)), 15_000);
|
||||||
|
if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
|
||||||
|
const state = await closeIntegrationIssue(current.provider, current.baseUrl, credential.username, credential.password, issue.repositoryName, issue.number);
|
||||||
|
const saved = cache.get(key);
|
||||||
|
if (saved) cache.set(key, { ...saved, issues: saved.issues.map(item => item.id === issue.id ? { ...item, state } : item) });
|
||||||
|
closedStates = new Map(closedStates).set(`${key}:${issue.id}`, state);
|
||||||
|
if (sourceKey === key) issues = issues.map(item => item.id === issue.id ? { ...item, state } : item);
|
||||||
|
} catch (cause) {
|
||||||
|
if (sourceKey === key && selectedId === issue.id) actionError = `${de ? "Issue konnte nicht geschlossen werden. Bitte Schreibrechte prüfen." : "Could not close issue. Check write permissions."} ${String(cause)}`;
|
||||||
|
} finally { closingId = ""; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openIssue(url: string) {
|
||||||
|
try { await openInBrowser(url); }
|
||||||
|
catch (cause) { error = String(cause); }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={event => { if (event.key === "Escape" && selectedId) void closeDetails(); }} />
|
||||||
|
|
||||||
|
<section class="issue-center" aria-label="Issues">
|
||||||
|
<header class="workspace-heading">
|
||||||
|
<div class="workspace-title"><span class="workspace-symbol"><CircleDot size={21} strokeWidth={1.7} /></span><div><h1>Issues</h1><p>{de ? "Arbeit im Blick. Über alle Integrationen." : "Work in focus. Across your integrations."}</p></div></div>
|
||||||
|
<button class="workspace-button" onclick={onOpenSettings} title={de ? "Integrationen konfigurieren" : "Configure integrations"}><Settings2 size={14} />{de ? "Integrationen" : "Integrations"}</button>
|
||||||
|
</header>
|
||||||
|
{#if !sources.length}
|
||||||
|
<div class="workspace-empty"><span class="empty-symbol"><CircleDot size={26} /></span><h2>{de ? "Noch keine Integration eingerichtet" : "No integrations configured"}</h2><p>{de ? "Verbinde einen Anbieter, um Issues zu sehen." : "Connect a provider to view issues."}</p><button class="workspace-button" onclick={onOpenSettings}>{de ? "Integration einrichten" : "Set up integration"}</button></div>
|
||||||
|
{:else}
|
||||||
|
<div class="workspace-navigation">
|
||||||
|
<div class="view-toggle" role="group" aria-label={de ? "Issue-Ansicht" : "Issue view"}>
|
||||||
|
<button class:active={viewMode === "list"} aria-pressed={viewMode === "list"} onclick={() => { viewMode = "list"; }}>{de ? "Liste" : "List"}</button>
|
||||||
|
<button class:active={viewMode === "board"} aria-pressed={viewMode === "board"} onclick={() => { viewMode = "board"; }}>Board</button>
|
||||||
|
</div>
|
||||||
|
<SelectMenu class="source-select" value={source?.id ?? ""} options={sources.map(item => ({ value: item.id, label: item.label }))} ariaLabel={de ? "Integration auswählen" : "Select integration"} onChange={value => { sourceId = value; }} />
|
||||||
|
</div>
|
||||||
|
{#if viewMode === "board" && source}
|
||||||
|
{#key sourceKey}<IntegrationBoardView {source} {language} {loadCredential} />{/key}
|
||||||
|
{:else}
|
||||||
|
<div class="issue-content">
|
||||||
|
<div class="issue-main">
|
||||||
|
<div class="issue-toolbar">
|
||||||
|
<label class="workspace-search"><Search size={15} /><input bind:value={query} placeholder={de ? "Issues durchsuchen …" : "Search issues …"} aria-label={de ? "Issues durchsuchen" : "Search issues"} /></label>
|
||||||
|
<SelectMenu class="repository-select" value={repositoryFilter} options={repositoryOptions} ariaLabel={de ? "Repository filtern" : "Filter repository"} onChange={value => { repositoryFilter = value; selectedId = ""; }} />
|
||||||
|
<SelectMenu class="state-select" value={stateFilter} options={stateOptions} ariaLabel={de ? "Status filtern" : "Filter state"} onChange={value => { stateFilter = value; }} />
|
||||||
|
<button class="workspace-button icon-button" disabled={loading} onclick={() => loadIssues()} title={de ? "Aktualisieren" : "Refresh"} aria-label={de ? "Aktualisieren" : "Refresh"}><RefreshCw size={15} /></button>
|
||||||
|
</div>
|
||||||
|
{#if error}<div class="workspace-notice error" role="alert">{error}<p>{de ? "Bitte Token und Leserechte für Issues bzw. Azure Boards prüfen." : "Check the token and read permissions for issues or Azure Boards."}</p></div>{/if}
|
||||||
|
<div class="issue-list">
|
||||||
|
{#if filtered.length}<div class="issue-table-heading"><span>Issue</span><span>Status</span><span>{de ? "Zugewiesen" : "Assignee"}</span></div>{/if}
|
||||||
|
{#each groups as group (group.repository)}
|
||||||
|
{@const collapsed = collapsedRepositories.has(JSON.stringify([sourceKey, group.repository]))}
|
||||||
|
<button class="repository-group-heading" aria-expanded={!collapsed} onclick={() => toggleRepository(group.repository)}>
|
||||||
|
<ChevronRight size={14} class={collapsed ? "repository-chevron" : "repository-chevron expanded"} />
|
||||||
|
<FolderGit2 size={15} /><strong>{group.repository || (de ? "Ohne Repository" : "No repository")}</strong><span>{group.issues.length}</span>
|
||||||
|
</button>
|
||||||
|
{#if !collapsed}
|
||||||
|
{#each group.issues as issue (issue.id)}
|
||||||
|
<button class="issue-row" class:selected={selectedId === issue.id} aria-pressed={selectedId === issue.id} onclick={event => { detailTrigger = event.currentTarget; selectedId = issue.id; actionError = ""; }}>
|
||||||
|
<span class="issue-identity"><span class="issue-number">#{issue.number}</span><span class="issue-text"><strong>{issue.title}</strong><small>{issue.repositoryName}</small><IssueLabels labels={issue.labels} /></span></span>
|
||||||
|
<span class="issue-status" data-tone={issueTone(issue.state)}><span class="status-dot"></span>{issueStateLabel(issue.state, de)}</span>
|
||||||
|
<IssueAssignees names={issue.assignees} />
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="workspace-empty"><span class="empty-symbol"><CircleDot size={26} /></span><h2>{loading && !issues.length ? (de ? "Issues werden geladen" : "Loading issues") : error && !issues.length ? (de ? "Issues konnten nicht geladen werden" : "Could not load issues") : (de ? "Keine passenden Issues" : "No matching issues")}</h2><p>{de ? "Suche und Filter gelten für die bereits geladenen Einträge." : "Search and filters apply to the items already loaded."}</p></div>
|
||||||
|
{/each}
|
||||||
|
{#if nextCursor}<button class="workspace-button load-more" disabled={loading} onclick={() => loadIssues(true)}>{de ? "Weitere laden" : "Load more"}</button>{/if}
|
||||||
|
</div>
|
||||||
|
<footer class="issue-footer" aria-live="polite"><span>{filtered.length !== issues.length ? `${filtered.length} / ` : ""}{issues.length} {de ? "geladene Issues" : "loaded issues"}</span>{#if loading}<span>{de ? "Aktualisierung im Hintergrund …" : "Updating in background …"}</span>{/if}</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if selected}
|
||||||
|
<aside bind:this={detailPanel} class="issue-detail-drawer" aria-label={de ? "Issue-Details" : "Issue details"} transition:fly={{ x: 140, duration: reduceMotion ? 0 : 210, easing: cubicOut }}>
|
||||||
|
<header class="issue-detail-header">
|
||||||
|
<div><CircleDot size={17} /><strong>{source?.label} · Issue</strong></div>
|
||||||
|
<div class="issue-detail-header-actions">
|
||||||
|
{#if selected.webUrl}<button class="workspace-button close-button" onclick={() => openIssue(selected!.webUrl)} aria-label={de ? "Im Anbieter öffnen" : "Open in provider"}><ExternalLink size={16} /></button>{/if}
|
||||||
|
<button class="workspace-button close-button" onclick={closeDetails} aria-label={de ? "Details schließen" : "Close details"}><X size={17} /></button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="issue-detail-body">
|
||||||
|
<div class="issue-detail-main">
|
||||||
|
<div class="issue-detail-title"><div><span>#{selected.number}</span><h2>{selected.title}</h2></div>
|
||||||
|
<div class="issue-detail-summary"><span class="issue-status" data-tone={issueTone(selected.state)}><span class="status-dot"></span>{issueStateLabel(selected.state, de)}</span><IssueAssignees names={selected.author ? [selected.author] : []} /></div>
|
||||||
|
</div>
|
||||||
|
<section class="issue-detail-description"><h3>{de ? "Beschreibung" : "Description"}</h3><div class="description">{description || (de ? "Keine Beschreibung vorhanden." : "No description provided.")}</div></section>
|
||||||
|
{#if source}{#key `${sourceKey}:${selected.id}`}<IssueComments {source} issue={selected} {language} {loadCredential} />{/key}{/if}
|
||||||
|
</div>
|
||||||
|
<div class="issue-detail-sidebar">
|
||||||
|
{#if selected.state !== "closed" && closedStates.get(`${sourceKey}:${selected.id}`) !== selected.state}
|
||||||
|
<button class="workspace-button issue-close-action" disabled={!!closingId || loading} onclick={closeIssue}><XCircle size={15} />{closingId === selected.id ? (de ? "Wird geschlossen …" : "Closing …") : (de ? "Issue schließen" : "Close issue")}</button>
|
||||||
|
{/if}
|
||||||
|
{#if actionError}<p class="comment-error" role="alert">{actionError}</p>{/if}
|
||||||
|
{#if selected.webUrl}<button class="workspace-button inspector-open" onclick={() => openIssue(selected!.webUrl)}><ExternalLink size={14} />{de ? "Im Browser öffnen" : "Open in browser"}</button>{/if}
|
||||||
|
<section><h3>{de ? "Zugewiesen" : "Assignees"}</h3><IssueAssignees names={selected.assignees} /></section>
|
||||||
|
<section><h3>Labels</h3>{#if selected.labels.length}<IssueLabels labels={selected.labels} />{:else}<small>{de ? "Keine Labels" : "No labels"}</small>{/if}</section>
|
||||||
|
<section><h3>Repository</h3><strong class="issue-detail-repo"><FolderGit2 size={15} />{selected.repositoryName}</strong></section>
|
||||||
|
{#if selected.updatedAt}<section><h3>{de ? "Aktualisiert" : "Updated"}</h3><small>{new Date(selected.updatedAt).toLocaleString(de ? "de-DE" : "en-US")}</small></section>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script context="module" lang="ts">
|
||||||
|
const drafts = new Map<string, string>();
|
||||||
|
</script>
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
import CommentEditor from "./CommentEditor.svelte";
|
||||||
|
import { RefreshCw } from "@lucide/svelte";
|
||||||
|
import IssueAssignees from "./IssueAssignees.svelte";
|
||||||
|
import { listIntegrationIssueComments, addIntegrationIssueComment } from "../git";
|
||||||
|
import { integrationCredentialKey } from "../integrations";
|
||||||
|
import type { GitIntegrationSource, IntegrationIssue, IssueComment, StoredCredential } from "../types";
|
||||||
|
export let source: GitIntegrationSource;
|
||||||
|
export let issue: IntegrationIssue;
|
||||||
|
export let language: "de" | "en";
|
||||||
|
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||||
|
const key = JSON.stringify([source.id, source.baseUrl, issue.id]);
|
||||||
|
let draft = drafts.get(key) ?? "";
|
||||||
|
let comments: IssueComment[] = [];
|
||||||
|
let cursor: string | null = null;
|
||||||
|
let loading = false;
|
||||||
|
let posting = false;
|
||||||
|
let error = "";
|
||||||
|
let notice = "";
|
||||||
|
let destroyed = false;
|
||||||
|
$: de = language === "de";
|
||||||
|
$: drafts.set(key, draft);
|
||||||
|
onMount(() => { void load(); });
|
||||||
|
onDestroy(() => { destroyed = true; });
|
||||||
|
async function credential() {
|
||||||
|
const saved = await loadCredential(integrationCredentialKey(source.provider, source.accountId));
|
||||||
|
if (!saved?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
async function load(more = false) {
|
||||||
|
if (loading || posting) return;
|
||||||
|
loading = true; error = "";
|
||||||
|
try {
|
||||||
|
const saved = await credential();
|
||||||
|
const page = await listIntegrationIssueComments(source.provider, source.baseUrl, saved.username, saved.password, issue.repositoryName, issue.number, more ? cursor : null);
|
||||||
|
if (destroyed) return;
|
||||||
|
comments = [...new Map([...(more ? comments : []), ...page.comments].map(comment => [comment.id, comment])).values()];
|
||||||
|
cursor = page.nextCursor;
|
||||||
|
} catch (cause) { if (!destroyed) error = String(cause); }
|
||||||
|
finally { if (!destroyed) loading = false; }
|
||||||
|
}
|
||||||
|
async function submit() {
|
||||||
|
if (!draft.trim() || posting || loading) return;
|
||||||
|
const body = draft;
|
||||||
|
posting = true; error = ""; notice = "";
|
||||||
|
try {
|
||||||
|
const saved = await credential();
|
||||||
|
const comment = await addIntegrationIssueComment(source.provider, source.baseUrl, saved.username, saved.password, issue.repositoryName, issue.number, body);
|
||||||
|
if (drafts.get(key) === body) drafts.delete(key);
|
||||||
|
if (destroyed) return;
|
||||||
|
comments = [...comments.filter(item => item.id !== comment.id), comment];
|
||||||
|
if (draft === body) draft = "";
|
||||||
|
notice = de ? "Kommentar gesendet." : "Comment posted.";
|
||||||
|
} catch (cause) {
|
||||||
|
if (!destroyed) error = `${de ? "Versand nicht bestätigt. Vor erneutem Senden bitte die Kommentare aktualisieren." : "Delivery not confirmed. Refresh comments before resending."} ${String(cause)}`;
|
||||||
|
} finally { if (!destroyed) posting = false; }
|
||||||
|
}
|
||||||
|
function text(comment: IssueComment) {
|
||||||
|
if (!comment.bodyHtml) return comment.body;
|
||||||
|
const document = new DOMParser().parseFromString(comment.body, "text/html");
|
||||||
|
document.querySelectorAll("script, style").forEach(element => element.remove());
|
||||||
|
document.querySelectorAll("br").forEach(element => element.replaceWith("\n"));
|
||||||
|
document.querySelectorAll("p, div, li").forEach(element => element.append("\n"));
|
||||||
|
return document.body.textContent?.trim() ?? "";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<section class="issue-comments" aria-label={de ? "Issue-Kommentare" : "Issue comments"}>
|
||||||
|
<header><h3>{de ? "Kommentare" : "Comments"}</h3><small>{comments.length}{cursor ? "+" : ""}</small><button class="workspace-button close-button" aria-label={de ? "Kommentare aktualisieren" : "Refresh comments"} disabled={loading || posting} onclick={() => load()}><RefreshCw size={14} /></button></header>
|
||||||
|
{#if error}<p class="comment-error" role="alert">{error}</p>{/if}
|
||||||
|
{#if loading}<small role="status">{de ? "Kommentare werden geladen …" : "Loading comments …"}</small>{/if}
|
||||||
|
<div class="issue-comment-list">
|
||||||
|
{#each comments as comment (comment.id)}
|
||||||
|
<article><header><IssueAssignees names={[comment.author || (de ? "Unbekannt" : "Unknown")]} />{#if comment.createdAt}<time datetime={comment.createdAt}>{new Date(comment.createdAt).toLocaleString(de ? "de-DE" : "en-US")}</time>{/if}</header><p>{text(comment)}</p></article>
|
||||||
|
{:else}{#if !loading && !error}<small>{de ? "Noch keine Kommentare." : "No comments yet."}</small>{/if}{/each}
|
||||||
|
</div>
|
||||||
|
{#if cursor}<button class="workspace-button" disabled={loading || posting} onclick={() => load(true)}>{de ? "Weitere Kommentare laden" : "Load more comments"}</button>{/if}
|
||||||
|
<CommentEditor bind:value={draft} {language} disabled={loading} busy={posting} onSend={submit} />
|
||||||
|
{#if notice}<small role="status">{notice}</small>{/if}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { labelTone } from "../issuePresentation";
|
||||||
|
export let labels: string[] = [];
|
||||||
|
</script>
|
||||||
|
<span class="issue-labels">{#each labels as label}<span class="issue-label" data-tone={labelTone(label)}>{label}</span>{/each}</span>
|
||||||
@@ -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, LayoutGrid, List, 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 {
|
||||||
@@ -20,16 +18,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Workspace { id: string; name: string; }
|
interface Workspace { id: string; name: string; }
|
||||||
interface PullRequestBadgeState {
|
|
||||||
status: "loading" | "ready" | "error" | "unavailable";
|
|
||||||
count: number;
|
|
||||||
sourceId: string;
|
|
||||||
repository: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RemoteIdentity { host: string; repository: string; organization: string; }
|
|
||||||
|
|
||||||
export let repos: DashboardRepository[] = [];
|
export let repos: DashboardRepository[] = [];
|
||||||
export let language: "de" | "en" = "en";
|
export let language: "de" | "en" = "en";
|
||||||
export let isBusy = false;
|
export let isBusy = false;
|
||||||
@@ -40,8 +28,6 @@
|
|||||||
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";
|
||||||
@@ -56,9 +42,7 @@
|
|||||||
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();
|
||||||
@@ -79,16 +63,6 @@
|
|||||||
{ value: "", label: de ? "Alle Workspaces" : "All workspaces" },
|
{ value: "", label: de ? "Alle Workspaces" : "All workspaces" },
|
||||||
...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() {
|
||||||
@@ -175,127 +149,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 {
|
|
||||||
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(badge: PullRequestBadgeState | undefined): string {
|
function pullRequestTitle(badge: PullRequestBadgeState | undefined): string {
|
||||||
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);
|
||||||
@@ -368,7 +226,7 @@
|
|||||||
</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(pullRequests)}`} title={pullRequestTitle(pullRequests)}>
|
<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 && 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 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}
|
||||||
|
|||||||
@@ -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">
|
||||||
<div class="review-heading"><GitPullRequest size={17} /><h1>Review Center</h1></div>
|
<div class="review-heading"><GitPullRequest size={17} /><h1>Review Center</h1></div>
|
||||||
|
{#if activeSource}<button class="create-review" type="button" disabled={loading} onclick={() => { detailOpen = false; createOpen = true; }}><GitPullRequest size={14}/>{activeSource.provider.startsWith("gitlab") ? (de ? "MR erstellen" : "Create MR") : (de ? "PR erstellen" : "Create PR")}</button>{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if sources.length === 0}
|
{#if sources.length === 0}
|
||||||
@@ -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,6 +532,7 @@
|
|||||||
</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}
|
||||||
@@ -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,38 @@ 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});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/** Visual tones only; provider status values and board membership stay untouched. */
|
||||||
|
export function issueTone(value: string): "neutral" | "active" | "review" | "done" {
|
||||||
|
const name = value.toLocaleLowerCase();
|
||||||
|
if (/^(closed|done|completed|resolved|erledigt|geschlossen|shipped)$/.test(name)) return "done";
|
||||||
|
if (/review|testing|test|\bqa\b|prüfung/.test(name)) return "review";
|
||||||
|
if (/progress|development|entwicklung|active|doing|build|bearbeitung/.test(name)) return "active";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function issueStateLabel(value: string, de: boolean): string {
|
||||||
|
if (!de) return value;
|
||||||
|
return ({ open: "Offen", opened: "Offen", closed: "Geschlossen" } as Record<string, string>)[value] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function labelTone(label: string): string {
|
||||||
|
const name = label.toLocaleLowerCase();
|
||||||
|
if (/bug|error|fehler/.test(name)) return "red";
|
||||||
|
if (/enhancement|feature|verbesserung/.test(name)) return "blue";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
/* Shared visual system for the issue list, native boards and their inspectors. */
|
||||||
|
.issue-center {
|
||||||
|
--issue-muted: var(--color-ink-dim);
|
||||||
|
--issue-canvas: var(--color-surface-solid);
|
||||||
|
--issue-line: var(--color-border-subtle);
|
||||||
|
display:flex;flex:1;min-height:0;min-width:0;flex-direction:column;overflow:hidden;
|
||||||
|
background:var(--issue-canvas);color:var(--color-ink);
|
||||||
|
font:400 13px/1.5 var(--font-sans);
|
||||||
|
-webkit-font-smoothing:antialiased;
|
||||||
|
}
|
||||||
|
.issue-center * {box-sizing:border-box}
|
||||||
|
.issue-center button,.issue-center input {font-family:inherit;font-size:12px;font-weight:400;line-height:1.4}
|
||||||
|
.issue-center button {cursor:pointer;transition:background 140ms,border-color 140ms,color 140ms}
|
||||||
|
.issue-center button:disabled {cursor:default;opacity:.5}
|
||||||
|
.issue-center button:focus-visible,.issue-center input:focus-visible {outline:2px solid var(--color-accent);outline-offset:2px}
|
||||||
|
.issue-center h1,.issue-center h2,.issue-center h3,.issue-center p {margin:0}
|
||||||
|
.issue-center h1 {font-size:20px;font-weight:650;line-height:1.3;letter-spacing:-.4px}
|
||||||
|
.issue-center h2 {font-size:18px;font-weight:600;line-height:1.45;letter-spacing:-.25px;overflow-wrap:anywhere}
|
||||||
|
.issue-center h3 {font-size:12px;font-weight:600;line-height:1.5}
|
||||||
|
.issue-center small {font-size:11px;line-height:1.5;color:var(--issue-muted)}
|
||||||
|
.issue-center .workspace-heading {display:flex;align-items:center;justify-content:space-between;gap:20px;padding:14px 24px 8px;flex-shrink:0}
|
||||||
|
.issue-center .workspace-title {display:flex;align-items:center;gap:13px;min-width:0}
|
||||||
|
.issue-center .workspace-title p {color:var(--issue-muted);font-size:12px;margin-top:4px}
|
||||||
|
.issue-center .workspace-symbol {display:grid;place-items:center;flex-shrink:0;width:34px;height:34px;color:var(--color-accent);border:1px solid color-mix(in srgb,var(--color-accent) 55%,transparent);border-radius:6px;background:color-mix(in srgb,var(--color-accent) 5%,transparent)}
|
||||||
|
.issue-center .workspace-button {display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:32px;padding:6px 10px;white-space:nowrap;border:1px solid var(--color-border);border-radius:4px;background:transparent;color:var(--color-ink-muted);box-shadow:none}
|
||||||
|
.issue-center .workspace-button:hover:not(:disabled) {background:var(--color-surface-hover);border-color:var(--color-border-input);color:var(--color-ink)}
|
||||||
|
.issue-center .workspace-button.active {color:var(--color-accent)}
|
||||||
|
.issue-center .workspace-button svg {flex-shrink:0}
|
||||||
|
.issue-center .icon-button {width:32px;min-width:32px;padding:0}
|
||||||
|
.issue-center .workspace-navigation {display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:40px;padding:0 24px;border-bottom:1px solid var(--issue-line);flex-shrink:0}
|
||||||
|
.issue-center .view-toggle {display:flex;gap:6px;align-self:stretch;margin-left:-12px}
|
||||||
|
.issue-center .view-toggle button {position:relative;min-height:40px;padding:0 14px;background:transparent;border:0;border-radius:0;color:var(--issue-muted);font-weight:550}
|
||||||
|
.issue-center .view-toggle button.active {color:var(--color-accent)}
|
||||||
|
.issue-center .view-toggle button.active:after {position:absolute;content:"";bottom:-1px;left:0;right:0;height:2px;background:var(--color-accent)}
|
||||||
|
.issue-center .view-toggle button:hover {color:var(--color-ink)}
|
||||||
|
.issue-center .source-select {width:190px;flex:0 1 190px}
|
||||||
|
.issue-center .source-select button,.issue-center .state-select button {border-radius:4px;font-size:12px;font-weight:400;min-height:32px;background:transparent}
|
||||||
|
.issue-center .workspace-search {display:flex;align-items:center;gap:9px;min-width:0;height:34px;padding:0 10px;border:1px solid var(--color-border);border-radius:4px;color:var(--issue-muted);background:color-mix(in srgb,var(--color-surface) 35%,transparent)}
|
||||||
|
.issue-center .workspace-search:focus-within {border-color:var(--color-accent)}
|
||||||
|
.issue-center .workspace-search svg {flex-shrink:0}
|
||||||
|
.issue-center .workspace-search input {min-width:0;width:100%;height:100%;border:0;border-radius:0;outline:0;background:transparent;color:var(--color-ink);padding:0}
|
||||||
|
.issue-center input::placeholder {color:var(--issue-muted);opacity:1}
|
||||||
|
.issue-center .workspace-notice {margin:0 24px 12px;padding:10px 12px;border:1px solid var(--issue-line);border-radius:4px;color:var(--color-ink-muted);font-size:12px;overflow-wrap:anywhere}
|
||||||
|
.issue-center .workspace-notice.error {border-color:color-mix(in srgb,#d6a555 45%,transparent);background:color-mix(in srgb,#d6a555 4%,transparent)}
|
||||||
|
.issue-center .workspace-notice p {margin-top:4px;color:var(--issue-muted)}
|
||||||
|
.issue-center .issue-content {display:flex;flex:1;min-height:0;min-width:0;position:relative;overflow:hidden}
|
||||||
|
.issue-center .issue-main {display:flex;flex:1;flex-direction:column;min-width:0;min-height:0}
|
||||||
|
.issue-center .issue-toolbar {display:flex;align-items:center;gap:10px;padding:12px 24px 10px;flex-shrink:0}
|
||||||
|
.issue-center .issue-toolbar .workspace-search {width:360px;max-width:55%;flex:0 1 360px}
|
||||||
|
.issue-center .state-select {flex:0 0 150px;width:150px}
|
||||||
|
.issue-center .issue-list {flex:0 1 auto;min-height:0;overflow:auto;padding:0 24px}
|
||||||
|
.issue-center .issue-table-heading,.issue-center .issue-row {display:grid;grid-template-columns:minmax(0,1fr) 145px 130px;gap:18px;align-items:center;text-align:left}
|
||||||
|
.issue-center .issue-table-heading {min-height:32px;padding:0 14px 0 16px;color:var(--issue-muted);font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:.65px;background:color-mix(in srgb,var(--color-surface) 35%,transparent);border-radius:4px 4px 0 0;border-bottom:1px solid var(--issue-line);position:sticky;top:0;z-index:1}
|
||||||
|
.issue-center .issue-row {position:relative;width:100%;min-height:56px;padding:8px 14px 8px 16px;border:0;border-bottom:1px solid var(--issue-line);border-radius:0;color:var(--color-ink);background:transparent;box-shadow:none}
|
||||||
|
.issue-center .issue-row:hover {background:color-mix(in srgb,var(--color-surface-hover) 55%,transparent)}
|
||||||
|
.issue-center .issue-row.selected {background:color-mix(in srgb,var(--color-accent) 9%,transparent);box-shadow:inset 2px 0 var(--color-accent)}
|
||||||
|
.issue-center .issue-identity {display:flex;min-width:0;gap:18px;align-items:center}
|
||||||
|
.issue-center .issue-number {font-family:var(--font-mono);color:var(--issue-muted);font-size:11px;font-weight:400;white-space:nowrap}
|
||||||
|
.issue-center .issue-identity>.issue-number {flex:0 0 44px}
|
||||||
|
.issue-center .issue-text {display:grid;grid-template-columns:minmax(0,320px) auto;gap:3px 12px;flex:1;max-width:465px;min-width:0;align-items:center}
|
||||||
|
.issue-center .issue-text strong {font-size:13px;line-height:1.45;font-weight:550;overflow-wrap:anywhere;grid-column:1}
|
||||||
|
.issue-center .issue-text>small {grid-column:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.issue-center .issue-text>.issue-labels {grid-column:2;grid-row:1 / 3;max-width:145px;justify-content:flex-end}
|
||||||
|
.issue-center .issue-status {display:flex;align-items:center;gap:9px;font-size:12px;color:var(--color-ink-muted);min-width:0;overflow-wrap:anywhere}
|
||||||
|
.issue-center .status-dot {display:block;width:13px;height:13px;border:2px solid var(--issue-muted);border-radius:50%;flex:0 0 13px}
|
||||||
|
.issue-center [data-tone="active"] {--status-color:#48cef0}
|
||||||
|
.issue-center [data-tone="review"] {--status-color:#ffb52e}
|
||||||
|
.issue-center [data-tone="done"] {--status-color:#6fe480}
|
||||||
|
.issue-center [data-tone="neutral"] {--status-color:var(--issue-muted)}
|
||||||
|
.issue-center .issue-status .status-dot {border-color:var(--status-color)}
|
||||||
|
.issue-center .issue-person {display:inline-flex;align-items:center;gap:8px;min-width:0;font-size:12px;font-weight:400;color:var(--color-ink-muted)}
|
||||||
|
.issue-center .issue-avatar {display:grid;place-items:center;width:23px;height:23px;flex:0 0 23px;border-radius:50%;border:1px solid color-mix(in srgb,var(--color-ink-muted) 20%,transparent);background:color-mix(in srgb,var(--color-ink-muted) 25%,var(--color-surface));font-size:10px;font-weight:550;color:var(--color-ink)}
|
||||||
|
.issue-center .person-name {min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.issue-center .unassigned {color:var(--color-ink-faint)}
|
||||||
|
.issue-center .issue-labels {display:flex;flex-wrap:wrap;gap:4px;min-width:0}
|
||||||
|
.issue-center .issue-label {padding:2px 6px;border-radius:4px;font-size:10px;line-height:1.5;letter-spacing:.1px;color:var(--color-ink-muted);background:var(--color-surface-dim);overflow-wrap:anywhere;font-weight:400}
|
||||||
|
.issue-center .issue-label[data-tone="red"] {background:#9e3d43;color:#fff0f1}
|
||||||
|
.issue-center .issue-label[data-tone="blue"] {background:#3e5699;color:#eef2ff}
|
||||||
|
:root[data-theme="light"] .issue-center .issue-label[data-tone="red"] {color:#972e3c;background:#fbe5e8}
|
||||||
|
:root[data-theme="light"] .issue-center .issue-label[data-tone="blue"] {color:#3753a4;background:#e8edfc}
|
||||||
|
.issue-center .issue-footer {display:flex;justify-content:space-between;gap:16px;padding:12px 24px;color:var(--issue-muted);font-size:11px;flex-shrink:0}
|
||||||
|
.issue-center .load-more {display:flex;margin:18px auto}
|
||||||
|
.issue-center .issue-inspector {flex:0 0 clamp(320px,26vw,420px);width:clamp(320px,26vw,420px);min-width:0;overflow:auto;padding:20px 22px 24px;border-left:1px solid var(--issue-line);background:color-mix(in srgb,var(--color-surface) 20%,var(--issue-canvas))}
|
||||||
|
.issue-center .inspector-top {display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;color:var(--issue-muted);font-size:11px;overflow-wrap:anywhere}
|
||||||
|
.issue-center .inspector-top .issue-number {margin-left:6px}
|
||||||
|
.issue-center .close-button {border:0;background:transparent;min-height:24px;width:24px;padding:0;flex-shrink:0}
|
||||||
|
.issue-center .issue-inspector h2 {font-size:20px;font-weight:600;line-height:1.4;letter-spacing:-.3px}
|
||||||
|
.issue-center .inspector-properties {display:flex;align-items:center;flex-wrap:wrap;gap:16px;margin:18px 0}
|
||||||
|
.issue-center .inspector-properties>.issue-person:not(:first-child) {padding-left:16px;border-left:1px solid var(--color-border)}
|
||||||
|
.issue-center .inspector-labels {display:flex;flex-direction:column;gap:5px;margin-bottom:20px}
|
||||||
|
.issue-center .inspector-description {border-top:1px solid var(--issue-line);padding-top:18px;margin-top:18px}
|
||||||
|
.issue-center .description {white-space:pre-wrap;overflow-wrap:anywhere;font-size:13px;line-height:1.75;color:var(--color-ink-muted);margin-top:8px}
|
||||||
|
.issue-center .inspector-open {margin-top:22px}
|
||||||
|
.issue-center .workspace-empty {display:flex;flex:1;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center;padding:56px 24px;color:var(--issue-muted);min-height:250px}
|
||||||
|
.issue-center .workspace-empty p {font-size:12px;max-width:440px;line-height:1.7}
|
||||||
|
.issue-center .workspace-empty h2 {color:var(--color-ink);font-size:16px}
|
||||||
|
.issue-center .empty-symbol {display:grid;place-items:center;width:52px;height:52px;margin-bottom:6px;border:1px solid var(--issue-line);border-radius:12px;color:var(--issue-muted)}
|
||||||
|
.issue-center .board-view {display:flex;flex:1;flex-direction:column;min-height:0;min-width:0;overflow:hidden}
|
||||||
|
.issue-center .board-header {display:flex;align-items:center;justify-content:space-between;gap:20px;padding:18px 24px 14px;flex-shrink:0}
|
||||||
|
.issue-center .board-title {min-width:0}
|
||||||
|
.issue-center .board-title h2 {font-size:16px;line-height:1.4;font-weight:600;letter-spacing:.05px}
|
||||||
|
.issue-center .board-title small {display:block;margin-top:4px}
|
||||||
|
.issue-center .board-title small>span {padding:0 5px}
|
||||||
|
.issue-center .board-tools {display:flex;align-items:center;gap:9px;flex-shrink:0}
|
||||||
|
.issue-center .board-tools .workspace-search {width:240px}
|
||||||
|
.issue-center .board-connect {padding:18px 24px;flex-shrink:0;border-bottom:1px solid var(--issue-line)}
|
||||||
|
.issue-center .board-connect label {display:block;font-size:11px;color:var(--issue-muted);margin-bottom:7px}
|
||||||
|
.issue-center .board-connect-fields {display:flex;gap:10px}
|
||||||
|
.issue-center .board-connect input {flex:1;min-width:100px;min-height:34px;padding:6px 10px;border:1px solid var(--color-border);border-radius:4px;background:transparent;color:var(--color-ink)}
|
||||||
|
.issue-center .board-notice {font-size:11px;line-height:1.6;padding:0 24px 12px;color:var(--issue-muted);flex-shrink:0}
|
||||||
|
.issue-center .board-body {position:relative;display:flex;flex:1;min-height:0;overflow:hidden}
|
||||||
|
.issue-center .board-columns {display:flex;flex:1;min-width:0;gap:14px;padding:0 24px 20px;overflow:auto;align-items:stretch}
|
||||||
|
.issue-center .board-column {display:flex;flex:1 0 278px;max-width:400px;min-width:0;flex-direction:column;border:0;border-radius:6px;background:color-mix(in srgb,var(--color-surface) 60%,var(--issue-canvas));overflow:hidden}
|
||||||
|
.issue-center .board-column>header {display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 12px 12px;flex-shrink:0}
|
||||||
|
.issue-center .board-column h3 {display:flex;align-items:center;gap:9px;min-width:0;font-size:12px;font-weight:600;overflow-wrap:anywhere}
|
||||||
|
.issue-center .column-dot {width:10px;height:10px;flex:0 0 10px;border-radius:50%;background:var(--status-color)}
|
||||||
|
.issue-center .column-count {color:var(--issue-muted);font-size:11px;white-space:nowrap}
|
||||||
|
.issue-center .wip-limit {font-size:10px}
|
||||||
|
.issue-center .column-cards {padding:0 10px 10px;min-height:0;overflow:auto;flex:1}
|
||||||
|
.issue-center .board-card {display:flex;flex-direction:column;align-items:flex-start;gap:7px;width:100%;text-align:left;padding:12px;margin-bottom:9px;border:1px solid var(--color-border);border-radius:6px;box-shadow:0 2px 3px #0000000a;background:var(--issue-canvas);color:var(--color-ink);overflow-wrap:anywhere}
|
||||||
|
.issue-center .board-card:hover {border-color:var(--color-border-input);background:color-mix(in srgb,var(--color-surface) 55%,var(--issue-canvas))}
|
||||||
|
.issue-center .board-card.chosen {border-color:color-mix(in srgb,var(--color-accent) 65%,var(--color-border));background:color-mix(in srgb,var(--color-accent) 5%,var(--issue-canvas))}
|
||||||
|
.issue-center .board-card strong {font-size:13px;line-height:1.5;font-weight:550}
|
||||||
|
.issue-center .card-reference {display:flex;align-items:center;flex-wrap:wrap;gap:8px;font-size:11px;line-height:1.4}
|
||||||
|
.issue-center .board-card .issue-person {margin-top:3px;font-size:11px}
|
||||||
|
.issue-center .board-card .issue-avatar {width:19px;height:19px;flex-basis:19px;font-size:9px}
|
||||||
|
.issue-center .lane {font-size:10px;line-height:1.5;color:var(--color-accent);font-weight:400}
|
||||||
|
.issue-center .column-empty {padding:28px 12px;text-align:center;color:var(--color-ink-faint);font-size:11px}
|
||||||
|
@media(min-width:1250px) {
|
||||||
|
.issue-center .issue-content:not(.with-details) .issue-table-heading,.issue-center .issue-content:not(.with-details) .issue-row {grid-template-columns:minmax(0,1fr) 180px 160px}
|
||||||
|
}
|
||||||
|
@media(max-width:1150px) {
|
||||||
|
.issue-center .issue-text {display:flex;flex-direction:column;align-items:flex-start;gap:3px}
|
||||||
|
.issue-center .issue-text>.issue-labels {justify-content:flex-start;max-width:none;margin-top:3px}
|
||||||
|
.issue-center .issue-table-heading,.issue-center .issue-row {grid-template-columns:minmax(0,1fr) 110px 100px;gap:12px}
|
||||||
|
.issue-center .issue-identity {gap:10px}
|
||||||
|
.issue-center .issue-inspector {flex-basis:310px;width:310px;padding:18px}
|
||||||
|
.issue-center .board-header {align-items:flex-start;flex-direction:column;gap:14px}
|
||||||
|
.issue-center .board-tools {width:100%}
|
||||||
|
.issue-center .board-tools .workspace-search {flex:1;width:auto}
|
||||||
|
}
|
||||||
|
@media(max-width:800px) {
|
||||||
|
.issue-center .workspace-heading {padding:18px 16px 10px;gap:12px}
|
||||||
|
.issue-center .workspace-title p {max-width:235px}
|
||||||
|
.issue-center .workspace-navigation {padding:0 16px}
|
||||||
|
.issue-center .workspace-symbol {display:none}
|
||||||
|
.issue-center .issue-toolbar {padding:14px 16px 10px;gap:8px;flex-wrap:wrap}
|
||||||
|
.issue-center .issue-toolbar .workspace-search {max-width:none;flex:1 1 190px;width:auto}
|
||||||
|
.issue-center .state-select {flex:0 0 130px;width:130px}
|
||||||
|
.issue-center .issue-list {padding:0 16px}
|
||||||
|
.issue-center .issue-footer {padding:12px 16px;font-size:10px}
|
||||||
|
.issue-center .issue-inspector {position:absolute;inset:0;width:100%;max-width:none;z-index:3;border:0;flex-basis:auto;background:var(--issue-canvas);padding:20px}
|
||||||
|
.issue-center .with-details .issue-main {visibility:hidden}
|
||||||
|
.issue-center .board-header,.issue-center .board-connect {padding:16px}
|
||||||
|
.issue-center .board-tools {flex-wrap:wrap;gap:8px}
|
||||||
|
.issue-center .board-tools .workspace-search {flex:1 1 100%;height:34px}
|
||||||
|
.issue-center .board-columns {padding:0 16px 16px;gap:12px}
|
||||||
|
.issue-center .board-column {flex:0 0 270px}
|
||||||
|
.issue-center .board-notice {padding:0 16px 12px}
|
||||||
|
.issue-center .workspace-notice {margin:0 16px 12px}
|
||||||
|
}
|
||||||
|
@media(max-width:520px) {
|
||||||
|
.issue-center .workspace-title p {font-size:11px;max-width:205px}
|
||||||
|
.issue-center .workspace-heading>.workspace-button {font-size:11px;padding:6px 8px}
|
||||||
|
.issue-center .workspace-heading>.workspace-button>svg {display:none}
|
||||||
|
.issue-center .source-select {flex-basis:155px;width:155px}
|
||||||
|
.issue-center .issue-table-heading,.issue-center .issue-row {grid-template-columns:minmax(0,1fr) 94px;gap:8px}
|
||||||
|
.issue-center .issue-table-heading>span:last-child,.issue-center .issue-row>.issue-person {display:none}
|
||||||
|
.issue-center .issue-identity>.issue-number {display:none}
|
||||||
|
.issue-center .issue-row {padding:12px 10px;min-height:80px}
|
||||||
|
.issue-center .issue-status {font-size:11px;gap:6px}
|
||||||
|
.issue-center .issue-toolbar .workspace-search {flex-basis:100%}
|
||||||
|
.issue-center .board-tools>.workspace-button {font-size:11px;padding:6px 8px}
|
||||||
|
.issue-center .board-connect-fields {flex-wrap:wrap}
|
||||||
|
.issue-center .board-connect input {flex-basis:100%}
|
||||||
|
}
|
||||||
|
@media(prefers-reduced-motion:reduce) {.issue-center button {transition:none}}
|
||||||
|
/* This workspace opts into its compact rounded controls. The app-wide square
|
||||||
|
reset uses !important, so the same priority is needed for these local shapes. */
|
||||||
|
.issue-center :is(.issue-avatar,.status-dot,.column-dot) {border-radius:50% !important}
|
||||||
|
.issue-center :is(.workspace-button,.workspace-search,.issue-label,.source-select button,.state-select button,.board-connect input) {border-radius:4px !important}
|
||||||
|
.issue-center :is(.workspace-symbol,.board-column,.board-card) {border-radius:6px !important}
|
||||||
|
.issue-center .empty-symbol {border-radius:12px !important}
|
||||||
|
|
||||||
|
.issue-center .repository-select {width:190px;flex:0 1 190px;min-width:130px}
|
||||||
|
.issue-center .repository-select button {font-size:12px;min-height:32px;border-radius:4px !important}
|
||||||
|
.issue-center .repository-group-heading {display:flex;align-items:center;gap:9px;padding:14px 16px 9px;color:var(--color-ink-muted);border-bottom:1px solid var(--issue-line)}
|
||||||
|
.issue-center .repository-group-heading strong {overflow-wrap:anywhere}
|
||||||
|
.issue-center .repository-group-heading>span {margin-left:auto;color:var(--issue-muted);font-size:11px}
|
||||||
|
.issue-center .issue-toolbar {flex-wrap:wrap}
|
||||||
|
.issue-center .board-tools {flex-wrap:wrap;justify-content:flex-end;flex-shrink:1}
|
||||||
|
:root[data-theme="light"] .issue-center [data-tone="active"] {--status-color:#0086a8}
|
||||||
|
:root[data-theme="light"] .issue-center [data-tone="review"] {--status-color:#ab6800}
|
||||||
|
:root[data-theme="light"] .issue-center [data-tone="done"] {--status-color:#24853c}
|
||||||
|
@media(max-width:800px) {.issue-center .board-tools {justify-content:flex-start}}
|
||||||
|
|
||||||
|
.issue-center .repository-group-heading {width:100%;text-align:left;background:transparent;border:0;border-bottom:1px solid var(--issue-line);border-radius:0;justify-content:flex-start}
|
||||||
|
.issue-center .repository-group-heading:hover {background:var(--color-surface-hover)}
|
||||||
|
.issue-center .repository-group-heading strong {font-size:12px;font-weight:600}
|
||||||
|
.issue-center .repository-group-heading svg {flex-shrink:0}
|
||||||
|
.issue-center .repository-chevron.expanded {transform:rotate(90deg)}
|
||||||
|
|
||||||
|
.issue-center .board-directory {display:flex;align-items:center;gap:10px;padding:12px 24px;flex-wrap:wrap;border-bottom:1px solid var(--issue-line)}
|
||||||
|
.issue-center .board-select {width:360px;max-width:100%;min-width:0}
|
||||||
|
.issue-center .board-discovery-notice {padding:8px 24px;color:var(--issue-muted);font-size:12px;max-height:160px;overflow:auto;flex-shrink:0}
|
||||||
|
.issue-center .board-discovery-notice summary {cursor:pointer}
|
||||||
|
.issue-center .board-discovery-notice p {margin-top:8px;overflow-wrap:anywhere}
|
||||||
|
|
||||||
|
.issue-center .board-column.drop-target {outline:2px solid var(--color-accent);outline-offset:-2px;background:color-mix(in srgb,var(--color-accent) 12%,var(--issue-canvas))}
|
||||||
|
.issue-center .board-card[draggable="true"] {cursor:grab}
|
||||||
|
.issue-center .board-card[draggable="true"]:active {cursor:grabbing}
|
||||||
|
.issue-center .card-move-control {display:flex;flex-direction:column;gap:6px;margin-top:16px}
|
||||||
|
|
||||||
|
/* Overlay drawer follows the PR Center's header, proportions and two-column detail layout. */
|
||||||
|
.issue-center {position:relative}
|
||||||
|
.issue-center .issue-detail-drawer {position:absolute;z-index:20;inset:0 0 0 auto;display:flex;flex-direction:column;width:44%;min-width:min(680px,100%);max-width:100%;height:100%;overflow:hidden;border-left:1px solid var(--color-border);background:color-mix(in srgb,var(--app-bg) 94%,var(--color-surface));box-shadow:-14px 0 30px #0007}
|
||||||
|
.issue-center .issue-detail-header {display:flex;align-items:center;justify-content:space-between;gap:14px;min-height:54px;flex-shrink:0;padding:0 24px;border-bottom:1px solid var(--color-border);background:color-mix(in srgb,var(--app-bg) 78%,var(--color-surface))}
|
||||||
|
.issue-center .issue-detail-header>div {display:flex;align-items:center;gap:9px;min-width:0}
|
||||||
|
.issue-center .issue-detail-header strong {font-size:13px;font-weight:600;overflow-wrap:anywhere}
|
||||||
|
.issue-center .issue-detail-header svg {color:var(--color-accent);flex-shrink:0}
|
||||||
|
.issue-center .issue-detail-header .issue-detail-header-actions {gap:3px;flex-shrink:0}
|
||||||
|
.issue-center .issue-detail-header-actions svg {color:var(--color-ink-muted)}
|
||||||
|
.issue-center .issue-detail-body {display:grid;grid-template-columns:minmax(0,1fr) 225px;gap:18px;padding-left:24px;min-height:0;flex:1;overflow:hidden}
|
||||||
|
.issue-center .issue-detail-main {min-width:0;overflow:auto}
|
||||||
|
.issue-center .issue-detail-title {padding:18px 0 16px;border-bottom:1px solid var(--issue-line)}
|
||||||
|
.issue-center .issue-detail-title>div:first-child {display:flex;align-items:baseline;gap:10px}
|
||||||
|
.issue-center .issue-detail-title>div:first-child>span {color:var(--color-accent);font-size:17px;font-weight:700;flex-shrink:0}
|
||||||
|
.issue-center .issue-detail-title h2 {font-size:19px;line-height:1.3;font-weight:700;min-width:0}
|
||||||
|
.issue-center .issue-detail-summary {display:flex;align-items:center;flex-wrap:wrap;gap:12px;min-height:33px;margin-top:8px}
|
||||||
|
.issue-center .issue-detail-summary .issue-status,.issue-center .issue-detail-summary .issue-person {font-size:10.5px}
|
||||||
|
.issue-center .issue-detail-description {padding:14px 0 18px}
|
||||||
|
.issue-center .issue-detail-description h3 {font-size:11.5px}
|
||||||
|
.issue-center .issue-detail-description .description {font-size:11px;line-height:1.6}
|
||||||
|
.issue-center .issue-detail-sidebar {min-width:0;overflow:auto;padding:28px 17px 16px;border-left:1px solid var(--color-border);background:color-mix(in srgb,var(--color-surface) 46%,var(--app-bg))}
|
||||||
|
.issue-center .issue-detail-sidebar .inspector-open {width:100%;margin:0 0 12px;min-height:39px;font-size:11px;color:var(--color-accent);border-color:var(--color-accent)}
|
||||||
|
.issue-center .issue-detail-sidebar section {display:flex;flex-direction:column;gap:11px;padding:17px 0;border-bottom:1px solid var(--issue-line)}
|
||||||
|
.issue-center .issue-detail-sidebar h3 {font-size:11.5px;color:var(--color-ink-muted)}
|
||||||
|
.issue-center .issue-detail-repo {display:flex;align-items:flex-start;gap:8px;font-size:11px;overflow-wrap:anywhere}
|
||||||
|
.issue-center .issue-detail-repo svg {flex-shrink:0}
|
||||||
|
@media(max-width:1280px) {.issue-center .issue-detail-drawer {width:50%;min-width:min(650px,100%)}}
|
||||||
|
@media(max-width:680px) {.issue-center .issue-detail-drawer {width:100%;min-width:0}.issue-center .issue-detail-body {display:block;overflow:auto;padding:0 15px}.issue-center .issue-detail-main {overflow:visible}.issue-center .issue-detail-sidebar {padding:0 0 18px;border-left:0;background:transparent;overflow:visible}}
|
||||||
|
|
||||||
|
.issue-center .issue-comments {padding:14px 0 18px;border-top:1px solid var(--issue-line)}
|
||||||
|
.issue-center .issue-comments>header {display:flex;align-items:center;gap:8px;margin-bottom:12px}
|
||||||
|
.issue-center .issue-comments>header button {margin-left:auto}
|
||||||
|
.issue-center .issue-comment-list {display:flex;flex-direction:column;gap:10px;margin:12px 0}
|
||||||
|
.issue-center .issue-comment-list article {border:1px solid var(--color-border);background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))}
|
||||||
|
.issue-center .issue-comment-list article header {display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;padding:10px}
|
||||||
|
.issue-center .issue-comment-list time {font-size:9px;color:var(--issue-muted)}
|
||||||
|
.issue-center .issue-comment-list article p {white-space:pre-wrap;overflow-wrap:anywhere;padding:0 12px 12px;font-size:11px;line-height:1.6}
|
||||||
|
.issue-center .comment-error {color:#e0737b;font-size:11px;overflow-wrap:anywhere}
|
||||||
|
|
||||||
|
.issue-center .issue-close-action {width:100%;min-height:39px;margin-bottom:10px;color:#e0737b;border-color:#a74b55;font-size:11px}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface PullRequestBadgeState {
|
||||||
|
status: "loading" | "ready" | "error" | "unavailable";
|
||||||
|
count: number;
|
||||||
|
sourceId: string;
|
||||||
|
repository: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user