feat(integrations): add multi-provider board and issue integrations

Add backend integration modules to discover, read, and modify
provider-hosted boards, issues, and comments across multiple providers.
Expose Tauri commands for board discovery, listing, and card moves,
and implement safe issue actions and comment APIs.
Wire new Svelte UI components to render boards, issue centers,
comments, labels, and assignees, and add sanitized markdown rendering.

- Add board discovery, board reading, and card-move APIs
- Add Svelte components and styles for integrated board UI
- Use marked + DOMPurify for safe markdown rendering
This commit is contained in:
2026-09-08 21:19:54 +02:00
parent 80c7cf7280
commit 0539020545
24 changed files with 3098 additions and 32 deletions
+23
View File
@@ -13,6 +13,8 @@
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"dompurify": "^3.4.15",
"marked": "^18.0.12",
"simple-icons": "^16.24.1",
"svelte": "^5.0.0",
"tailwindcss": "^4.3.1"
@@ -1557,6 +1559,15 @@
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz",
"integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/enhanced-resolve": {
"version": "5.21.6",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
@@ -1975,6 +1986,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "18.0.12",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.12.tgz",
"integrity": "sha512-LEm4ga2YeI2T3GVHj9b0BaDPPk93LLTHMFeMyQbNIzPxc8vCI0y/scy0ZA6z6lXKyT9j9Nhl/OC6ZYKYGuFScA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+2
View File
@@ -20,6 +20,8 @@
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"dompurify": "^3.4.15",
"marked": "^18.0.12",
"simple-icons": "^16.24.1",
"svelte": "^5.0.0",
"tailwindcss": "^4.3.1"
+9
View File
@@ -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 reqwest::blocking::{Client, Response};
use reqwest::header::{ACCEPT, USER_AGENT};
@@ -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();
}
}
+115
View File
@@ -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,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");
}
}
+336
View File
@@ -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"
);
}
}
+8 -1
View File
@@ -35,7 +35,7 @@ use git::{
};
use integrations::{
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::sync::Mutex;
@@ -439,6 +439,13 @@ async fn main() {
cred_delete,
list_integration_repositories,
list_integration_review_requests,
list_integration_issues,
get_integration_board,
list_integration_boards,
move_integration_board_card,
list_integration_issue_comments,
add_integration_issue_comment,
close_integration_issue,
get_integration_review_details,
add_integration_review_comment,
run_integration_review_action,
+5 -1
View File
@@ -13,6 +13,7 @@
import PullRequestStatusLoader from "./lib/components/PullRequestStatusLoader.svelte";
import type { PullRequestBadgeState } from "./lib/pullRequestBadges";
let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
import IssueCenter from "./lib/components/IssueCenter.svelte";
import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte";
import ReviewCenter from "./lib/components/ReviewCenter.svelte";
import RepoTabs from "./lib/RepoTabs.svelte";
@@ -222,7 +223,7 @@
import { setTelemetryEnabled, tracedInvoke } from "./lib/telemetry";
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 CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
type CredentialMode = "credentials" | "token";
@@ -5396,6 +5397,7 @@
language={appLanguage}
onOpenManagement={openRepoManagement}
onOpenReviewCenter={() => openReviewCenter()}
onOpenIssues={() => { activeView = "issues"; }}
isActive={(path) => activeView === "repository" && sameRepoPath(activeRepoPath, path)}
onSelect={selectRepoTab}
onClose={closeRepoTab}
@@ -5566,6 +5568,8 @@
onRemoveRecent={removeRepoFromRecent}
onOpenPullRequests={(repository, sourceId) => openReviewCenter(repository, sourceId)}
/>
{:else if activeView === "issues"}
<IssueCenter language={appLanguage} integrations={gitIntegrationSettings} loadCredential={loadStoredCredential} onOpenSettings={openAppSettings} />
{:else if activeView === "review-center"}
<ReviewCenter
language={appLanguage}
+2 -1
View File
@@ -26,6 +26,7 @@
--color-bar-text: #f0f1f2;
--color-bar-muted: #8b8f94;
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
}
@@ -151,7 +152,7 @@
overflow: hidden;
color: var(--color-ink);
background: var(--app-bg);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-family: var(--font-sans);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
+8 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Folder, GitBranch, GitPullRequest, Plus, X } from "@lucide/svelte";
import { CircleDot, Folder, GitBranch, GitPullRequest, Plus, X } from "@lucide/svelte";
interface RepositoryTabItem {
path: string;
@@ -7,11 +7,12 @@
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 isBusy: boolean = false;
export let language: "en" | "de" = "en";
export let onOpenManagement: () => void = () => {};
export let onOpenIssues: () => void = () => {};
export let onOpenReviewCenter: () => void = () => {};
export let isActive: (path: string) => boolean = () => false;
export let onSelect: (path: string) => void | Promise<void> = () => {};
@@ -40,6 +41,11 @@
<span>PR</span>
</button>
</div>
<div class="repo-tab-wrap" class:active={activeView === "issues"} role="presentation">
<button class="repo-tab" type="button" onclick={onOpenIssues} disabled={isBusy} title="Issues" aria-label="Issues">
<CircleDot size={13} aria-hidden="true" /><span>Issues</span>
</button>
</div>
{#each repoTabs as repo (repo.path)}
<div
class="repo-tab-wrap"
+109
View File
@@ -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,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>
+8
View File
@@ -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>
+259
View File
@@ -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>
+83
View File
@@ -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>
+5
View File
@@ -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>
+13 -27
View File
@@ -1,12 +1,13 @@
<script lang="ts">
import { onMount } from "svelte";
import CommentEditor from "./CommentEditor.svelte";
import SelectMenu from "./SelectMenu.svelte";
import { cubicOut } from "svelte/easing";
import { fly } from "svelte/transition";
import {
AlertTriangle, AtSign, Bold, Check, ChevronDown, ChevronRight, Code2,
CircleCheck, Clock3, CircleDotDashed, ExternalLink, GitBranch, GitMerge, GitPullRequest, Inbox, Italic,
Link, List, LoaderCircle, PanelRightOpen, RefreshCw, RotateCcw, Search, Settings2, X, XCircle,
AlertTriangle, Check, ChevronDown, ChevronRight,
CircleCheck, Clock3, CircleDotDashed, ExternalLink, GitBranch, GitMerge, GitPullRequest, Inbox,
LoaderCircle, PanelRightOpen, RefreshCw, RotateCcw, Search, Settings2, X, XCircle,
} from "@lucide/svelte";
import { addIntegrationReviewComment, getIntegrationReviewDetails, listIntegrationReviewRequests, openInBrowser, runIntegrationReviewAction } from "../git";
import { configuredIntegrationSources, integrationCredentialKey, providerLabel } from "../integrations";
@@ -46,7 +47,6 @@
let actionNotice = $state("");
let detailLoadingId = $state("");
let commentDraft = $state("");
let commentTextarea = $state<HTMLTextAreaElement>();
let commentPosting = $state(false);
let collapsedRepositories = $state(new Set<string>());
let loadGeneration = 0;
@@ -235,20 +235,6 @@
collapsedRepositories = next;
}
function formatComment(prefix: string, suffix = prefix) {
const textarea = commentTextarea;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selection = commentDraft.slice(start, end);
commentDraft = `${commentDraft.slice(0, start)}${prefix}${selection}${suffix}${commentDraft.slice(end)}`;
requestAnimationFrame(() => {
textarea.focus();
const cursorStart = start + prefix.length;
textarea.setSelectionRange(cursorStart, cursorStart + selection.length);
});
}
function selectRequest(request: IntegrationReviewRequest, showDetail = false) {
if (selectedId !== request.id) commentDraft = "";
selectedId = request.id;
@@ -511,7 +497,7 @@
<section class="comments-section">
<header><h3>{de ? "Kommentare" : "Comments"}</h3><span>{selected.comments?.length ?? 0}</span></header>
{#if detailLoadingId === selected.id && !(selected.comments?.length)}<div class="comments-loading"><LoaderCircle class="spin" size={13} />{de ? "Kommentare werden geladen …" : "Loading comments …"}</div>{:else if selected.comments?.length}<div class="comment-list">{#each selected.comments as comment (comment.id)}<article><div class="comment-card"><header><span class="avatar">{initials(comment.author)}</span><strong>{comment.author || (de ? "Unbekannt" : "Unknown")}</strong><time>{formatRelativeDate(comment.createdAt)}</time>{#if comment.author === selected.author}<span class="owner-badge" title={de ? "Autor dieses Requests" : "Author of this request"}>{de ? "Autor" : "Author"}</span>{/if}</header><p>{comment.body}</p></div></article>{/each}</div>{:else}<p class="no-comments">{de ? "Noch keine Kommentare." : "No comments yet."}</p>{/if}
<form class="comment-composer" onsubmit={(event) => { event.preventDefault(); void submitComment(); }}><textarea bind:this={commentTextarea} bind:value={commentDraft} maxlength="100000" rows="4" placeholder={de ? "Kommentar hinzufügen …" : "Add a comment …"}></textarea><footer><div class="format-actions"><button type="button" title={de ? "Fett" : "Bold"} onclick={() => formatComment("**")}><Bold size={14} /></button><button type="button" title={de ? "Kursiv" : "Italic"} onclick={() => formatComment("_")}><Italic size={14} /></button><button type="button" title="Code" onclick={() => formatComment("`", "`")}><Code2 size={14} /></button><button type="button" title="Link" onclick={() => formatComment("[", "](url)")}><Link size={14} /></button><button type="button" title={de ? "Liste" : "List"} onclick={() => formatComment("- ", "")}><List size={14} /></button><button type="button" title={de ? "Erwähnen" : "Mention"} onclick={() => formatComment("@", "")}><AtSign size={14} /></button></div><button class="submit-comment" type="submit" disabled={!commentDraft.trim() || commentPosting}>{#if commentPosting}<LoaderCircle class="spin" size={13} />{/if}{de ? "Kommentar senden" : "Add comment"}</button></footer></form>
<div class="review-comment-editor"><CommentEditor bind:value={commentDraft} language={de ? "de" : "en"} busy={commentPosting} onSend={submitComment} /></div>
</section>
</main>
<aside class="detail-sidebar">
@@ -542,12 +528,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}}
.branch-loading{display:flex;align-items:center;gap:5px;color:var(--color-ink-faint);font-size:9px}
.provider-action{overflow:visible;border-radius:2px;box-shadow:0 1px 0 #0005}.provider-button{border-radius:2px 0 0 2px;font-weight:600}.action-toggle{border-radius:0 2px 2px 0;background:color-mix(in srgb,var(--color-accent) 14%,var(--color-surface))}.panel-button{margin-left:2px;border-radius:2px}.review-layout.inspector-open{grid-template-columns:minmax(580px,1fr) minmax(420px,44%)}.detail-header{background:linear-gradient(180deg,color-mix(in srgb,var(--color-surface-raised) 72%,var(--color-surface)),var(--color-surface))}
.comments-section{padding:15px 0;border-bottom:1px solid var(--color-border-subtle)}.comments-section>header{display:flex;align-items:center;gap:7px;margin-bottom:10px}.comments-section h3{margin:0;font-size:11px}.comments-section>header>span{display:grid;min-width:18px;height:18px;place-items:center;border-radius:9px;color:var(--color-ink-faint);background:var(--color-surface-raised);font-size:9px}.comments-loading,.no-comments{margin:8px 0;color:var(--color-ink-faint);font-size:10px}.comments-loading{display:flex;align-items:center;gap:6px}.comment-list{display:grid;gap:10px;margin-bottom:12px}.comment-list article{display:flex;align-items:flex-start;gap:9px}.comment-list article>div{min-width:0;flex:1;padding:9px 10px;border:1px solid var(--color-border-subtle);border-radius:2px;background:var(--app-bg)}.comment-list article header{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}.comment-list article strong{font-size:10.5px}.comment-list article time{color:var(--color-ink-faint);font-size:9px}.comment-list article p{margin:0;white-space:pre-wrap;color:var(--color-ink-muted);font-size:10.5px;line-height:1.5}.comment-composer{display:flex;align-items:flex-start;gap:9px;margin-top:10px}.comment-composer textarea{box-sizing:border-box;width:100%;min-height:82px;resize:vertical;padding:9px 10px;border:1px solid var(--color-border-input);outline:0;color:var(--color-ink);background:var(--app-input-bg);font:inherit;line-height:1.45}.comment-composer textarea:focus{border-color:var(--color-accent)}.comment-composer button{display:inline-flex;height:29px;align-items:center;gap:6px;padding:0 10px;border:1px solid #4fa565;border-radius:2px;color:#63c783;background:color-mix(in srgb,#4fa565 12%,var(--color-surface))}.comment-composer button:hover:not(:disabled){color:#fff;background:#397849}
.comments-section{padding:15px 0;border-bottom:1px solid var(--color-border-subtle)}.comments-section>header{display:flex;align-items:center;gap:7px;margin-bottom:10px}.comments-section h3{margin:0;font-size:11px}.comments-section>header>span{display:grid;min-width:18px;height:18px;place-items:center;border-radius:9px;color:var(--color-ink-faint);background:var(--color-surface-raised);font-size:9px}.comments-loading,.no-comments{margin:8px 0;color:var(--color-ink-faint);font-size:10px}.comments-loading{display:flex;align-items:center;gap:6px}.comment-list{display:grid;gap:10px;margin-bottom:12px}.comment-list article{display:flex;align-items:flex-start;gap:9px}.comment-list article>div{min-width:0;flex:1;padding:9px 10px;border:1px solid var(--color-border-subtle);border-radius:2px;background:var(--app-bg)}.comment-list article header{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}.comment-list article strong{font-size:10.5px}.comment-list article time{color:var(--color-ink-faint);font-size:9px}.comment-list article p{margin:0;white-space:pre-wrap;color:var(--color-ink-muted);font-size:10.5px;line-height:1.5}
@media(max-width:1100px){.review-layout.inspector-open{grid-template-columns:minmax(480px,1fr) 380px}}
@media(max-width:760px){.review-layout.inspector-open{display:block}.detail-panel{position:absolute;inset:90px 0 0 12%;z-index:5;box-shadow:-12px 0 30px #0008}}
.comment-list{max-height:230px;overflow-y:auto;overscroll-behavior:contain;padding-right:5px;scrollbar-gutter:stable}.comment-list::-webkit-scrollbar{width:7px}.comment-list::-webkit-scrollbar-thumb{border:2px solid transparent;border-radius:4px;background:var(--color-border-input);background-clip:padding-box}.comment-list::-webkit-scrollbar-track{background:transparent}
.detail-panel{display:flex;flex-direction:column;overflow:hidden}.detail-header{flex:0 0 auto}.detail-content{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden}.detail-summary,.merge-summary,.description,.detail-actions{flex:0 0 auto}.description p{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:3;line-clamp:3}.comments-section{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden}.comments-section>header,.comment-composer{flex:0 0 auto}.comment-list{min-height:0;max-height:none;flex:1;overflow-y:auto}.comment-composer textarea{min-height:68px;max-height:92px}.detail-actions{margin-bottom:12px}
.review-center{position:relative}.review-layout.inspector-open{grid-template-columns:minmax(0,1fr)}.detail-panel{position:absolute;z-index:20;inset:0;display:flex;width:100%;height:100%;flex-direction:column;overflow:hidden;border:0;background:var(--app-bg)}.detail-header{display:flex;min-height:48px;align-items:center;padding:0 20px;border-bottom:1px solid var(--color-border);background:var(--color-surface-raised)}.detail-provider{display:flex;align-items:center;gap:9px;color:var(--color-ink-muted)}.detail-provider>:global(svg){color:var(--color-accent)}.detail-provider strong{font-size:13px;font-weight:600}.detail-header-actions{display:flex;align-items:center;gap:3px;margin-left:auto}.detail-content{display:grid;min-height:0;flex:1;grid-template-columns:minmax(0,1fr) 300px;gap:28px;overflow:hidden;padding:0 28px}.detail-main{display:flex;min-width:0;min-height:0;flex-direction:column;overflow:hidden}.detail-title{flex:0 0 auto;padding:18px 0 0;border-bottom:1px solid var(--color-border-subtle)}.detail-title h2{margin:7px 0 10px;font-size:20px;font-weight:520;line-height:1.25}.detail-title .detail-summary{min-height:34px;border:0}.detail-title .detail-summary>span:last-child{color:var(--color-ink-muted);font-size:10.5px}.detail-main>.description{padding:13px 0}.detail-main>.comments-section{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden;padding:13px 0 18px}.detail-main .merge-summary{flex:0 0 auto;margin-bottom:11px;padding:0 12px;border:1px solid var(--color-border);background:var(--color-surface)}.detail-main .comment-list{min-height:0;flex:1}.detail-main .comment-composer{flex:0 0 auto}.detail-sidebar{display:flex;min-height:0;flex-direction:column;gap:0;overflow:hidden;padding:18px 0;border-left:1px solid var(--color-border-subtle);padding-left:22px;background:var(--color-surface)}.detail-sidebar .detail-actions{display:grid;grid-template-columns:1fr;gap:6px;margin:0 0 8px}.detail-sidebar section{padding:17px 0;border-bottom:1px solid var(--color-border-subtle)}.detail-sidebar section header{display:flex;align-items:center;justify-content:space-between;gap:12px}.detail-sidebar section h3{margin:0;color:var(--color-ink-muted);font-size:11px;font-weight:560}.people{display:grid;grid-template-columns:24px minmax(0,1fr);align-items:center;gap:8px;margin-top:12px}.people.compact{grid-template-columns:24px}
.detail-panel{display:flex;flex-direction:column;overflow:hidden}.detail-header{flex:0 0 auto}.detail-content{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden}.detail-summary,.merge-summary,.description,.detail-actions{flex:0 0 auto}.description p{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:3;line-clamp:3}.comments-section{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden}.comments-section>header{flex:0 0 auto}.comment-list{min-height:0;max-height:none;flex:1;overflow-y:auto}.detail-actions{margin-bottom:12px}
.review-center{position:relative}.review-layout.inspector-open{grid-template-columns:minmax(0,1fr)}.detail-panel{position:absolute;z-index:20;inset:0;display:flex;width:100%;height:100%;flex-direction:column;overflow:hidden;border:0;background:var(--app-bg)}.detail-header{display:flex;min-height:48px;align-items:center;padding:0 20px;border-bottom:1px solid var(--color-border);background:var(--color-surface-raised)}.detail-provider{display:flex;align-items:center;gap:9px;color:var(--color-ink-muted)}.detail-provider>:global(svg){color:var(--color-accent)}.detail-provider strong{font-size:13px;font-weight:600}.detail-header-actions{display:flex;align-items:center;gap:3px;margin-left:auto}.detail-content{display:grid;min-height:0;flex:1;grid-template-columns:minmax(0,1fr) 300px;gap:28px;overflow:hidden;padding:0 28px}.detail-main{display:flex;min-width:0;min-height:0;flex-direction:column;overflow:hidden}.detail-title{flex:0 0 auto;padding:18px 0 0;border-bottom:1px solid var(--color-border-subtle)}.detail-title h2{margin:7px 0 10px;font-size:20px;font-weight:520;line-height:1.25}.detail-title .detail-summary{min-height:34px;border:0}.detail-title .detail-summary>span:last-child{color:var(--color-ink-muted);font-size:10.5px}.detail-main>.description{padding:13px 0}.detail-main>.comments-section{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden;padding:13px 0 18px}.detail-main .merge-summary{flex:0 0 auto;margin-bottom:11px;padding:0 12px;border:1px solid var(--color-border);background:var(--color-surface)}.detail-main .comment-list{min-height:0;flex:1}.detail-sidebar{display:flex;min-height:0;flex-direction:column;gap:0;overflow:hidden;padding:18px 0;border-left:1px solid var(--color-border-subtle);padding-left:22px;background:var(--color-surface)}.detail-sidebar .detail-actions{display:grid;grid-template-columns:1fr;gap:6px;margin:0 0 8px}.detail-sidebar section{padding:17px 0;border-bottom:1px solid var(--color-border-subtle)}.detail-sidebar section header{display:flex;align-items:center;justify-content:space-between;gap:12px}.detail-sidebar section h3{margin:0;color:var(--color-ink-muted);font-size:11px;font-weight:560}.people{display:grid;grid-template-columns:24px minmax(0,1fr);align-items:center;gap:8px;margin-top:12px}.people.compact{grid-template-columns:24px}
@media(max-width:900px){.detail-content{grid-template-columns:minmax(0,1fr) 270px;gap:18px;padding:0 18px}.detail-sidebar{padding-left:16px}.detail-title h2{font-size:17px}}
@media(max-width:680px){.detail-content{display:block;overflow:hidden;padding:0 15px}.detail-main{height:100%}.detail-sidebar{display:none}.detail-panel{inset:0}}
.comment-list{align-content:start;grid-auto-rows:max-content}
@@ -567,7 +553,7 @@
.action-menu{top:36px;width:190px;padding:6px;border-color:color-mix(in srgb,var(--color-border) 86%,var(--color-accent));box-shadow:0 12px 30px #000b,0 0 0 1px #0005;background:color-mix(in srgb,var(--color-surface-raised) 92%,#10151a)}.action-menu:before{position:absolute;top:-5px;right:10px;width:8px;height:8px;transform:rotate(45deg);border-top:1px solid var(--color-border);border-left:1px solid var(--color-border);background:inherit;content:""}.action-menu button{position:relative;height:34px;gap:10px;padding:0 10px;border-left:2px solid transparent;color:var(--color-ink-muted);font-size:10.5px}.action-menu button:hover:not(:disabled){color:var(--color-ink);border-left-color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 8%,var(--color-surface-hover))}.action-menu button>:global(svg){flex:0 0 auto}.action-menu .menu-merge>:global(svg){color:#72d58f}.action-menu .menu-approve>:global(svg),.action-menu .menu-provider>:global(svg){color:var(--color-accent)}.action-menu button.danger{margin-top:4px;border-top:1px solid var(--color-border-subtle);color:#e7767e}.action-menu button.danger:hover{border-left-color:#e7767e;background:color-mix(in srgb,#e7767e 8%,var(--color-surface-hover))}.action-menu .menu-conflict{color:#e2ac58}.action-menu button:disabled{opacity:.48}
.detail-panel{width:50%;min-width:720px;max-width:960px;box-shadow:-14px 0 30px #0008}.detail-header{min-height:47px;padding:0 16px}.detail-provider strong{font-size:12px}.detail-content{grid-template-columns:minmax(0,1fr) 250px;gap:20px;padding:0 16px}.detail-title{padding:15px 0 10px}.detail-title h2{margin:5px 0 8px;font-size:18px;font-weight:620}.detail-summary{min-height:27px;border:0}.detail-summary .avatar{margin-left:3px}
.detail-main>.merge-summary{min-height:43px;margin:11px 0 0;padding:0 10px;border:1px solid color-mix(in srgb,#63c783 48%,var(--color-border));background:color-mix(in srgb,#63c783 6%,var(--color-surface))}.detail-main>.merge-summary.conflict{border-color:color-mix(in srgb,#d6a64f 55%,var(--color-border));color:#d6a64f;background:color-mix(in srgb,#d6a64f 7%,var(--color-surface))}.merge-spacer{flex:1}.detail-change-stats{display:flex;align-items:center;gap:7px;white-space:nowrap;font-size:9px}.detail-change-stats b{color:#63c783}.detail-change-stats em{color:#e0737b;font-style:normal}.detail-change-stats span{color:var(--color-ink-faint)}
.detail-main>.description{padding:12px 0}.detail-main>.comments-section{padding:12px 0 14px}.comment-list{gap:8px}.comment-list article{gap:8px}.comment-list article>div{padding:8px 9px;border-radius:0;background:color-mix(in srgb,var(--color-surface) 48%,var(--app-bg))}.comment-composer{margin-top:9px;padding-top:9px;border-top:1px solid var(--color-border-subtle)}.comment-composer textarea{min-height:72px;max-height:92px}.comment-composer button{color:var(--color-accent);border-color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 6%,var(--color-surface))}
.detail-main>.description{padding:12px 0}.detail-main>.comments-section{padding:12px 0 14px}.comment-list{gap:8px}.comment-list article{gap:8px}.comment-list article>div{padding:8px 9px;border-radius:0;background:color-mix(in srgb,var(--color-surface) 48%,var(--app-bg))}
.detail-sidebar{padding:15px 0 15px 16px;background:color-mix(in srgb,var(--color-surface) 58%,var(--app-bg))}.detail-sidebar .detail-actions{gap:6px;margin-bottom:9px}.detail-action{height:31px}.detail-sidebar section{padding:14px 0}.people{grid-template-columns:23px minmax(0,1fr);gap:8px;margin-top:10px}.people strong,.detail-meta strong,.detail-meta span{overflow:hidden;color:var(--color-ink-muted);font-size:10px;font-weight:500;text-overflow:ellipsis}.detail-meta{display:grid;gap:8px}.detail-actions>.danger-action:first-child{color:#e0aa55;border-color:#b88432;background:color-mix(in srgb,#b88432 8%,var(--color-surface))}
.local-resolution{margin:8px 0 0;border-color:color-mix(in srgb,#d6a64f 55%,var(--color-border));background:color-mix(in srgb,#d6a64f 7%,var(--color-surface))}.local-resolution>div>:global(svg){color:#d6a64f}.local-resolution button{color:#e0aa55;border-color:#b88432;background:color-mix(in srgb,#b88432 10%,var(--color-surface))}
@media(max-width:1280px){.review-toolbar{grid-template-columns:auto minmax(180px,1fr) 155px auto}.state-tabs button{min-width:52px;padding:0 6px}.table-head,.request-row{grid-template-columns:80px minmax(250px,1.5fr) 110px minmax(190px,1fr) 180px}.table-head>span:nth-child(4),.collaborators{display:none}.request-groups{min-width:900px}.detail-panel{width:58%;min-width:680px}}
@@ -580,7 +566,6 @@
.table-head,.request-row{grid-template-columns:90px minmax(255px,1.65fr) 105px 120px minmax(180px,1fr) 150px}.table-head{min-height:37px;padding:0 16px;background:color-mix(in srgb,var(--color-surface-raised) 88%,var(--app-bg));font-size:8.5px}.request-groups{min-width:900px}.group-header{height:45px;padding:0 17px;background:color-mix(in srgb,var(--app-bg) 72%,var(--color-surface))}.group-header strong{font-size:11.5px}.request-row{min-height:78px;padding:0 16px}.request-row.selected{background:linear-gradient(90deg,color-mix(in srgb,var(--color-accent) 7%,var(--color-surface-raised)),color-mix(in srgb,var(--color-surface-raised) 72%,var(--app-bg)))}.request-title{gap:8px}.request-title strong{font-size:12px}.request-status{font-size:10.5px}.request-author i,.avatar,.collaborators i{width:28px;height:28px;background:color-mix(in srgb,var(--color-accent) 58%,#26333a)}.provider-button,.action-toggle,.panel-button{height:34px}.provider-button{min-width:102px}.action-toggle{width:34px}.panel-button{width:34px}.action-menu{top:38px;width:154px}.action-menu button{height:36px}
.detail-panel{width:44%;min-width:680px;max-width:none;background:color-mix(in srgb,var(--app-bg) 94%,var(--color-surface))}.detail-header{min-height:54px;padding:0 24px;background:color-mix(in srgb,var(--app-bg) 78%,var(--color-surface))}.detail-provider strong{font-size:13px}.detail-content{grid-template-columns:minmax(0,1fr) 225px;gap:18px;padding:0 0 0 24px}.detail-main{padding-right:0}.detail-title{padding:18px 0 16px}.detail-title-line{display:flex;min-width:0;align-items:baseline;gap:10px}.detail-title-line>span{flex:0 0 auto;color:var(--color-accent);font-size:17px;font-weight:700}.detail-title h2{min-width:0;margin:0;overflow:hidden;font-size:19px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.detail-title .detail-summary{min-height:33px;gap:7px}.detail-summary .avatar{width:23px;height:23px;margin-left:5px}.detail-summary strong{font-size:10.5px}.summary-separator{color:var(--color-ink-faint)}.detail-branch-route{display:flex;min-width:0;align-items:center;gap:6px;color:var(--color-ink-muted)}.detail-branch-route>:global(svg){color:var(--color-ink-muted)}.detail-branch-route code{max-width:95px;overflow:hidden;color:var(--color-ink-muted);font-size:10.5px;text-overflow:ellipsis;white-space:nowrap}.detail-branch-route>span{color:var(--color-ink-faint)}.state-badge{padding:0;border:0;font-size:10.5px}
.detail-main>.merge-summary{min-height:55px;margin:0 0 4px;padding:0 12px;border-color:color-mix(in srgb,#63c783 65%,var(--color-border));background:color-mix(in srgb,#63c783 5%,var(--app-bg))}.detail-main>.description{padding:14px 0 18px}.description h3,.comments-section h3{font-size:11.5px}.description p{font-size:10.5px}.detail-main>.comments-section{padding:13px 0 0}.comments-section>header{min-height:28px;margin:0 0 8px}.comments-section>header>span{border-radius:1px}.comment-list{gap:10px;padding-right:0}.comment-list article{display:block}.comment-card{padding:0!important;border:1px solid var(--color-border)!important;background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))!important}.comment-card header{min-height:42px;margin:0!important;padding:0 10px;border-bottom:0}.comment-card header .avatar{width:25px;height:25px;margin-right:2px}.comment-card header strong{font-size:10.5px}.comment-card header time{margin-left:auto}.owner-badge{padding:3px 6px;border:1px solid var(--color-border);color:var(--color-ink-faint);font-size:8.5px}.comment-card p{padding:0 44px 13px!important;color:var(--color-ink)!important}
.comment-composer{display:grid;gap:0;margin:0;padding:14px 0 18px;border-top:1px solid var(--color-border-subtle)}.comment-composer textarea{min-height:86px;max-height:120px;resize:none;padding:11px 12px;border-bottom:0;background:color-mix(in srgb,var(--app-input-bg) 94%,#11171c)}.comment-composer footer{display:flex;min-height:39px;align-items:center;justify-content:space-between;border:1px solid var(--color-border-input);border-top:0;background:color-mix(in srgb,var(--app-bg) 85%,var(--color-surface))}.format-actions{display:flex;align-items:center;padding-left:5px}.comment-composer .format-actions button{display:grid;width:30px;height:30px;place-items:center;padding:0;border:0;color:var(--color-ink-muted);background:transparent}.comment-composer .format-actions button:hover{color:var(--color-ink);background:var(--color-surface-hover)}.comment-composer .submit-comment{height:29px;margin-right:6px;padding:0 11px;border:1px solid var(--color-accent);border-radius:0;color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 5%,var(--app-bg))}.comment-composer .submit-comment:hover:not(:disabled){color:#fff;background:color-mix(in srgb,var(--color-accent) 72%,var(--app-bg))}
.detail-sidebar{padding:28px 17px 16px;border-left-color:var(--color-border);background:color-mix(in srgb,var(--color-surface) 46%,var(--app-bg))}.detail-sidebar .detail-actions{gap:10px;margin:0 0 12px}.detail-action{height:39px;font-size:11px}.detail-sidebar section{padding:17px 0}.detail-sidebar section h3{font-size:11.5px}.people{margin-top:12px}.detail-meta{gap:11px}
@media(max-width:1280px){.review-toolbar{grid-template-columns:310px minmax(180px,1fr) 145px 34px}.state-tabs{gap:3px}.state-tabs button{min-width:64px}.table-head,.request-row{grid-template-columns:80px minmax(250px,1.5fr) 105px minmax(190px,1fr) 150px}.request-groups{min-width:840px}.detail-panel{width:50%;min-width:650px}}
@media(max-width:900px){.review-toolbar{grid-template-columns:1fr 150px 34px;grid-template-rows:40px 40px}.detail-panel{width:72%;min-width:600px}.detail-content{grid-template-columns:minmax(0,1fr) 205px;gap:14px;padding-left:16px}.table-head,.request-row{grid-template-columns:76px minmax(240px,1.5fr) minmax(180px,1fr) 150px}.request-groups{min-width:720px}}
@@ -593,10 +578,10 @@
.review-heading h1,.detail-provider strong{font-size:14px}.detail-provider>:global(svg){display:none}.detail-header{gap:4px}.detail-header-actions{display:contents}.detail-header-actions button:first-child{margin-right:auto}.detail-provider{flex:none}
.review-toolbar{height:60px;min-height:60px}.review-search input{font-size:13px}.review-center .state-tabs button{font-size:13px}.state-tabs span{font-size:11px}.table-pane{padding:8px 7px 0;box-sizing:border-box}.table-head{padding:0 16px;height:37px}.table-head,.request-row{grid-template-columns:100px minmax(210px,1fr) 106px 120px 165px 145px}.table-head>span{font-size:10px}.request-groups{min-width:900px}.group-header{height:44px;padding:0 12px}.group-header strong{font-size:13px}.request-row{padding:0 10px;min-height:78px}.request-row.selected{outline-color:color-mix(in srgb,var(--color-accent) 50%,var(--color-border));background:color-mix(in srgb,var(--app-bg) 93%,var(--color-accent));box-shadow:none}.request-row.selected:before{width:2px;box-shadow:none}.request-status{font-size:13px;font-weight:400}.request-status>span{font-weight:400}.request-status small{font-size:11px}.request-title strong{font-size:13px;font-weight:650}.request-title code{font-size:12px}.change-stats{font-size:12px}.request-author{font-size:12px}.repo-branch>strong{font-size:12px;font-weight:500}.branch-route code{padding:0;background:none;font-size:11px}.branch-route{gap:6px}
.review-center .provider-button{min-width:68px;padding:0 8px;font-size:13px;font-weight:500}.provider-button.merge-primary>:global(svg){display:none}.provider-action .action-toggle{width:32px;background:transparent}.row-actions{gap:7px}.panel-button{margin:0;width:32px}.action-menu{width:154px;padding:4px;background:color-mix(in srgb,var(--app-bg) 90%,var(--color-surface-raised));border:1px solid var(--color-border);box-shadow:0 6px 12px #0005}.action-menu button{font-size:13px;gap:10px;height:37px;padding:0 10px}.action-menu button.danger{margin:0;border-top:0;color:var(--color-ink)}.action-menu button.danger>:global(svg){color:#e7767e}.action-menu .menu-merge>:global(svg),.action-menu .menu-approve>:global(svg),.action-menu .menu-provider>:global(svg){color:var(--color-ink-muted)}
.detail-panel{min-width:0;width:var(--inspector-width,44%);box-shadow:-2px 0 8px #0002}.detail-content{grid-template-columns:minmax(0,1fr) 225px;gap:0;padding:0}.detail-main{padding:0 18px 0 24px}.detail-sidebar{padding:28px 17px 16px;background:transparent;border-left:1px solid var(--review-divider)}.detail-title{padding:17px 0 9px;border:0}.detail-title h2{font-size:21px;font-weight:650}.detail-title-line>span{font-size:20px;color:var(--color-ink-muted)}.detail-title .detail-summary{min-height:34px;gap:7px}.detail-summary strong{font-size:12px;font-weight:400}.state-badge{font-size:13px;font-weight:400}.detail-branch-route code{font-size:13px}.detail-main>.merge-summary{min-height:54px;margin:0;padding:0 11px;gap:10px}.merge-summary>span,.merge-summary>strong{font-size:12px}.detail-change-stats{font-size:12px;gap:9px}.merge-summary>:global(svg){flex:none}.detail-main>.description{padding:20px 0 22px}.description h3{font-size:14px;font-weight:600;margin-bottom:10px}.description p{font-size:13px}.detail-main>.comments-section{padding:12px 0 0;overflow:visible;border:0}.comments-section>header{margin-bottom:10px}.comments-section h3{font-size:14px;font-weight:600}.comments-section>header>span{font-size:10px}.comment-list{gap:10px;margin:0;scrollbar-gutter:auto}.comment-list article .comment-card{min-height:78px;border-color:var(--review-divider)!important;background:transparent!important}.comment-list article header{justify-content:flex-start;gap:5px;padding:0 11px}.comment-card header .avatar{margin-right:5px;width:28px;height:28px}.comment-list article strong{font-size:12px;font-weight:500}.comment-list article time{font-size:11px;margin:0}.owner-badge{margin-left:auto;font-size:10px}.comment-list article p{font-size:12px;padding:2px 14px 15px 56px!important}.comment-composer{margin-top:auto;padding:16px 0 26px}.comment-composer textarea{font-size:13px;min-height:86px;height:86px;max-height:86px;border-bottom:1px solid var(--color-border-input);background:transparent}.comment-composer footer{margin-top:8px;border:0;background:transparent;min-height:39px}.comment-composer .submit-comment{height:37px;margin:0;font-size:13px}.format-actions{gap:4px;padding:0}.comment-composer .format-actions button{width:27px}.detail-sidebar .detail-actions{gap:15px;margin-bottom:27px}.review-center .detail-action{font-size:13px;position:relative}.detail-action>:global(svg){position:absolute;left:12px;width:18px;height:18px}.detail-action.primary-action{background:color-mix(in srgb,#63c783 4%,transparent)}.detail-sidebar section{padding:16px 0;border-top:1px solid var(--review-divider);border-bottom:0}.detail-sidebar section h3{font-size:14px;font-weight:600}.people.compact{display:flex;gap:10px}.people strong,.detail-meta strong,.detail-meta span{font-size:12px}.detail-meta strong,.detail-meta span{display:flex;align-items:center;gap:10px}.detail-meta :global(svg){flex:none;color:var(--color-ink-muted)}
@media(max-width:1450px){.review-center.has-inspector{--inspector-width:52%}.detail-content{grid-template-columns:minmax(0,1fr) 190px}.detail-main{padding:0 14px}.detail-title h2{font-size:17px}.detail-title-line>span{font-size:17px}.detail-summary{flex-wrap:wrap}.detail-main>.merge-summary{flex-wrap:wrap;gap:7px;padding:9px}.detail-change-stats{font-size:10px}.detail-main>.merge-summary>span{font-size:11px}.detail-sidebar{padding:24px 12px}.format-actions{gap:0}.comment-composer .format-actions button{width:23px}}
.detail-panel{min-width:0;width:var(--inspector-width,44%);box-shadow:-2px 0 8px #0002}.detail-content{grid-template-columns:minmax(0,1fr) 225px;gap:0;padding:0}.detail-main{padding:0 18px 0 24px}.detail-sidebar{padding:28px 17px 16px;background:transparent;border-left:1px solid var(--review-divider)}.detail-title{padding:17px 0 9px;border:0}.detail-title h2{font-size:21px;font-weight:650}.detail-title-line>span{font-size:20px;color:var(--color-ink-muted)}.detail-title .detail-summary{min-height:34px;gap:7px}.detail-summary strong{font-size:12px;font-weight:400}.state-badge{font-size:13px;font-weight:400}.detail-branch-route code{font-size:13px}.detail-main>.merge-summary{min-height:54px;margin:0;padding:0 11px;gap:10px}.merge-summary>span,.merge-summary>strong{font-size:12px}.detail-change-stats{font-size:12px;gap:9px}.merge-summary>:global(svg){flex:none}.detail-main>.description{padding:20px 0 22px}.description h3{font-size:14px;font-weight:600;margin-bottom:10px}.description p{font-size:13px}.detail-main>.comments-section{padding:12px 0 0;overflow:visible;border:0}.comments-section>header{margin-bottom:10px}.comments-section h3{font-size:14px;font-weight:600}.comments-section>header>span{font-size:10px}.comment-list{gap:10px;margin:0;scrollbar-gutter:auto}.comment-list article .comment-card{min-height:78px;border-color:var(--review-divider)!important;background:transparent!important}.comment-list article header{justify-content:flex-start;gap:5px;padding:0 11px}.comment-card header .avatar{margin-right:5px;width:28px;height:28px}.comment-list article strong{font-size:12px;font-weight:500}.comment-list article time{font-size:11px;margin:0}.owner-badge{margin-left:auto;font-size:10px}.comment-list article p{font-size:12px;padding:2px 14px 15px 56px!important}.detail-sidebar .detail-actions{gap:15px;margin-bottom:27px}.review-center .detail-action{font-size:13px;position:relative}.detail-action>:global(svg){position:absolute;left:12px;width:18px;height:18px}.detail-action.primary-action{background:color-mix(in srgb,#63c783 4%,transparent)}.detail-sidebar section{padding:16px 0;border-top:1px solid var(--review-divider);border-bottom:0}.detail-sidebar section h3{font-size:14px;font-weight:600}.people.compact{display:flex;gap:10px}.people strong,.detail-meta strong,.detail-meta span{font-size:12px}.detail-meta strong,.detail-meta span{display:flex;align-items:center;gap:10px}.detail-meta :global(svg){flex:none;color:var(--color-ink-muted)}
@media(max-width:1450px){.review-center.has-inspector{--inspector-width:52%}.detail-content{grid-template-columns:minmax(0,1fr) 190px}.detail-main{padding:0 14px}.detail-title h2{font-size:17px}.detail-title-line>span{font-size:17px}.detail-summary{flex-wrap:wrap}.detail-main>.merge-summary{flex-wrap:wrap;gap:7px;padding:9px}.detail-change-stats{font-size:10px}.detail-main>.merge-summary>span{font-size:11px}.detail-sidebar{padding:24px 12px}}
@media(max-width:900px){.review-center.has-inspector{--inspector-width:100%}.detail-content{display:grid;grid-template-columns:minmax(0,1fr) 200px}.detail-sidebar{display:flex}.detail-main{height:auto}.detail-panel{inset:0 0 0 auto;min-width:0}.group-actions .icon-button{display:flex!important}}
@media(max-width:520px){.detail-content{display:flex;flex-direction:column}.detail-main{flex:1;min-height:0}.detail-sidebar{flex:none;padding:8px 14px;border-top:1px solid var(--review-divider);border-left:0}.detail-sidebar section{display:none}.detail-sidebar .detail-actions{grid-template-columns:1fr 1fr;gap:6px;margin:0}.detail-action{height:32px}.detail-header{padding:0 14px}.detail-main>.description{padding:10px 0}.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. */
@@ -628,4 +613,5 @@
.review-center .action-menu button.danger>:global(svg){color:#e7767e}
.review-center .action-menu button.menu-conflict{color:#e2ac58}
.review-center .action-menu button:hover:not(:disabled){background:var(--color-surface-hover)}
.review-comment-editor{flex:0 0 auto;min-width:0;padding:14px 0 18px;border-top:1px solid var(--color-border-subtle)}
</style>
+27
View File
@@ -675,3 +675,30 @@ export function resolveConflictSide(
): Promise<GitStatus> {
return invoke<GitStatus>("resolve_conflict_side", { path, file, side });
}
export function listIntegrationIssues(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, cursor: string | null = null): Promise<import("./types").IntegrationIssuePage> {
return invoke("list_integration_issues", { provider, baseUrl, username, token, cursor });
}
export function getIntegrationBoard(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, boardUrl: string): Promise<import("./types").IntegrationBoard> {
return invoke("get_integration_board", { provider, baseUrl, username, token, boardUrl });
}
export function listIntegrationBoards(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string): Promise<{ boards: import("./types").IntegrationBoardReference[]; warnings: string[] }> {
return invoke("list_integration_boards", { provider, baseUrl, username, token });
}
export function moveIntegrationBoardCard(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, boardUrl: string, cardId: string, sourceColumnId: string, targetColumnId: string): Promise<void> {
return invoke("move_integration_board_card", { provider, baseUrl, username, token, boardUrl, cardId, sourceColumnId, targetColumnId });
}
export function listIntegrationIssueComments(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number, cursor: string | null): Promise<{ comments: import("./types").IssueComment[]; nextCursor: string | null }> {
return invoke("list_integration_issue_comments", { provider, baseUrl, username, token, repository, number, cursor });
}
export function addIntegrationIssueComment(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number, body: string): Promise<import("./types").IssueComment> {
return invoke("add_integration_issue_comment", { provider, baseUrl, username, token, repository, number, body });
}
export function closeIntegrationIssue(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number): Promise<string> {
return invoke("close_integration_issue", { provider, baseUrl, username, token, repository, number });
}
+20
View File
@@ -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";
}
+253
View File
@@ -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}
+43
View File
@@ -448,3 +448,46 @@ export interface StoredCredential {
password: string;
mode?: "credentials" | "token";
}
export interface IntegrationIssue {
id: string;
number: number;
title: string;
description: string;
descriptionHtml: boolean;
repositoryName: string;
author: string;
state: string;
labels: string[];
assignees: string[];
webUrl: string;
updatedAt: string;
}
export interface IntegrationIssuePage {
issues: IntegrationIssue[];
nextCursor: string | null;
}
export interface IntegrationBoardCard {
id: string;
title: string;
number: number;
webUrl: string;
repository: string;
description: string;
labels: string[];
assignees: string[];
lane: string;
}
export interface IntegrationBoard {
title: string;
webUrl: string;
notice: string;
columns: Array<{ id: string; title: string; limit: number; moveTarget?: Record<string, unknown> | null; cards: IntegrationBoardCard[] }>;
}
export interface IntegrationBoardReference { title: string; webUrl: string; scope: string }
export interface IssueComment { id: string; author: string; body: string; createdAt: string; bodyHtml: boolean }