Compare commits

...
13 Commits
Author SHA1 Message Date
Christoph 8c652b5df8 feat(repo-tabs): replace tab bar with workspace navigation
publish / Build and publish Windows installer (release) Successful in 8m44s
publish / Build and publish Ubuntu AppImage (release) Successful in 9m14s
publish / Build and publish AUR packages (release) Successful in 18m56s
Replace the legacy repository tab bar with a compact workspace
navigation that groups primary views and exposes a dedicated
"Repositories" action. The repository list is shown only when the
repository view is active and supports per-repo selection and closing.
Also add a Repositories open handler in the app to select the active
repo or prompt to choose a repository folder when none is available.

- Introduce top-level navigation (Dashboard, PRs, Issues, Repositories)
- Add onOpenRepositories hook and app logic to select or choose repo
- Update icons, styles, and accessibility attributes for the nav
2026-09-08 22:10:16 +02:00
Christoph 200f01185a feat(review): add integration review APIs and adjust UI
Add client-side wrappers for creating integration review requests and
for listing repository branches, and wire native command imports so the
app can call those integration endpoints. Update the review center UI to
include a create-review dialog import and refine table and provider
button layout for improved spacing and readability.

- Add wrappers for create review requests and branch listing
- Wire native integration imports and prepare the create-review dialog
- Adjust table sizing and provider button layout for better spacing
2026-09-08 22:00:08 +02:00
Christoph 814f0e3a40 Merge pull request 'feat(integrations): add multi-provider board and issue integrations' (#41) from issue-center into main 2026-09-08 19:56:10 +00:00
Christoph Brandau cd224fcc55 Merge remote-tracking branch 'origin/main' into issue-center
# Conflicts:
#	src-tauri/src/main.rs
#	src/lib/components/ReviewCenter.svelte
#	src/lib/git.ts
2026-09-08 21:55:51 +02:00
Christoph 298bda2b54 Merge branch 'main' of https://git.cbsk-tech.de/Christoph/GitLite 2026-09-08 21:49:32 +02:00
Christoph b7a5bfe29f style(review-center): improve responsive table and toolbar layout
Adjust CSS to make the review center more robust at mid and narrow
viewports. This increases several min-width thresholds, tweaks grid
column sizing and padding, and refines toolbar/button sizing to avoid
content wrapping and truncated actions. The result keeps lists and
controls visible and aligned across a wider range of window sizes.

- Raise table/group min-widths and adjust column widths to prevent wrap
- Increase provider button min-width and disable shrinking for clarity
2026-09-08 21:49:29 +02:00
Christoph ee35169feb feat(create-review): load repository branches and suggest local branch
Add a backend command to enumerate a repository's branches and default
branch for configured integrations, and expose it to the UI. The create
review dialog now fetches branches, shows loading and error states, and
uses searchable SelectMenu controls for repositories and branches. If a
local repository path is available the dialog will try to match remotes
and preselect a local branch that exists on the remote to streamline
review creation.

- Add integration branch listing command and wire it into the dialog
- Replace plain selects with searchable SelectMenu and improved UX
- Attempt to detect and suggest a matching local source branch when possible
2026-09-08 21:41:29 +02:00
Christoph c7d6fa680a Merge pull request 'Create PRs' (#40) from review-center into main 2026-09-08 19:41:12 +00:00
Christoph f7f85d387d feat(integrations): add create integration review request API and UI
Add a new Tauri command and front-end flow to create PRs/MRs.
The backend builds and validates provider-specific payloads and
endpoints, sends creation requests, and parses responses into the
app's review model. A Svelte dialog and a JS wrapper wire the UI to the
command so users can create requests from the Review Center.

- Implement provider payload and endpoint logic with unit tests
- Expose create_integration_review_request as a Tauri command
- Add CreateReviewDialog UI and integrate a creation button in Review Center
2026-09-08 21:32:12 +02:00
Christoph 0539020545 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
2026-09-08 21:19:54 +02:00
Christoph 80c7cf7280 Merge branch 'main' of https://git.cbsk-tech.de/Christoph/GitLite 2026-09-08 10:04:32 +02:00
Christoph 1b2460761f feat(dashboard): extract PR status loader and surface badges
Move pull-request discovery and caching into a dedicated background
loader component. The repository dashboard no longer performs the
network/keychain work itself and instead receives a shared badge state
that is refreshed and persisted independently. The app wires the loader
into top-level state and binds badge data into the dashboard view.

- Add a background loader to fetch, group and cache open PR counts.
- Simplify dashboard to accept and render pull-request badge state.
- Wire loader into app-level state and bind badges to the dashboard.
2026-09-08 10:04:20 +02:00
Christoph 9e373e77d1 Update version to 2026.9.3 2026-09-08 08:35:00 +02:00
31 changed files with 3730 additions and 263 deletions
+25 -2
View File
@@ -1,18 +1,20 @@
{
"name": "gitty",
"version": "2026.9.2",
"version": "2026.9.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitty",
"version": "2026.9.2",
"version": "2026.9.3",
"dependencies": {
"@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"dompurify": "^3.4.15",
"marked": "^18.0.12",
"simple-icons": "^16.24.1",
"svelte": "^5.0.0",
"tailwindcss": "^4.3.1"
@@ -1557,6 +1559,15 @@
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz",
"integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/enhanced-resolve": {
"version": "5.21.6",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
@@ -1975,6 +1986,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "18.0.12",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.12.tgz",
"integrity": "sha512-LEm4ga2YeI2T3GVHj9b0BaDPPk93LLTHMFeMyQbNIzPxc8vCI0y/scy0ZA6z6lXKyT9j9Nhl/OC6ZYKYGuFScA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitty",
"version": "2026.9.2",
"version": "2026.9.3",
"private": true,
"type": "module",
"scripts": {
@@ -20,6 +20,8 @@
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"dompurify": "^3.4.15",
"marked": "^18.0.12",
"simple-icons": "^16.24.1",
"svelte": "^5.0.0",
"tailwindcss": "^4.3.1"
+13 -1
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};
@@ -5,11 +14,14 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::time::Duration;
mod creation;
pub use creation::{create_integration_review_request, list_integration_repository_branches};
const PAGE_SIZE: usize = 100;
const REVIEW_REQUEST_TIMEOUT: Duration = Duration::from_secs(35);
const AZURE_PROJECT_WORKERS: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IntegrationRepository {
pub id: String,
@@ -0,0 +1,177 @@
//! Discover boards without changing provider data. Return partial results with explicit warnings.
use super::*;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BoardReference { title: String, web_url: String, scope: String }
#[derive(Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BoardDirectory { boards: Vec<BoardReference>, warnings: Vec<String> }
impl BoardDirectory {
fn add(&mut self, api: &BoardApi, title: String, web_url: String, scope: String) {
if board_path(api.base, &web_url).is_ok() && !self.boards.iter().any(|b| b.web_url == web_url) {
self.boards.push(BoardReference { title, web_url, scope });
}
}
fn warn(&mut self, scope: &str, error: String) { self.warnings.push(format!("{scope}: {error}")); }
}
fn rest_directory(api: &BoardApi, out: &mut BoardDirectory) -> Result<(), String> {
if api.provider == "gitea" {
// The deployed server schema is more useful than guessing from its version.
if let Ok(schema) = api.get(endpoint(api.base, &["swagger.v1.json"])?) {
if schema["paths"].as_object().is_some_and(|paths| !paths.contains_key("/repos/{owner}/{repo}/projects")) {
return Err("This Gitea server publishes no Projects/Columns API. Automatic board discovery and import are unavailable; use the original web board.".into());
}
}
let repos = api.pages(endpoint(api.base, &["api", "v1", "user", "repos"])?, &[])?;
for repo in repos {
if repo["has_projects"] == false { continue; }
let full = value_string(&repo, &["full_name"]);
let Some((owner, name)) = full.split_once('/') else { continue; };
let result = api.pages(endpoint(api.base, &["api", "v1", "repos", owner, name, "projects"])?, &[("state".into(), "open".into())]);
match result {
Ok(boards) => for board in boards {
let id = json_id(&board);
out.add(api, value_string(&board, &["title"]), endpoint(api.base, &[owner, name, "projects", &id])?, full.clone());
},
Err(e) => out.warn(&full, e),
}
}
match api.pages(endpoint(api.base, &["api", "v1", "user", "orgs"])?, &[]) {
Ok(orgs) => for org in orgs {
let name = value_string(&org, &["username"]);
let name = if name.is_empty() { value_string(&org, &["name"]) } else { name };
match api.pages(endpoint(api.base, &["api", "v1", "orgs", &name, "projects"])?, &[("state".into(), "open".into())]) {
Ok(boards) => for board in boards { out.add(api, value_string(&board, &["title"]), endpoint(api.base, &["org", &name, "projects", &json_id(&board)])?, name.clone()); },
Err(e) => out.warn(&name, e),
}
},
Err(e) => out.warn("Organizations", e),
}
} else {
for (scope, query) in [("projects", vec![("membership".into(), "true".into()), ("archived".into(), "false".into())]), ("groups", vec![("all_available".into(), "false".into())])] {
let entities = match api.pages(endpoint(api.base, &["api", "v4", scope])?, &query) { Ok(v) => v, Err(e) => { out.warn(scope, e); continue; } };
for entity in entities {
let id = json_id(&entity);
let name = value_string(&entity, &[if scope == "groups" { "full_path" } else { "path_with_namespace" }]);
let base = value_string(&entity, &["web_url"]);
match api.pages(endpoint(api.base, &["api", "v4", scope, &id, "boards"])?, &[]) {
Ok(boards) => for board in boards {
out.add(api, value_string(&board, &["name"]), format!("{}/-/boards/{}", base.trim_end_matches('/'), json_id(&board)), name.clone());
},
Err(e) => out.warn(&name, e),
}
}
}
}
Ok(())
}
fn github_directory(api: &BoardApi, out: &mut BoardDirectory) -> Result<(), String> {
let graphql = "https://api.github.com/graphql".to_string();
let mut owners = vec![];
let mut cursor = Value::Null;
for page in 0..20 {
let data = api.graphql(graphql.clone(), "query($cursor:String){viewer{login organizations(first:100,after:$cursor){nodes{login} pageInfo{hasNextPage endCursor}}}}", json!({"cursor":cursor}))?;
let viewer = &data["data"]["viewer"];
if page == 0 { owners.push(("user", value_string(viewer, &["login"]))); }
let orgs = &viewer["organizations"];
for org in orgs["nodes"].as_array().ok_or("Could not read GitHub organizations.")? { owners.push(("organization", value_string(org, &["login"]))); }
if orgs["pageInfo"]["hasNextPage"] != true { break; }
cursor = orgs["pageInfo"]["endCursor"].clone();
if page == 19 { out.warn("GitHub", "Organization page limit reached.".into()); }
}
for (kind, owner) in owners {
let query = format!("query($owner:String!,$cursor:String){{{kind}(login:$owner){{projectsV2(first:100,after:$cursor){{nodes{{title url closed}} pageInfo{{hasNextPage endCursor}}}}}}}}");
let mut cursor = Value::Null;
for page in 0..20 {
let data = match api.graphql(graphql.clone(), &query, json!({"owner":owner,"cursor":cursor})) { Ok(v) => v, Err(e) => { out.warn(&owner, e); break; } };
let projects = &data["data"][kind]["projectsV2"];
let Some(nodes) = projects["nodes"].as_array() else { out.warn(&owner, "Project list unavailable; check read:project access.".into()); break; };
for board in nodes { if board["closed"] != true { out.add(api, value_string(board, &["title"]), value_string(board, &["url"]), owner.clone()); } }
if projects["pageInfo"]["hasNextPage"] != true { break; }
cursor = projects["pageInfo"]["endCursor"].clone();
if page == 19 { out.warn(&owner, "Project page limit reached.".into()); }
}
}
Ok(())
}
fn azure_directory(api: &BoardApi, out: &mut BoardDirectory) -> Result<(), String> {
for page in 0..20 {
let teams = api.read(api.request(reqwest::Method::GET, endpoint(api.base, &["_apis", "teams"])?)
.query(&[("api-version", "7.1-preview.3".to_string()), ("$top", "100".into()), ("$skip", (page * 100).to_string())]))?;
let teams = teams["value"].as_array().ok_or("Could not read Azure teams.")?;
if teams.is_empty() { return Ok(()); }
for team in teams {
let project = value_string(team, &["projectName"]);
let name = value_string(team, &["name"]);
match api.read(api.request(reqwest::Method::GET, endpoint(api.base, &[&project, &name, "_apis", "work", "boards"])?)
.query(&[("api-version", "7.1")])) {
Ok(data) => {
let Some(boards) = data["value"].as_array() else { out.warn(&name, "Invalid Azure board list.".into()); continue; };
for board in boards {
let title = value_string(board, &["name"]);
out.add(api, title.clone(), endpoint(api.base, &[&project, "_boards", "board", "t", &name, &title])?, format!("{project} / {name}"));
}
},
Err(e) => out.warn(&name, e),
}
}
}
out.warn("Azure", "Team page limit reached.".into());
Ok(())
}
#[tauri::command]
pub async fn list_integration_boards(provider: String, base_url: String, username: String, token: String) -> Result<BoardDirectory, String> {
tauri::async_runtime::spawn_blocking(move || {
if token.trim().is_empty() { return Err("No token stored for this integration.".into()); }
let base = normalized_base_url(&base_url)?;
let api = BoardApi { client: Client::builder().connect_timeout(Duration::from_secs(7)).timeout(Duration::from_secs(12)).redirect(reqwest::redirect::Policy::none()).build().map_err(|e| e.to_string())?, provider: &provider, base: &base, username: &username, token: &token, started: Instant::now() };
let mut out = BoardDirectory::default();
let result = match provider.as_str() {
"gitea" | "gitlab" | "gitlab-self-hosted" => rest_directory(&api, &mut out),
"github" => github_directory(&api, &mut out),
"azure-devops" => azure_directory(&api, &mut out),
_ => Err("Unsupported board provider.".into()),
};
if let Err(e) = result { out.warn("Board discovery", e); }
out.boards.sort_by(|a,b| (&a.scope, &a.title).cmp(&(&b.scope, &b.title)));
Ok(out)
}).await.map_err(|e| e.to_string())?
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::tests::{mock_api, test_api};
#[test]
fn missing_gitea_routes_are_reported_without_querying_repositories() {
let (base, worker) = mock_api(vec![("/swagger.v1.json ", json!({"paths":{"/repos/{owner}/{repo}":{}}}))]);
let mut out = BoardDirectory::default();
let error = rest_directory(&test_api(&base,"gitea"), &mut out).unwrap_err();
assert!(error.contains("publishes no Projects/Columns API"));
assert!(out.boards.is_empty());
worker.join().unwrap();
}
#[test]
fn gitea_discovers_multiple_repository_boards_and_paginates() {
let (base, worker) = mock_api(vec![
("/swagger.v1.json ",json!({"paths":{"/repos/{owner}/{repo}/projects":{}}})),
("/user/repos?page=1",json!([{"full_name":"team/repo"}])),
("/user/repos?page=2",json!([])),
("/repos/team/repo/projects?state=open&page=1",json!([{"id":1,"title":"Delivery"},{"id":2,"title":"Roadmap"}])),
("/repos/team/repo/projects?state=open&page=2",json!([])),
("/user/orgs?page=1",json!([])),
]);
let mut out = BoardDirectory::default();
rest_directory(&test_api(&base,"gitea"), &mut out).unwrap();
assert_eq!(out.boards.len(),2);
assert_eq!(out.boards[0].web_url,format!("{base}/team/repo/projects/1"));
assert!(out.warnings.is_empty());
worker.join().unwrap();
}
}
+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
+168
View File
@@ -0,0 +1,168 @@
use super::*;
fn creation_payload(provider: &str, source: &str, target: &str, title: &str, description: &str) -> Result<serde_json::Value, String> {
if title.trim().is_empty() || source.trim().is_empty() || target.trim().is_empty() {
return Err("Title, source branch and target branch are required.".into());
}
if source == target { return Err("Source and target branch must be different.".into()); }
Ok(match provider {
"github" | "gitea" => serde_json::json!({"head":source,"base":target,"title":title,"body":description}),
"gitlab" | "gitlab-self-hosted" => serde_json::json!({"source_branch":source,"target_branch":target,"title":title,"description":description}),
"azure-devops" => serde_json::json!({"sourceRefName":format!("refs/heads/{source}"),"targetRefName":format!("refs/heads/{target}"),"title":title,"description":description}),
_ => return Err("Unsupported integration provider.".into()),
})
}
fn creation_endpoint(provider: &str, base: &str, repository: &IntegrationRepository) -> Result<reqwest::Url, String> {
let base = if provider == "github" { github_api_base_url(base)? } else { normalized_base_url(base)? };
let mut url = reqwest::Url::parse(&format!("{base}/")).map_err(|e| e.to_string())?;
{
let mut path = url.path_segments_mut().map_err(|_| "Invalid integration URL.")?;
path.pop_if_empty();
match provider {
"github" | "gitea" => {
let parts: Vec<_> = repository.full_name.split('/').collect();
if parts.len() != 2 || parts.iter().any(|part| part.is_empty() || *part == "." || *part == "..") {
return Err("Invalid repository name.".into());
}
if provider == "gitea" { path.extend(["api", "v1"]); }
path.push("repos").extend(parts).push("pulls");
}
"gitlab" | "gitlab-self-hosted" => { path.extend(["api","v4","projects", &repository.id,"merge_requests"]); }
"azure-devops" => { path.extend(["_apis","git","repositories", &repository.id,"pullrequests"]); }
_ => return Err("Unsupported integration provider.".into()),
}
}
if provider == "azure-devops" { url.query_pairs_mut().append_pair("api-version", "7.1"); }
Ok(url)
}
#[tauri::command]
pub async fn create_integration_review_request(provider: String, base_url: String, username: String, token: String, repository: IntegrationRepository, source_branch: String, target_branch: String, title: String, description: String) -> Result<IntegrationReviewRequest, String> {
tauri::async_runtime::spawn_blocking(move || {
if token.trim().is_empty() { return Err("No token is stored for this integration.".into()); }
let source = source_branch.trim().strip_prefix("refs/heads/").unwrap_or(source_branch.trim());
let target = target_branch.trim().strip_prefix("refs/heads/").unwrap_or(target_branch.trim());
let payload = creation_payload(&provider, source, target, title.trim(), &description)?;
let endpoint = creation_endpoint(&provider, &base_url, &repository)?;
let client = client()?;
let request = client.post(endpoint).header(USER_AGENT, "Gitty").header(ACCEPT, "application/json");
let request = match provider.as_str() {
"github" => request.bearer_auth(&token),
"gitea" => request.header("Authorization", format!("token {token}")),
"gitlab" | "gitlab-self-hosted" => request.header("PRIVATE-TOKEN", &token),
"azure-devops" => request.basic_auth(if username.is_empty() { "gitty" } else { &username }, Some(&token)),
_ => unreachable!(),
};
// Never automatically retry creation: a lost response can still mean the PR was created.
let response = request.json(&payload).send().map_err(|_| "The creation response could not be received. Check the original repository before trying again.".to_string())?;
if !response.status().is_success() { return Err(response_error(response, &provider)); }
let mut value: serde_json::Value = response.json().map_err(|_| "The request was created, but its response could not be read. Refresh the Review Center before trying again.".to_string())?;
let mut review = match provider.as_str() {
"github" => {
value["pull_request"] = serde_json::json!({});
value["repository_url"] = serde_json::json!(format!("{}/repos/{}", github_api_base_url(&base_url)?, repository.full_name));
parse_github_review(&value).ok_or("Could not read the created PR.")?
}
"gitea" => {
value["pull_request"] = value.clone();
value["repository"] = serde_json::json!({"id":repository.id.parse::<u64>().unwrap_or_default(),"full_name":repository.full_name});
parse_gitea_review(&value).ok_or("Could not read the created PR.")?
}
"gitlab" | "gitlab-self-hosted" => parse_gitlab_review(&value, &provider),
"azure-devops" => parse_azure_review(&value, &repository),
_ => unreachable!(),
};
review.repository_name = repository.full_name;
review.source_branch = source.to_string();
review.target_branch = target.to_string();
Ok(review)
}).await.map_err(|e| format!("Could not create review request: {e}"))?
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn creation_payloads_and_validation() {
for provider in ["github", "gitea", "gitlab", "gitlab-self-hosted", "azure-devops"] {
assert!(creation_payload(provider, "main", "main", "Test", "").is_err());
assert!(creation_payload(provider, "topic", "main", " ", "").is_err());
}
assert_eq!(creation_payload("github", "feature/test", "main", "Test", "Body").unwrap()["head"], "feature/test");
assert_eq!(creation_payload("gitea", "topic", "main", "Test", "Body").unwrap()["body"], "Body");
assert_eq!(creation_payload("gitlab-self-hosted", "topic", "main", "Test", "").unwrap()["source_branch"], "topic");
assert_eq!(creation_payload("azure-devops", "topic", "main", "Test", "").unwrap()["targetRefName"], "refs/heads/main");
}
#[test]
fn creation_urls_preserve_prefixes_and_encode_paths() {
let repository: IntegrationRepository = serde_json::from_value(serde_json::json!({"id":"17","name":"repo","fullName":"owner/repo","description":"","cloneUrl":"","sshUrl":"","webUrl":"","updatedAt":"","private":false})).unwrap();
assert_eq!(creation_endpoint("github", "https://github.com", &repository).unwrap().as_str(), "https://api.github.com/repos/owner/repo/pulls");
assert_eq!(creation_endpoint("gitea", "https://git.example/sub", &repository).unwrap().path(), "/sub/api/v1/repos/owner/repo/pulls");
assert_eq!(creation_endpoint("gitlab", "https://git.example", &repository).unwrap().path(), "/api/v4/projects/17/merge_requests");
assert_eq!(creation_endpoint("azure-devops", "https://dev.azure.com/org", &repository).unwrap().as_str(), "https://dev.azure.com/org/_apis/git/repositories/17/pullrequests?api-version=7.1");
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RepositoryBranches {
branches: Vec<String>,
default_branch: String,
}
#[tauri::command]
pub async fn list_integration_repository_branches(provider: String, base_url: String, username: String, token: String, repository: IntegrationRepository) -> Result<RepositoryBranches, String> {
tauri::async_runtime::spawn_blocking(move || {
if token.trim().is_empty() { return Err("No token is stored for this integration.".into()); }
let client = client()?;
let mut metadata_url = creation_endpoint(&provider, &base_url, &repository)?;
metadata_url.path_segments_mut().map_err(|_| "Invalid repository URL")?.pop();
let get = |url: reqwest::Url| -> Result<Response, String> {
let request = client.get(url).header(USER_AGENT, "Gitty").header(ACCEPT, "application/json");
let request = match provider.as_str() {
"github" => request.bearer_auth(&token),
"gitea" => request.header("Authorization", format!("token {token}")),
"gitlab" | "gitlab-self-hosted" => request.header("PRIVATE-TOKEN", &token),
"azure-devops" => request.basic_auth(&username, Some(&token)),
_ => return Err("Unsupported integration provider.".into()),
};
let response = request.send().map_err(|e| format!("Could not load branches: {e}"))?;
if !response.status().is_success() { return Err(response_error(response, &provider)); }
Ok(response)
};
let metadata: serde_json::Value = get(metadata_url.clone())?.json().map_err(|e| format!("Invalid repository response: {e}"))?;
let default_branch = value_string(&metadata, &[if provider == "azure-devops" { "defaultBranch" } else { "default_branch" }]).trim_start_matches("refs/heads/").to_string();
let mut branches = BTreeSet::new();
let mut continuation = String::new();
for page in 1..=1000 {
let mut url = metadata_url.clone();
{
let mut path = url.path_segments_mut().map_err(|_| "Invalid repository URL")?;
if provider.starts_with("gitlab") { path.push("repository"); }
path.push(if provider == "azure-devops" { "refs" } else { "branches" });
}
if provider == "azure-devops" {
url.query_pairs_mut().append_pair("filter", "heads/").append_pair("$top", "100");
if !continuation.is_empty() { url.query_pairs_mut().append_pair("continuationToken", &continuation); }
} else {
url.query_pairs_mut().append_pair("page", &page.to_string()).append_pair(if provider == "gitea" { "limit" } else { "per_page" }, "100");
}
let response = get(url)?;
let next = response.headers().get("x-ms-continuationtoken").and_then(|h| h.to_str().ok()).unwrap_or_default().to_string();
let data: serde_json::Value = response.json().map_err(|e| format!("Invalid branch response: {e}"))?;
let items = if provider == "azure-devops" { data.get("value") } else { Some(&data) }.and_then(serde_json::Value::as_array).ok_or("Invalid branch list.")?;
for item in items {
let name = value_string(item, &["name"]);
let name = if provider == "azure-devops" { name.strip_prefix("refs/heads/").unwrap_or(&name) } else { &name };
if !name.is_empty() { branches.insert(name.to_string()); }
}
if (provider == "azure-devops" && next.is_empty()) || (provider != "azure-devops" && items.len() < 100) {
return Ok(RepositoryBranches { branches: branches.into_iter().collect(), default_branch });
}
if provider == "azure-devops" && next == continuation { return Err("The server repeated its branch pagination token.".into()); }
continuation = next;
}
Err("The repository has too many branches to load completely.".into())
}).await.map_err(|e| format!("Could not load branches: {e}"))?
}
@@ -0,0 +1,53 @@
use super::*;
use super::issue_comments::{comment_url, request, comment_client};
use serde_json::{Value,json};
fn read(response: reqwest::blocking::Response, provider: &str) -> Result<Value,String> {
if !response.status().is_success() {return Err(response_error(response,provider));}
response.json().map_err(|e|e.to_string())
}
fn completed_state(value: &Value) -> Result<String,String> {
let states:Vec<_>=value["value"].as_array().into_iter().flatten().filter(|v|v["category"]=="Completed").filter_map(|v|v["name"].as_str()).filter(|s|!s.is_empty()).collect();
if states.len()!=1 {return Err("No unique completed state is configured for this work item type. Open the issue in Azure to select its state.".into());}
Ok(states[0].into())
}
#[tauri::command]
pub async fn close_integration_issue(provider:String,base_url:String,username:String,token:String,repository:String,number:u64) -> Result<String,String> {
tauri::async_runtime::spawn_blocking(move || {
let client=comment_client()?;
let mut url=comment_url(&provider,&base_url,&repository,number)?;
url.path_segments_mut().map_err(|_|"Invalid issue URL.")?.pop();
if provider=="azure-devops" {
url.set_query(Some("api-version=7.1"));
let item=read(request(&client,reqwest::Method::GET,url.clone(),&provider,&username,&token)?.send().map_err(|e|e.to_string())?,&provider)?;
let kind=value_string(&item,&["fields","System.WorkItemType"]);
let mut states_url=reqwest::Url::parse(&normalized_base_url(&base_url)?).map_err(|e|e.to_string())?;
states_url.path_segments_mut().map_err(|_|"Invalid Azure URL.")?.pop_if_empty().extend([&repository,"_apis","wit","workitemtypes",&kind,"states"]);
states_url.set_query(Some("api-version=7.1"));
let states=read(request(&client,reqwest::Method::GET,states_url,&provider,&username,&token)?.send().map_err(|e|e.to_string())?,&provider)?;
let state=completed_state(&states)?;
if item["fields"]["System.State"]==state {return Ok(state);}
let rev=item["rev"].as_u64().ok_or("Azure returned no revision.")?;
let payload=json!([{"op":"test","path":"/rev","value":rev},{"op":"add","path":"/fields/System.State","value":state}]);
let result=read(request(&client,reqwest::Method::PATCH,url,&provider,&username,&token)?.header("Content-Type","application/json-patch+json").json(&payload).send().map_err(|e|e.to_string())?,&provider)?;
let actual=value_string(&result,&["fields","System.State"]);
if actual!=state {return Err("Azure did not confirm the completed state.".into());}
Ok(actual)
} else {
let gitlab=provider.starts_with("gitlab");
let method=if gitlab {reqwest::Method::PUT}else{reqwest::Method::PATCH};
let payload=if gitlab {json!({"state_event":"close"})}else{json!({"state":"closed"})};
let result=read(request(&client,method,url,&provider,&username,&token)?.json(&payload).send().map_err(|e|format!("Closing was not confirmed: {e}"))?,&provider)?;
let state=value_string(&result,&["state"]);
if state!="closed" {return Err("The provider did not confirm the closed state.".into());}
Ok(state)
}
}).await.map_err(|e|e.to_string())?
}
#[cfg(test)] mod tests {
use super::*;
#[test] fn uses_custom_completed_state_and_rejects_ambiguous_configuration() {
assert_eq!(completed_state(&json!({"value":[{"name":"Review","category":"Resolved"},{"name":"Delivered","category":"Completed"}]})).unwrap(),"Delivered");
assert!(completed_state(&json!({"value":[]})).is_err());
assert!(completed_state(&json!({"value":[{"name":"A","category":"Completed"},{"name":"B","category":"Completed"}]})).is_err());
}
}
@@ -0,0 +1,95 @@
use super::*;
use serde_json::{Value, json};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IssueComment { id: String, author: String, body: String, created_at: String, body_html: bool }
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IssueCommentPage { comments: Vec<IssueComment>, next_cursor: Option<String> }
pub(super) fn comment_url(provider: &str, base: &str, repository: &str, number: u64) -> Result<reqwest::Url, String> {
if repository.trim().is_empty() || number == 0 { return Err("Missing issue repository or number.".into()); }
let base = normalized_base_url(base)?;
let mut url = reqwest::Url::parse(if provider == "github" { "https://api.github.com" } else { &base }).map_err(|e|e.to_string())?;
let num = number.to_string();
let mut path = url.path_segments_mut().map_err(|_|"Invalid API URL.")?;
path.pop_if_empty();
match provider {
"github" | "gitea" => {
let (owner, repo) = repository.split_once('/').filter(|(a,b)|!a.is_empty() && !b.is_empty() && !b.contains('/')).ok_or("Invalid issue repository.")?;
if provider == "gitea" { path.extend(["api","v1"]); }
path.extend(["repos",owner,repo,"issues",&num,"comments"]);
},
"gitlab" | "gitlab-self-hosted" => { path.extend(["api","v4","projects",repository,"issues",&num,"notes"]); },
"azure-devops" => { path.extend([repository,"_apis","wit","workItems",&num,"comments"]); },
_ => return Err("Unsupported issue provider.".into()),
}
drop(path);
if provider == "azure-devops" { url.query_pairs_mut().append_pair("api-version","7.1-preview.4"); }
Ok(url)
}
pub(super) fn request(client: &Client, method: reqwest::Method, url: reqwest::Url, provider: &str, username: &str, token: &str) -> Result<reqwest::blocking::RequestBuilder, String> {
if token.trim().is_empty() { return Err("No token stored for this integration.".into()); }
let req = client.request(method,url).header(USER_AGENT,"Gitty").header(ACCEPT,"application/json");
Ok(match provider {
"github" => req.bearer_auth(token),
"gitea" => req.header("Authorization",format!("token {token}")),
"gitlab" | "gitlab-self-hosted" => req.header("PRIVATE-TOKEN",token),
_ => req.basic_auth(if username.is_empty(){"gitty"}else{username},Some(token)),
})
}
fn parse_comment(v: &Value, provider: &str) -> Option<IssueComment> {
if v["system"] == true || v["isDeleted"] == true { return None; }
let azure = provider == "azure-devops";
let id = if azure && v["id"].is_null() { json_id(&json!({"id":v["commentId"]})) } else { json_id(v) };
Some(IssueComment { id, author: value_string(v, if azure { &["createdBy","displayName"] } else if provider.starts_with("gitlab") { &["author","username"] } else { &["user","login"] }), body:value_string(v,&[if azure{"text"}else{"body"}]), created_at:value_string(v,&[if azure{"createdDate"}else{"created_at"}]), body_html: azure && (v["format"] == "html" || v["format"] == 0) })
}
pub(super) fn comment_client() -> Result<Client,String> {
Client::builder().timeout(Duration::from_secs(30)).connect_timeout(Duration::from_secs(7)).redirect(reqwest::redirect::Policy::none()).build().map_err(|e|e.to_string())
}
#[tauri::command]
pub async fn list_integration_issue_comments(provider:String, base_url:String, username:String, token:String, repository:String, number:u64, cursor:Option<String>) -> Result<IssueCommentPage,String> {
tauri::async_runtime::spawn_blocking(move || {
let client=comment_client()?;
let mut url=comment_url(&provider,&base_url,&repository,number)?;
let page=if provider!="azure-devops" { cursor.as_deref().unwrap_or("1").parse::<u32>().map_err(|_|"Invalid comment page.")?.max(1) } else {1};
if provider=="azure-devops" {
url.query_pairs_mut().append_pair("$top","100").append_pair("order","asc").append_pair("includeDeleted","false");
if let Some(cursor)=cursor {url.query_pairs_mut().append_pair("continuationToken",&cursor);}
} else {url.query_pairs_mut().append_pair("page",&page.to_string()).append_pair("per_page","100").append_pair("limit","100").append_pair("sort","asc").append_pair("order_by","created_at");}
let response=request(&client,reqwest::Method::GET,url,&provider,&username,&token)?.send().map_err(|e|e.to_string())?;
if !response.status().is_success(){return Err(response_error(response,&provider));}
let linked_next=response.headers().get("link").and_then(|h|h.to_str().ok()).map(|s|s.contains("rel=\"next\""));
let value:Value=response.json().map_err(|e|e.to_string())?;
let items=if provider=="azure-devops" {value["comments"].as_array()}else{value.as_array()}.ok_or("Invalid comment list.")?;
let next_cursor=if provider=="azure-devops" {value["continuationToken"].as_str().filter(|s|!s.is_empty()).map(str::to_string)}else if linked_next.unwrap_or(items.len()==100) {Some(page.saturating_add(1).to_string())}else{None};
Ok(IssueCommentPage{comments:items.iter().filter_map(|v|parse_comment(v,&provider)).collect(),next_cursor})
}).await.map_err(|e|e.to_string())?
}
#[tauri::command]
pub async fn add_integration_issue_comment(provider:String, base_url:String, username:String, token:String, repository:String, number:u64, body:String) -> Result<IssueComment,String> {
tauri::async_runtime::spawn_blocking(move || {
if body.trim().is_empty() || body.len()>100_000 {return Err("Comment must contain between 1 and 100,000 bytes.".into());}
let mut url=comment_url(&provider,&base_url,&repository,number)?;
if provider=="azure-devops" {url.query_pairs_mut().append_pair("format","markdown");}
let payload=if provider=="azure-devops"{json!({"text":body})}else{json!({"body":body})};
let response=request(&comment_client()?,reqwest::Method::POST,url,&provider,&username,&token)?.json(&payload).send().map_err(|e|format!("Comment delivery not confirmed: {e}"))?;
if !response.status().is_success(){return Err(response_error(response,&provider));}
parse_comment(&response.json::<Value>().map_err(|e|e.to_string())?,&provider).ok_or("Invalid posted comment.".into())
}).await.map_err(|e|e.to_string())?
}
#[cfg(test)]
mod tests {
use super::*;
#[test] fn issue_endpoints_use_issue_notes_and_encode_project_paths() {
assert_eq!(comment_url("gitlab","https://git.test","team/sub/repo",4).unwrap().as_str(),"https://git.test/api/v4/projects/team%2Fsub%2Frepo/issues/4/notes");
assert_eq!(comment_url("azure-devops","https://dev.azure.com/org","My Project",4).unwrap().path(),"/org/My%20Project/_apis/wit/workItems/4/comments");
assert!(comment_url("gitea","https://git.test","owner/repo/invalid",4).is_err());
}
#[test] fn comments_filter_system_entries_and_preserve_html_format() {
assert!(parse_comment(&json!({"system":true}),"gitlab").is_none());
let c=parse_comment(&json!({"commentId":7,"text":"<p>Hello</p>","format":"html","createdBy":{"displayName":"Alex"}}),"azure-devops").unwrap();
assert_eq!(c.id,"7"); assert!(c.body_html); assert_eq!(c.author,"Alex");
}
}
+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"
);
}
}
+11 -1
View File
@@ -34,8 +34,9 @@ use git::{
unstage_files, untrack_paths, update_remote,
};
use integrations::{
create_integration_review_request, list_integration_repository_branches,
add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser,
run_integration_review_action,
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;
@@ -437,8 +438,17 @@ async fn main() {
cred_load,
cred_save,
cred_delete,
list_integration_repository_branches,
create_integration_review_request,
list_integration_repositories,
list_integration_review_requests,
list_integration_issues,
get_integration_board,
list_integration_boards,
move_integration_board_card,
list_integration_issue_comments,
add_integration_issue_comment,
close_integration_issue,
get_integration_review_details,
add_integration_review_comment,
run_integration_review_action,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty",
"version": "2026.9.2",
"version": "2026.9.3",
"identifier": "com.gitty",
"build": {
"beforeDevCommand": "npm run prepare:lfs && npm run dev",
+20 -2
View File
@@ -10,6 +10,10 @@
import TitleBar from "./lib/TitleBar.svelte";
import RepoToolbar from "./lib/RepoToolbar.svelte";
import PullRequestStatusLoader from "./lib/components/PullRequestStatusLoader.svelte";
import type { PullRequestBadgeState } from "./lib/pullRequestBadges";
let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
import IssueCenter from "./lib/components/IssueCenter.svelte";
import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte";
import ReviewCenter from "./lib/components/ReviewCenter.svelte";
import RepoTabs from "./lib/RepoTabs.svelte";
@@ -219,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";
@@ -5392,7 +5396,13 @@
{isBusy}
language={appLanguage}
onOpenManagement={openRepoManagement}
onOpenRepositories={() => {
const path = repoTabs.find(tab => sameRepoPath(tab.path, activeRepoPath))?.path ?? repoTabs[0]?.path;
if (path) return selectRepoTab(path);
return chooseRepositoryFolder();
}}
onOpenReviewCenter={() => openReviewCenter()}
onOpenIssues={() => { activeView = "issues"; }}
isActive={(path) => activeView === "repository" && sameRepoPath(activeRepoPath, path)}
onSelect={selectRepoTab}
onClose={closeRepoTab}
@@ -5548,18 +5558,26 @@
</section>
{/if}
<PullRequestStatusLoader
repos={dashboardRepos} language={appLanguage}
integrations={gitIntegrationSettings} loadCredential={loadStoredCredential}
bind:pullRequestBadges
/>
{#if activeView === "management"}
<RepositoryDashboard
repos={dashboardRepos} language={appLanguage} {isBusy}
integrations={gitIntegrationSettings} loadCredential={loadStoredCredential}
{pullRequestBadges}
onOpen={selectRepoTab} onAdd={chooseRepositoryFolder} onClone={openCloneDialog}
onInit={initializeRepository}
onFavorite={toggleFavoriteRepo} onClose={closeDashboardRepository}
onRemoveRecent={removeRepoFromRecent}
onOpenPullRequests={(repository, sourceId) => openReviewCenter(repository, sourceId)}
/>
{:else if activeView === "issues"}
<IssueCenter language={appLanguage} integrations={gitIntegrationSettings} loadCredential={loadStoredCredential} onOpenSettings={openAppSettings} />
{:else if activeView === "review-center"}
<ReviewCenter
localRepositoryPath={activeRepoPath}
language={appLanguage}
integrations={gitIntegrationSettings}
initialQuery={reviewCenterInitialQuery}
+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;
+45 -60
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Folder, GitBranch, GitPullRequest, Plus, X } from "@lucide/svelte";
import { Columns3, Database, Folder, House, GitPullRequest, Plus, X } from "@lucide/svelte";
interface RepositoryTabItem {
path: string;
@@ -7,11 +7,13 @@
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 onOpenRepositories: () => void | Promise<void> = () => {};
export let onOpenIssues: () => void = () => {};
export let onOpenReviewCenter: () => void = () => {};
export let isActive: (path: string) => boolean = () => false;
export let onSelect: (path: string) => void | Promise<void> = () => {};
@@ -20,65 +22,48 @@
export let onAdd: () => void | Promise<void> = () => {};
</script>
<header class="repo-tabbar" aria-label={language === "de" ? "Repository-Reiter" : "Repository tabs"}>
<button
class="repo-tab management"
class:active={activeView === "management"}
type="button"
onclick={onOpenManagement}
disabled={isBusy}
title={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
aria-label={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
>
<Folder size={15} aria-hidden="true" />
</button>
<div class="repo-tabs-scroll">
<div class="repo-tab-wrap review-center-tab" class:active={activeView === "review-center"} role="presentation">
<button class="repo-tab" type="button" onclick={onOpenReviewCenter} disabled={isBusy} title="Review Center" aria-label="Review Center">
<GitPullRequest size={13} aria-hidden="true" />
<span>PR</span>
</button>
</div>
<header class="workspace-navigation">
<nav class="global-navigation" aria-label={language === "de" ? "Hauptnavigation" : "Main navigation"}>
<button type="button" class:active={activeView === "management"} aria-current={activeView === "management" ? "page" : undefined} disabled={isBusy} onclick={onOpenManagement}><House size={17}/><span>Dashboard</span></button>
<button type="button" class:active={activeView === "repository"} aria-current={activeView === "repository" ? "page" : undefined} disabled={isBusy} onclick={onOpenRepositories}><Database size={17}/><span>Repositories</span></button>
<button type="button" class:active={activeView === "review-center"} aria-current={activeView === "review-center" ? "page" : undefined} disabled={isBusy} onclick={onOpenReviewCenter}><GitPullRequest size={17}/><span>Pull Requests</span></button>
<button type="button" class:active={activeView === "issues"} aria-current={activeView === "issues" ? "page" : undefined} disabled={isBusy} onclick={onOpenIssues}><Columns3 size={17}/><span>Issues &amp; Boards</span></button>
</nav>
{#if activeView === "repository"}
<nav class="repository-navigation" aria-label={language === "de" ? "Geöffnete Repositories" : "Open repositories"}>
{#each repoTabs as repo (repo.path)}
<div
class="repo-tab-wrap"
class:active={isActive(repo.path)}
role="presentation"
oncontextmenu={(event) => onContextMenu(repo.path, event)}
>
<button
class="repo-tab"
type="button"
onclick={() => onSelect(repo.path)}
disabled={isBusy}
title={repo.path}
>
<GitBranch size={13} aria-hidden="true" />
<span>{repo.name}</span>
</button>
<button
class="repo-tab-close"
type="button"
onclick={(event) => onClose(repo.path, event)}
disabled={isBusy}
aria-label={language === "de" ? `${repo.name} schließen` : `Close ${repo.name}`}
title={language === "de" ? "Repository-Tab schließen" : "Close repository tab"}
>
<X size={11} aria-hidden="true" />
</button>
<div class="repository-tab" class:active={isActive(repo.path)} role="presentation" oncontextmenu={(event) => onContextMenu(repo.path, event)}>
<button class="repository-select" type="button" onclick={() => onSelect(repo.path)} disabled={isBusy} title={repo.path} aria-current={isActive(repo.path) ? "page" : undefined}><Folder size={16}/><span>{repo.name}</span></button>
<button class="repository-close" type="button" onclick={(event) => onClose(repo.path, event)} disabled={isBusy} aria-label={language === "de" ? `${repo.name} schließen` : `Close ${repo.name}`}><X size={13}/></button>
</div>
{/each}
</div>
<button
class="repo-tab-add"
type="button"
onclick={onAdd}
disabled={isBusy}
title={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"}
aria-label={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"}
>
<Plus size={15} aria-hidden="true" />
</button>
<button class="repository-add" type="button" onclick={onAdd} disabled={isBusy} title={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"} aria-label={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"}><Plus size={18}/></button>
</nav>
{/if}
</header>
<style>
.workspace-navigation{flex:0 0 auto;min-width:0;background:var(--app-bg);color:var(--color-ink-muted);font-family:var(--font-sans);border-bottom:1px solid var(--color-border-subtle)}
.global-navigation{display:flex;align-items:stretch;gap:2px;min-height:38px;padding:0 5px;overflow-x:auto;scrollbar-width:thin;border-bottom:1px solid var(--color-border-subtle)}
button{font:inherit;cursor:pointer;color:inherit;background:transparent;border:0;box-shadow:none}
button:disabled{opacity:.45;cursor:default}
button:focus-visible{outline:2px solid var(--color-accent);outline-offset:-3px}
.global-navigation button{position:relative;display:flex;flex:0 0 auto;align-items:center;gap:7px;padding:0 10px;min-height:38px;font-size:13px;white-space:nowrap}
.global-navigation button:hover:not(:disabled){background:var(--color-surface-hover);color:var(--color-ink)}
.global-navigation button.active{color:var(--color-ink);font-weight:600}
.global-navigation button.active::after{position:absolute;content:"";height:2px;bottom:0;left:12px;right:12px;background:var(--color-accent)}
.global-navigation button.active>:global(svg){color:var(--color-accent)}
.repository-navigation{display:flex;align-items:stretch;gap:4px;min-height:34px;padding:4px 5px 0;overflow-x:auto;scrollbar-width:thin;background:color-mix(in srgb,var(--color-surface) 45%,var(--app-bg))}
.repository-tab{display:flex;flex:0 0 auto;align-items:center;min-width:100px;max-width:250px;border:1px solid var(--color-border);border-bottom:0;border-radius:5px 5px 0 0;background:var(--app-bg)}
.repository-tab.active{background:var(--color-surface-raised);border-color:var(--color-border-input);color:var(--color-ink)}
.repository-tab:hover{background:var(--color-surface-hover)}
.repository-select{display:flex;flex:1;min-width:0;align-items:center;gap:7px;min-height:29px;padding:0 9px;font-size:12px;text-align:left}
.repository-select span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.repository-select>:global(svg){flex-shrink:0}
.repository-tab.active .repository-select{font-weight:600}
.repository-close{display:grid;place-items:center;flex:0 0 24px;height:24px;margin-right:2px;color:var(--color-ink-dim)}
.repository-close:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-hover)}
.repository-add{display:grid;place-items:center;flex:0 0 32px;min-height:29px;border:1px solid var(--color-border);border-bottom:0;border-radius:5px 5px 0 0}
.repository-add:hover:not(:disabled){color:var(--color-accent);background:var(--color-surface-hover)}
@media(max-width:640px){.global-navigation{gap:0;padding:0 4px}.global-navigation button{gap:7px;padding:0 10px;font-size:12px;min-height:38px}.global-navigation{min-height:38px}}
</style>
+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,125 @@
<script lang="ts">
import SelectMenu from "./SelectMenu.svelte";
import { onMount } from "svelte";
import { GitPullRequest, X, LoaderCircle, ArrowRight } from "@lucide/svelte";
import { createIntegrationReviewRequest, listIntegrationRepositories, listIntegrationRepositoryBranches, listRemotes, listBranches } from "../git";
import { integrationCredentialKey } from "../integrations";
import type { GitIntegrationSource, GitIntegrationRepository, IntegrationReviewRequest, StoredCredential } from "../types";
let { source, de, localRepositoryPath = "", loadCredential, onClose, onCreated }: {
source: GitIntegrationSource; de: boolean; localRepositoryPath?: string;
loadCredential: (key: string) => Promise<StoredCredential | null>;
onClose: () => void; onCreated: (request: IntegrationReviewRequest) => void;
} = $props();
let dialog: HTMLDialogElement;
let repositories = $state<GitIntegrationRepository[]>([]);
let repositoryId = $state("");
let sourceBranch = $state("");
let targetBranch = $state("");
let branches = $state<string[]>([]);
let defaultBranch = $state("");
let branchesLoading = $state(false);
let branchError = $state("");
let branchGeneration = 0;
const branchOptions = $derived(branches.map(name => ({value:name,label:name,group:name === defaultBranch ? (de ? "Standardbranch" : "Default branch") : undefined})));
$effect(() => {
const repository = repositories.find(item => item.id === repositoryId);
void loadRepositoryBranches(repository);
});
async function loadRepositoryBranches(repository?: GitIntegrationRepository) {
const generation = ++branchGeneration;
branches = []; sourceBranch = ""; targetBranch = ""; defaultBranch = ""; branchError = "";
branchesLoading = !!repository;
if (!repository) return;
try {
const credential = await loadCredential(integrationCredentialKey(source.provider, source.accountId));
if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
const result = await listIntegrationRepositoryBranches(source.provider, source.baseUrl, credential.username, credential.password, repository);
if (generation !== branchGeneration) return;
branches = result.branches;
defaultBranch = result.defaultBranch;
targetBranch = branches.includes(defaultBranch) ? defaultBranch : "";
// Only suggest a local branch when its remote matches this exact repository.
if (localRepositoryPath) {
try {
const remotes = await listRemotes(localRepositoryPath);
const clean = (url: string) => url.trim().replace(/\.git\/?$/, "").replace(/\/$/, "");
const matches = remotes.some(remote => [repository.cloneUrl, repository.sshUrl].filter(Boolean).some(url => clean(url) === clean(remote.fetch_url) || clean(url) === clean(remote.push_url)));
if (matches) {
const localBranches = await listBranches(localRepositoryPath);
if (generation !== branchGeneration) return;
const current = localBranches.find(branch => branch.current)?.name;
if (current && branches.includes(current) && current !== targetBranch) sourceBranch = current;
}
} catch { /* Remote branch selection remains available without local metadata. */ }
}
if (generation !== branchGeneration) return;
if (!sourceBranch && branches.length === 2 && targetBranch) sourceBranch = branches.find(name => name !== targetBranch) ?? "";
} catch (cause) {
if (generation === branchGeneration) branchError = String(cause);
} finally { if (generation === branchGeneration) branchesLoading = false; }
}
let title = $state("");
let description = $state("");
let loading = $state(true);
let busy = $state(false);
let error = $state("");
const mr = $derived(source.provider.startsWith("gitlab"));
const heading = $derived(de ? (mr ? "Merge Request erstellen" : "Pull Request erstellen") : (mr ? "Create merge request" : "Create pull request"));
const normalizeBranch = (branch: string) => branch.trim().replace(/^refs\/heads\//, "");
const sameBranch = $derived(!!sourceBranch.trim() && normalizeBranch(sourceBranch) === normalizeBranch(targetBranch));
const valid = $derived(!branchesLoading && !branchError && branches.includes(sourceBranch) && branches.includes(targetBranch) && repositoryId && title.trim() && sourceBranch.trim() && targetBranch.trim() && !sameBranch);
onMount(() => { dialog.showModal(); void loadRepositories(); });
async function loadRepositories() {
loading = true; error = "";
try {
repositories = (await listIntegrationRepositories(source.provider, source.baseUrl, source.accountId)).sort((a,b) => a.fullName.localeCompare(b.fullName));
if (repositories.length === 1) repositoryId = repositories[0].id;
} catch (cause) { error = String(cause); }
finally { loading = false; }
}
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy || !valid) return;
const repository = repositories.find(item => item.id === repositoryId);
if (!repository) return;
busy = true; error = "";
try {
const credential = await loadCredential(integrationCredentialKey(source.provider, source.accountId));
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
const request = await createIntegrationReviewRequest(source.provider, source.baseUrl, credential.username, credential.password, repository, normalizeBranch(sourceBranch), normalizeBranch(targetBranch), title.trim(), description);
onCreated(request);
} catch (cause) { error = cause instanceof Error ? cause.message : String(cause); }
finally { busy = false; }
}
</script>
<dialog bind:this={dialog} aria-labelledby="create-review-title" oncancel={(event) => { event.preventDefault(); if (!busy) onClose(); }} onclick={(event) => { if (event.target === dialog && !busy) { const rect = dialog.getBoundingClientRect(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) onClose(); } }}>
<form onsubmit={submit}>
<header><div class="heading-icon"><GitPullRequest size={19} /></div><div><h2 id="create-review-title">{heading}</h2><p>{source.label}</p></div><button class="close" type="button" aria-label={de ? "Schließen" : "Close"} disabled={busy} onclick={onClose}><X size={18}/></button></header>
<div class="body">
{#if error}<div class="error" role="alert">{error}{#if !repositories.length && !loading}<button type="button" onclick={loadRepositories}>{de ? "Erneut laden" : "Retry"}</button>{/if}</div>{/if}
<div class="repository-field"><div class="field-heading"><span>Repository</span><small>{loading ? "…" : `${repositories.length} ${de ? "verfügbar" : "available"}`}</small></div>
<SelectMenu value={repositoryId} options={repositories.map(repository => ({value:repository.id,label:repository.fullName}))} disabled={loading || busy} ariaLabel="Repository" placeholder={loading ? (de ? "Repositories werden geladen …" : "Loading repositories…") : (de ? "Repository auswählen" : "Select repository")} searchable searchPlaceholder={de ? "Repositories durchsuchen …" : "Search repositories…"} emptyText={de ? "Keine passenden Repositories" : "No matching repositories"} onChange={value => repositoryId = value}/>
</div>
{#if !loading && !error && !repositories.length}<p>{de ? "Keine Repositories für diese Integration gefunden." : "No repositories found for this integration."}</p>{/if}
<div class="branches">
<div class="repository-field"><span>{de ? "Quellbranch" : "Source branch"}</span><SelectMenu value={sourceBranch} options={branchOptions.map(option => ({...option,disabled:option.value === targetBranch}))} disabled={busy || branchesLoading || !repositoryId} ariaLabel={de ? "Quellbranch" : "Source branch"} placeholder={branchesLoading ? (de ? "Lädt …" : "Loading…") : (de ? "Quellbranch auswählen" : "Select source branch")} searchable searchPlaceholder={de ? "Branches durchsuchen …" : "Search branches…"} emptyText={de ? "Keine Branches gefunden" : "No branches found"} onChange={value => sourceBranch = value}/></div>
<ArrowRight size={16}/>
<div class="repository-field"><span>{de ? "Zielbranch" : "Target branch"}</span><SelectMenu value={targetBranch} options={branchOptions.map(option => ({...option,disabled:option.value === sourceBranch}))} disabled={busy || branchesLoading || !repositoryId} ariaLabel={de ? "Zielbranch" : "Target branch"} placeholder={branchesLoading ? (de ? "Lädt …" : "Loading…") : (de ? "Zielbranch auswählen" : "Select target branch")} searchable searchPlaceholder={de ? "Branches durchsuchen …" : "Search branches…"} emptyText={de ? "Keine Branches gefunden" : "No branches found"} onChange={value => targetBranch = value}/></div>
</div>
{#if branchError}<div class="error" role="alert">{branchError}<button type="button" onclick={() => loadRepositoryBranches(repositories.find(item => item.id === repositoryId))}>{de ? "Erneut laden" : "Retry"}</button></div>{:else if repositoryId && !branchesLoading && !branches.length}<p>{de ? "Dieses Repository hat noch keine Branches." : "This repository has no branches yet."}</p>{/if}
{#if sameBranch}<p class="validation">{de ? "Quell- und Zielbranch müssen unterschiedlich sein." : "Source and target branches must be different."}</p>{/if}
<p class="hint">{de ? "Beide Branches müssen bereits in diesem Repository gepusht sein." : "Both branches must already be pushed to this repository."}</p>
<label>{de ? "Titel" : "Title"}<input bind:value={title} disabled={busy} placeholder={de ? "Was ändert sich?" : "What is changing?"} required /></label>
<label>{de ? "Beschreibung" : "Description"}<textarea bind:value={description} disabled={busy} rows="7" placeholder={de ? "Beschreibe deine Änderungen … (Markdown unterstützt)" : "Describe your changes… (Markdown supported)"}></textarea></label>
</div>
<footer><button type="button" disabled={busy} onclick={onClose}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit" disabled={busy || loading || !valid}>{#if busy}<LoaderCircle class="spin" size={15}/>{:else}<GitPullRequest size={15}/>{/if}{busy ? (de ? "Wird erstellt …" : "Creating…") : heading}</button></footer>
</form>
</dialog>
<style>
.repository-field{min-width:0;display:grid;gap:9px}.field-heading{display:flex;align-items:center;justify-content:space-between;font-weight:600}.field-heading small{font-size:10px;font-weight:400;color:var(--color-ink-faint)}
dialog{margin:auto;width:min(640px,calc(100vw - 40px));max-height:calc(100vh - 48px);padding:0;border:1px solid var(--color-border-subtle);border-radius:14px;background:var(--app-dialog-bg);color:var(--color-ink);box-shadow:var(--app-dialog-shadow);font-family:inherit;font-size:12px;overflow:auto}dialog::backdrop{background:#0007;backdrop-filter:blur(3px)}header{display:flex;align-items:center;gap:12px;padding:22px 26px;background:var(--app-dialog-chrome);border-bottom:1px solid var(--color-border-subtle)}h2{margin:0;font-size:16px;font-weight:650}p{margin:5px 0 0;color:var(--color-ink-muted)}.heading-icon{display:grid;place-items:center;width:38px;height:38px;border-radius:10px;background:color-mix(in srgb,var(--color-accent) 12%,transparent);color:var(--color-accent)}button,input,textarea{font:inherit}button{display:inline-flex;justify-content:center;align-items:center;gap:8px;border:1px solid var(--color-border-subtle);border-radius:7px;padding:9px 13px;background:var(--color-surface);color:var(--color-ink);cursor:pointer}button:disabled{opacity:.5;cursor:default}.close{margin-left:auto;border:0;padding:6px}.body{display:grid;gap:20px;padding:24px 26px}label{display:grid;gap:8px;font-weight:600;min-width:0}input,textarea{box-sizing:border-box;width:100%;padding:10px 11px;border:1px solid var(--color-border-subtle);border-radius:7px;background:var(--app-bg);color:var(--color-ink);font-weight:400}input:focus,textarea:focus{outline:2px solid var(--color-accent);outline-offset:1px}textarea{resize:vertical;min-height:110px;line-height:1.6}.branches{padding:16px;background:color-mix(in srgb,var(--color-accent) 3%,var(--app-bg));border:1px solid var(--color-border-subtle);display:grid;grid-template-columns:minmax(0,1fr) 16px minmax(0,1fr);gap:12px;align-items:end}.branches>:global(svg){margin-bottom:12px;color:var(--color-accent)}.hint{margin-top:-8px;font-size:11px;line-height:1.5}.error,.validation{color:var(--color-danger,#e76767);line-height:1.5}.error{padding:12px;border-radius:7px;background:color-mix(in srgb,var(--color-danger,#e76767) 9%,transparent);overflow-wrap:anywhere}.error button{margin-left:8px}footer{display:flex;justify-content:flex-end;gap:9px;padding:16px 26px;background:var(--app-dialog-chrome);border-top:1px solid var(--color-border-subtle)}.primary{background:var(--color-accent);border-color:var(--color-accent);color:white}.primary:enabled:hover{filter:brightness(1.08)}:global(.spin){animation:rotate 1s linear infinite}@keyframes rotate{to{transform:rotate(360deg)}}
</style>
@@ -0,0 +1,280 @@
<script context="module" lang="ts">
const boardCache = new Map<string, IntegrationBoard>();
</script>
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { Columns3, ExternalLink, Link2, RefreshCw, Search, X } from "@lucide/svelte";
import SelectMenu from "./SelectMenu.svelte";
import IssueLabels from "./IssueLabels.svelte";
import IssueAssignees from "./IssueAssignees.svelte";
import { issueTone } from "../issuePresentation";
import { getIntegrationBoard, moveIntegrationBoardCard, listIntegrationBoards, openInBrowser } from "../git";
import { integrationCredentialKey } from "../integrations";
import type { GitIntegrationSource, IntegrationBoard, IntegrationBoardCard, IntegrationBoardReference, StoredCredential } from "../types";
export let source: GitIntegrationSource;
export let language: "de" | "en" = "en";
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
const preferenceKey = `gitty.board.v1:${JSON.stringify([source.id, source.baseUrl])}`;
const directoryKey = `${preferenceKey}:directory`;
let savedBoards: IntegrationBoardReference[] = [];
let discovering = false;
let discoveryWarnings: string[] = [];
let destroyed = false;
let connectOpen = true;
let boardUrl = "";
let activeUrl = "";
let board: IntegrationBoard | null = null;
let loading = false;
let moving = false;
let dragged: { cardId: string; columnId: string } | null = null;
let dropColumn = "";
let moveMessage = "";
$: canMove = board?.columns.some(column => !!column.moveTarget) ?? false;
let error = "";
let query = "";
let repositoryFilter = "";
let selected: IntegrationBoardCard | null = null;
let selectedColumnId = "";
let generation = 0;
$: de = language === "de";
$: normalizedQuery = query.trim().toLocaleLowerCase();
$: repositoryOptions = [{ value: "", label: de ? "Alle Repositories" : "All repositories" }, ...[...new Set(board?.columns.flatMap(column => column.cards.map(card => card.repository)).filter(Boolean) ?? [])].sort().map(value => ({ value, label: value }))];
$: columns = board?.columns.map(column => ({ ...column, cards: column.cards.filter(card => (!repositoryFilter || card.repository === repositoryFilter) &&
`${card.title} ${card.number} ${card.repository} ${card.labels.join(" ")} ${card.assignees.join(" ")} ${card.lane}`.toLocaleLowerCase().includes(normalizedQuery)) })) ?? [];
$: placeholder = source.provider === "github" ? "https://github.com/orgs/team/projects/1/views/1"
: source.provider.startsWith("gitlab") ? `${source.baseUrl}/group/project/-/boards/1`
: source.provider === "gitea" ? `${source.baseUrl}/owner/repo/projects/1`
: `${source.baseUrl}/Project/_boards/board/t/Team/Stories`;
onMount(() => {
try { boardUrl = localStorage.getItem(preferenceKey) ?? ""; } catch { /* Optional preference. */ }
try {
const saved: unknown = JSON.parse(localStorage.getItem(directoryKey) ?? "[]");
if (Array.isArray(saved)) savedBoards = saved.filter(item => item && typeof item.title === "string" && typeof item.webUrl === "string" && typeof item.scope === "string" && validBoardUrl(item.webUrl));
} catch { /* Invalid optional preference. */ }
if (boardUrl && validBoardUrl(boardUrl)) rememberBoard({ title: boardUrl, webUrl: boardUrl, scope: "" });
void discoverBoards();
if (boardUrl) { connectOpen = false; void loadBoard(boardUrl); }
const timer = window.setInterval(() => { if (activeUrl && !loading && !moving) void loadBoard(activeUrl); }, 180_000);
return () => window.clearInterval(timer);
});
onDestroy(() => { generation++; destroyed = true; });
function validBoardUrl(url: string) {
try { const parsed = new URL(url); return parsed.origin === new URL(source.baseUrl).origin && !parsed.username && !parsed.password; } catch { return false; }
}
function rememberBoard(item: IntegrationBoardReference) {
if (!validBoardUrl(item.webUrl)) return;
savedBoards = [...savedBoards.filter(board => board.webUrl !== item.webUrl), item];
try { localStorage.setItem(directoryKey, JSON.stringify(savedBoards)); } catch { /* Optional preference. */ }
}
function boardError(message: string) {
if (de && message.includes("publishes no Projects/Columns API")) return "Dieser Gitea-Server bietet keine Projects-/Columns-API an. Boards sind im Browser verfügbar, können aber mit dem API-Token weder automatisch gefunden noch importiert werden. Bitte das Original-Board öffnen.";
if (de && message.includes("Gitea returned 404")) return "Gitea meldet 404. Bitte Board-Link und Token-Zugriff prüfen. Möglicherweise unterstützt der Server die Projects-/Columns-API nicht.";
return message;
}
async function discoverBoards() {
if (discovering) return;
discovering = true;
discoveryWarnings = [];
try {
const credential = await timeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
const directory = await timeout(listIntegrationBoards(source.provider, source.baseUrl, credential.username, credential.password), 75_000);
if (destroyed) return;
for (const item of directory.boards) rememberBoard(item);
discoveryWarnings = directory.warnings.map(boardError);
} catch (cause) {
if (!destroyed) discoveryWarnings = [boardError(String(cause))];
} finally { if (!destroyed) discovering = false; }
}
async function loadBoard(url: string, collapseEditor = false) {
if (moving) return;
const requestedUrl = url.trim();
if (!requestedUrl) return;
const requestGeneration = ++generation;
if (requestedUrl !== activeUrl) {
selected = null;
repositoryFilter = "";
board = boardCache.get(`${preferenceKey}:${requestedUrl}`) ?? null;
}
activeUrl = requestedUrl;
loading = true;
error = "";
try {
const parsed = new URL(requestedUrl);
if (parsed.origin !== new URL(source.baseUrl).origin || parsed.username || parsed.password) {
throw new Error(de ? "Der Board-Link muss zur ausgewählten Integration gehören." : "The board link must belong to the selected integration.");
}
if (!savedBoards.some(item => item.webUrl === requestedUrl)) rememberBoard({ title: requestedUrl, webUrl: requestedUrl, scope: "" });
const credential = await timeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token stored for this integration.");
const result = await timeout(getIntegrationBoard(source.provider, source.baseUrl, credential.username, credential.password, requestedUrl), 70_000);
if (requestGeneration !== generation) return;
board = result;
rememberBoard({ title: result.title, webUrl: requestedUrl, scope: savedBoards.find(item => item.webUrl === requestedUrl)?.scope ?? "" });
if (collapseEditor && boardUrl.trim() === requestedUrl) connectOpen = false;
boardCache.set(`${preferenceKey}:${requestedUrl}`, result);
selected = selected ? result.columns.flatMap(column => column.cards).find(card => card.id === selected!.id) ?? null : null;
try { localStorage.setItem(preferenceKey, requestedUrl); } catch { /* Keep the board in memory. */ }
} catch (cause) {
if (requestGeneration === generation) error = boardError(cause instanceof Error ? cause.message : String(cause));
} finally {
if (requestGeneration === generation) loading = false;
}
}
async function moveCard(cardId: string, from: string, to: string) {
if (moving || loading || from === to || !board?.columns.find(column => column.id === to)?.moveTarget) return;
const previousBoard = board;
const card = previousBoard.columns.find(column => column.id === from)?.cards.find(card => card.id === cardId);
if (!card) return;
const previousSelectedColumn = selected?.id === cardId ? selectedColumnId : from;
const url = activeUrl;
const requestGeneration = generation;
// Keep the original arrays intact for an exact rollback, including card order.
board = {
...previousBoard,
columns: previousBoard.columns.map(column => {
const remove = column.id === from || (source.provider.startsWith("gitlab") && (to === "open" || to === "closed"));
const cards = remove ? column.cards.filter(item => item.id !== cardId) : column.cards;
return { ...column, cards: column.id === to && !cards.some(item => item.id === cardId) ? [...cards, card] : cards };
}),
};
if (selected?.id === cardId) selectedColumnId = to;
moving = true;
dragged = null;
dropColumn = "";
error = "";
moveMessage = de ? "Verschiebung wird gespeichert …" : "Saving move …";
try {
const credential = await timeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
await moveIntegrationBoardCard(source.provider, source.baseUrl, credential.username, credential.password, url, cardId, from, to);
// A successful mutation invalidates the cached snapshot, even if reloading fails.
boardCache.delete(`${preferenceKey}:${url}`);
if (destroyed || generation !== requestGeneration) return;
boardCache.set(`${preferenceKey}:${url}`, board!);
moveMessage = de ? "Verschoben. Board wird aktualisiert …" : "Moved. Refreshing board …";
moving = false;
if (selected?.id === cardId) selectedColumnId = to;
await loadBoard(url);
if (!destroyed) moveMessage = error ? (de ? "Verschoben; der angezeigte Stand konnte noch nicht aktualisiert werden." : "Moved; the displayed board could not be refreshed yet.") : (de ? "Karte verschoben." : "Card moved.");
} catch (cause) {
if (!destroyed && generation === requestGeneration) {
board = previousBoard;
if (selected?.id === cardId) selectedColumnId = previousSelectedColumn;
error = `${de ? "Verschieben nicht bestätigt. Bitte aktualisieren und Schreibrechte prüfen." : "Move not confirmed. Refresh and check write access."} ${String(cause)}`;
moveMessage = "";
}
} finally { if (!destroyed) moving = false; }
}
async function timeout<T>(promise: Promise<T>, duration: number): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
try {
return await Promise.race([promise, new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(de ? "Das Board hat nicht rechtzeitig geantwortet." : "The board did not respond in time.")), duration);
})]);
} finally { clearTimeout(timer!); }
}
async function openOriginal(url: string) {
try { await openInBrowser(url); } catch (cause) { error = String(cause); }
}
function notice(text: string) {
if (!de) return text;
if (text.startsWith("Team scope")) return "Team-Zuordnung, Spalten, geteilte Spalten und Swimlane-Namen werden übernommen. Temporäre Browser-Filter und das Alterslimit für abgeschlossene Karten werden nicht angewendet.";
if (text.startsWith("Saved board scope")) return "Die gespeicherte Board-Konfiguration wird angewendet. Temporäre Suchfilter aus dem Browser werden nicht übernommen.";
if (text.startsWith("Archived cards")) return "Archivierte Karten werden ausgeblendet. Die Spalten entsprechen den Auswahloptionen des Board-Feldes.";
if (text.startsWith("The saved GitHub view filter")) return `Der gespeicherte GitHub-Ansichtsfilter wird nicht angewendet. Alle nicht archivierten Projektkarten erscheinen in ihren ursprünglichen Spalten. ${text.split(": ")[1]?.split(". All non-archived")[0] ?? ""}`;
return text;
}
</script>
<svelte:window onkeydown={event => { if (event.key === "Escape") selected = null; }} />
<section class="board-view" aria-label={de ? "Kanban-Board" : "Kanban board"}>
<div class="board-directory">
<SelectMenu class="board-select" value={activeUrl} options={[{ value: "", label: de ? "Board auswählen" : "Select board" }, ...savedBoards.map(item => ({ value: item.webUrl, label: `${item.scope ? `${item.scope} · ` : ""}${item.title}` }))]} ariaLabel={de ? "Board auswählen" : "Select board"} onChange={value => { if (value && !moving) { boardUrl = value; void loadBoard(value, true); } }} />
<button class="workspace-button" disabled={discovering} onclick={discoverBoards}><RefreshCw size={14} />{de ? "Boards suchen" : "Find boards"}</button>
<small aria-live="polite">{discovering ? (de ? "Boards werden im Hintergrund gesucht …" : "Finding boards in background …") : `${savedBoards.length} Boards`}</small>
{#if !board && boardUrl && validBoardUrl(boardUrl)}<button class="workspace-button" onclick={() => openOriginal(boardUrl)}><ExternalLink size={14} />{de ? "Original öffnen" : "Open original"}</button>{/if}
</div>
{#if discoveryWarnings.length}<details class="board-discovery-notice" open={!savedBoards.length}><summary>{de ? "Hinweise zur Board-Suche" : "Board discovery notices"} ({discoveryWarnings.length})</summary>{#each discoveryWarnings as warning}<p>{warning}</p>{/each}</details>{/if}
{#if board}
<div class="board-header">
<div class="board-title"><h2>{board.title}</h2><small>{source.label} <span>·</span> {canMove ? (de ? "Karten zwischen Spalten verschieben" : "Move cards between columns") : (de ? "Lesende Ansicht" : "Read-only view")}{#if loading} <span>·</span> {de ? "Aktualisierung läuft …" : "Updating …"}{/if}</small></div>
<div class="board-tools">
<SelectMenu class="repository-select" value={repositoryFilter} options={repositoryOptions} ariaLabel={de ? "Repository filtern" : "Filter repository"} onChange={value => { repositoryFilter = value; selected = null; }} />
<label class="workspace-search"><Search size={15} /><input bind:value={query} aria-label={de ? "Board durchsuchen" : "Search board"} placeholder={de ? "Karten suchen …" : "Search cards …"} /></label>
<button class="workspace-button" class:active={connectOpen} aria-expanded={connectOpen} onclick={() => { connectOpen = !connectOpen; }}><Link2 size={14} />{de ? "Board wechseln" : "Change board"}</button>
<button class="workspace-button icon-button" disabled={loading || moving} onclick={() => loadBoard(activeUrl)} aria-label={de ? "Board aktualisieren" : "Refresh board"}><RefreshCw size={15} /></button>
<button class="workspace-button" onclick={() => openOriginal(board!.webUrl)}><ExternalLink size={14} />{de ? "Original öffnen" : "Open original"}</button>
</div>
</div>
{/if}
{#if connectOpen || !board}
<form class="board-connect" onsubmit={event => { event.preventDefault(); void loadBoard(boardUrl, true); }}>
<label for="native-board-url">{de ? "Original-Board" : "Original board"}</label>
<div class="board-connect-fields"><input id="native-board-url" type="url" bind:value={boardUrl} {placeholder} required aria-label={de ? "Board-Link" : "Board URL"} /><button class="workspace-button" type="submit" disabled={moving || !boardUrl.trim()}>{de ? "Board übernehmen" : "Load board"}</button></div>
</form>
{/if}
{#if moveMessage}<p class="board-notice" role="status">{moveMessage}</p>{/if}
{#if error}<div class="workspace-notice error" role="alert">{error}{#if board}<p>{de ? "Der zuletzt geladene Stand bleibt sichtbar." : "The last loaded board remains visible."}</p>{/if}</div>{/if}
{#if board}
{#if board.notice}<p class="board-notice">{notice(board.notice)}</p>{/if}
<div class="board-body">
<div class="board-columns" aria-label={de ? "Board-Spalten" : "Board columns"}>
{#each columns as column (column.id)}
<section class="board-column" class:drop-target={dropColumn === column.id} aria-label={column.title}
ondragover={event => { if (dragged && column.moveTarget && !moving && !loading && dragged.columnId !== column.id) { event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; dropColumn = column.id; } }}
ondragleave={event => { if (!(event.relatedTarget instanceof Node) || !event.currentTarget.contains(event.relatedTarget)) dropColumn = ""; }}
ondrop={event => { event.preventDefault(); if (dragged) void moveCard(dragged.cardId, dragged.columnId, column.id); dropColumn = ""; }}>
<header><h3><span class="column-dot" data-tone={issueTone(column.title)}></span>{column.title}</h3><span class="column-count">{column.cards.length}{#if column.limit}<span class="wip-limit"> · WIP {column.limit}</span>{/if}</span></header>
<div class="column-cards">
{#each column.cards as card (`${column.id}:${card.id}`)}
<button class="board-card" draggable={!!column.moveTarget && !moving && !loading}
ondragstart={event => { if (moving || loading || !column.moveTarget) { event.preventDefault(); return; } dragged = { cardId: card.id, columnId: column.id }; if (event.dataTransfer) { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", card.id); } }}
ondragend={() => { dragged = null; dropColumn = ""; }} class:chosen={selected?.id === card.id} aria-pressed={selected?.id === card.id} onclick={() => { selected = card; selectedColumnId = column.id; }}>
{#if card.repository || card.number}<small class="card-reference">{card.repository}<span class="issue-number">{card.number ? `#${card.number}` : ""}</span></small>{/if}
<strong>{card.title}</strong>
{#if card.lane}<span class="lane">{card.lane}</span>{/if}
<IssueLabels labels={card.labels} />
{#if card.assignees.length}<IssueAssignees names={card.assignees} />{/if}
</button>
{:else}<p class="column-empty">{de ? "Keine Karten" : "No cards"}</p>{/each}
</div>
</section>
{:else}<p class="column-empty">{de ? "Dieses Board hat keine Spalten." : "This board has no columns."}</p>{/each}
</div>
{#if selected}
<aside class="issue-inspector board-inspector" aria-label={de ? "Kartendetails" : "Card details"}>
<div class="inspector-top"><span>{selected.repository} <span class="issue-number">{selected.number ? `#${selected.number}` : ""}</span></span><button class="workspace-button close-button" onclick={() => { selected = null; }} aria-label={de ? "Kartendetails schließen" : "Close card details"}><X size={16} /></button></div>
<h2>{selected.title}</h2>
{#if canMove}
{@const currentColumn = board.columns.find(column => column.id === selectedColumnId && column.cards.some(card => card.id === selected?.id))}
{#if currentColumn?.moveTarget}
<div class="card-move-control"><small>{de ? "In Spalte verschieben" : "Move to column"}</small><SelectMenu value={currentColumn.id} options={board.columns.filter(column => column.moveTarget).map(column => ({ value: column.id, label: column.title }))} ariaLabel={de ? "Karte verschieben" : "Move card"} onChange={value => { if (selected && currentColumn) void moveCard(selected.id, currentColumn.id, value); }} /></div>
{/if}
{/if}
<div class="inspector-properties"><IssueAssignees names={selected.assignees} />{#if selected.lane}<span class="lane">{selected.lane}</span>{/if}</div>
{#if selected.labels.length}<div class="inspector-labels"><small>Labels</small><IssueLabels labels={selected.labels} /></div>{/if}
<div class="inspector-description"><h3>{de ? "Beschreibung" : "Description"}</h3><div class="description">{selected.description || (de ? "Weitere Details im Original-Board." : "More details in the original board.")}</div></div>
{#if selected.webUrl}<button class="workspace-button inspector-open" onclick={() => openOriginal(selected!.webUrl)}><ExternalLink size={14} />{de ? "Karte öffnen" : "Open card"}</button>{/if}
</aside>
{/if}
</div>
{:else}
<div class="workspace-empty"><span class="empty-symbol"><Columns3 size={26} /></span><h2>{loading ? (de ? "Board wird im Hintergrund geladen" : "Loading board in background") : (de ? "Dein vorhandenes Board übernehmen" : "Load your existing board")}</h2><p>{de ? "Wähle oben ein gefundenes Board aus oder füge einen Board-Link ein. Spalten und Karten werden aus der Integration gelesen; der Link wird für diese Integration gespeichert." : "Select a discovered board above or paste a board link. Columns and cards come from the integration; the link is saved for this integration."}</p>
{#if source.provider === "gitea"}<p>{de ? "Gitea benötigt eine Serverversion mit Projects-/Columns-API." : "Gitea requires a server version with the Projects/Columns API."}</p>{/if}
</div>
{/if}
</section>
+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>
@@ -0,0 +1,186 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { listIntegrationReviewRequests, listRemotes } from "../git";
import { configuredIntegrationSources, integrationCredentialKey } from "../integrations";
import type { GitIntegrationSettings, GitIntegrationSource, IntegrationReviewRequest, StoredCredential } from "../types";
import type { PullRequestBadgeState } from "../pullRequestBadges";
interface DashboardRepository { path: string; }
interface RemoteIdentity { host: string; repository: string; organization: string; }
export let repos: DashboardRepository[] = [];
export let language: "de" | "en" = "en";
export let integrations: GitIntegrationSettings;
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
export let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
const STORAGE_KEY = "gitty.pull-request-badges.v1";
let pullRequestLoadGeneration = 0;
let lastPullRequestLoadKey = "";
let mounted = false;
let loading = false;
$: de = language === "de";
$: integrationSources = configuredIntegrationSources(integrations);
$: sourceKey = JSON.stringify(integrationSources.map(source => [source.id, source.provider, source.accountId, source.baseUrl]));
$: loadKey = JSON.stringify([repos.map(repo => repo.path).sort(), sourceKey]);
$: if (mounted && loadKey !== lastPullRequestLoadKey) {
lastPullRequestLoadKey = loadKey;
restoreCache();
void refresh();
}
onMount(() => {
mounted = true;
const timer = window.setInterval(() => { if (!loading) void refresh(); }, 3 * 60_000);
return () => window.clearInterval(timer);
});
onDestroy(() => { mounted = false; ++pullRequestLoadGeneration; });
function restoreCache() {
pullRequestBadges = {};
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null");
if (saved?.sourceKey !== sourceKey || !saved?.badges) return;
for (const repo of repos) {
const badge = saved.badges[repo.path];
if (badge?.status === "ready" && Number.isInteger(badge.count) && badge.count >= 0
&& typeof badge.repository === "string" && typeof badge.sourceId === "string"
&& integrationSources.some(source => source.id === badge.sourceId)) {
pullRequestBadges[repo.path] = { ...badge, message: "" };
}
}
} catch { /* Cache is optional. */ }
}
async function refresh() {
loading = true;
const pending = loadPullRequestBadges();
const generation = pullRequestLoadGeneration;
try { await pending; }
finally {
if (generation === pullRequestLoadGeneration) {
loading = false;
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ sourceKey, badges: pullRequestBadges })); }
catch { /* Keep in-memory values if storage is unavailable. */ }
}
}
}
function remoteIdentity(value: string): RemoteIdentity | null {
const trimmed = value.trim();
if (!trimmed) return null;
let host = "";
let path = "";
const scp = trimmed.match(/^[^@\s]+@([^:\s]+):(.+)$/);
if (scp) {
host = scp[1];
path = scp[2];
} else {
try {
const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
host = url.hostname;
path = url.pathname;
} catch {
return null;
}
}
const parts = path.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").split("/").filter(Boolean);
const gitIndex = parts.findIndex((part) => part.toLocaleLowerCase() === "_git");
const normalizedHost = host.toLocaleLowerCase();
if (gitIndex > 0 && parts[gitIndex + 1]) return { host: normalizedHost, repository: `${parts[gitIndex - 1]}/${parts[gitIndex + 1]}`.toLocaleLowerCase(), organization: normalizedHost === "dev.azure.com" ? (parts[0] ?? "").toLocaleLowerCase() : "" };
if (parts[0]?.toLocaleLowerCase() === "v3" && parts.length >= 4) return { host: normalizedHost, repository: `${parts[2]}/${parts[3]}`.toLocaleLowerCase(), organization: parts[1].toLocaleLowerCase() };
return { host: normalizedHost, repository: parts.join("/").toLocaleLowerCase(), organization: "" };
}
function sourceMatchesRemote(source: GitIntegrationSource, identity: RemoteIdentity): boolean {
try {
const sourceUrl = new URL(source.baseUrl);
const sourceHost = sourceUrl.hostname.toLocaleLowerCase();
const remoteHost = identity.host;
if (source.provider === "azure-devops" ? sourceHost.replace(/^ssh\./, "") !== remoteHost.replace(/^ssh\./, "") : sourceHost !== remoteHost) return false;
if (source.provider !== "azure-devops") return true;
const organization = sourceUrl.hostname.toLocaleLowerCase() === "dev.azure.com"
? sourceUrl.pathname.split("/").filter(Boolean)[0]?.toLocaleLowerCase() ?? ""
: sourceUrl.hostname.split(".")[0]?.toLocaleLowerCase() ?? "";
return !organization || !identity.organization || organization === identity.organization;
} catch {
return false;
}
}
function requestMatchesRepository(request: IntegrationReviewRequest, repository: string): boolean {
const requestRepository = request.repositoryName.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLocaleLowerCase();
if (!requestRepository) return false;
if (requestRepository === repository || repository.endsWith(`/${requestRepository}`) || requestRepository.endsWith(`/${repository}`)) return true;
const requestParts = requestRepository.split("/");
const remoteParts = repository.split("/");
return requestParts[requestParts.length - 1] === remoteParts[remoteParts.length - 1];
}
function updatePullRequestBadge(path: string, state: PullRequestBadgeState, generation: number) {
if (generation !== pullRequestLoadGeneration) return;
if (state.status === "error" && pullRequestBadges[path]?.status === "ready") {
state = { ...pullRequestBadges[path], message: state.message };
}
pullRequestBadges = { ...pullRequestBadges, [path]: state };
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs);
promise.then((value) => { window.clearTimeout(timer); resolve(value); }, (error) => { window.clearTimeout(timer); reject(error); });
});
}
async function loadPullRequestBadges() {
const generation = ++pullRequestLoadGeneration;
const sources = integrationSources;
pullRequestBadges = Object.fromEntries(repos.map((repo) => [repo.path, pullRequestBadges[repo.path] ?? {
status: sources.length ? "loading" : "unavailable",
count: 0,
sourceId: "",
repository: "",
message: sources.length ? "" : (de ? "Keine passende Integration eingerichtet." : "No matching integration configured."),
} satisfies PullRequestBadgeState]));
if (!repos.length || !sources.length) return;
const grouped = new Map<string, Array<{ repo: DashboardRepository; identity: RemoteIdentity }>>();
await Promise.all(repos.map(async (repo) => {
try {
const remotes = await withTimeout(listRemotes(repo.path), 15_000, "Remote lookup timed out.");
const identities = remotes
.sort((left, right) => Number(right.name === "origin") - Number(left.name === "origin"))
.map((remote) => remoteIdentity(remote.fetch_url))
.filter((identity): identity is RemoteIdentity => Boolean(identity));
const match = identities.map((identity) => ({ identity, source: sources.find((source) => sourceMatchesRemote(source, identity)) })).find((item) => item.source);
if (!match?.source) {
updatePullRequestBadge(repo.path, { status: "unavailable", count: 0, sourceId: "", repository: "", message: de ? "Keine passende Integration für das Remote gefunden." : "No matching integration for this remote." }, generation);
return;
}
grouped.set(match.source.id, [...(grouped.get(match.source.id) ?? []), { repo, identity: match.identity }]);
} catch (error) {
updatePullRequestBadge(repo.path, { status: "error", count: 0, sourceId: "", repository: "", message: error instanceof Error ? error.message : String(error) }, generation);
}
}));
if (generation !== pullRequestLoadGeneration) return;
await Promise.all([...grouped.entries()].map(async ([sourceId, entries]) => {
const source = sources.find((candidate) => candidate.id === sourceId);
if (!source) return;
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000, de ? "Der System-Schlüsselbund hat nicht geantwortet." : "The system keychain did not respond.");
if (!credential?.password) throw new Error(de ? "Kein Integrationstoken gespeichert." : "No integration token stored.");
const requests = await withTimeout(listIntegrationReviewRequests(source.provider, source.baseUrl, credential.username, credential.password, "open"), 46_000, de ? `${source.label} hat nicht geantwortet.` : `${source.label} did not respond.`);
for (const { repo, identity } of entries) {
updatePullRequestBadge(repo.path, {
status: "ready",
count: requests.filter((request) => requestMatchesRepository(request, identity.repository)).length,
sourceId,
repository: identity.repository,
message: "",
}, generation);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
for (const { repo, identity } of entries) updatePullRequestBadge(repo.path, { status: "error", count: 0, sourceId, repository: identity.repository, message }, generation);
}
}));
}
</script>
+6 -148
View File
@@ -1,8 +1,6 @@
<script lang="ts">
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, Circle, Download, FolderGit2, GitBranch, GitPullRequest, LayoutGrid, List, LoaderCircle, Plus, Search, Star, TriangleAlert, Trash2, X } from "@lucide/svelte";
import { listIntegrationReviewRequests, listRemotes } from "../git";
import { configuredIntegrationSources, integrationCredentialKey } from "../integrations";
import type { GitIntegrationSettings, GitIntegrationSource, IntegrationReviewRequest, StoredCredential } from "../types";
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, Circle, Download, FolderGit2, GitBranch, GitPullRequest, LayoutGrid, List, Plus, Search, Star, TriangleAlert, Trash2, X } from "@lucide/svelte";
import type { PullRequestBadgeState } from "../pullRequestBadges";
import SelectMenu from "./SelectMenu.svelte";
interface DashboardRepository {
@@ -20,16 +18,6 @@
}
interface Workspace { id: string; name: string; }
interface PullRequestBadgeState {
status: "loading" | "ready" | "error" | "unavailable";
count: number;
sourceId: string;
repository: string;
message: string;
}
interface RemoteIdentity { host: string; repository: string; organization: string; }
export let repos: DashboardRepository[] = [];
export let language: "de" | "en" = "en";
export let isBusy = false;
@@ -40,8 +28,6 @@
export let onInit: () => unknown;
export let onFavorite: (path: string) => unknown;
export let onRemoveRecent: (path: string) => unknown;
export let integrations: GitIntegrationSettings;
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
export let onOpenPullRequests: (repository: string, sourceId: string) => unknown;
const STORAGE_KEY = "gitty.dashboard.v1";
@@ -56,9 +42,7 @@
let workspaceName = "";
let workspaceError = "";
let workspaceSelection = new Set<string>();
let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
let pullRequestLoadGeneration = 0;
let lastPullRequestLoadKey = "";
export let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
$: de = language === "de";
$: normalizedQuery = query.trim().toLocaleLowerCase();
@@ -79,16 +63,6 @@
{ value: "", label: de ? "Alle Workspaces" : "All workspaces" },
...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name, group: "Workspaces" })),
];
$: integrationSources = configuredIntegrationSources(integrations);
$: pullRequestLoadKey = JSON.stringify({
repositories: repos.map((repo) => repo.path),
sources: integrationSources.map((source) => [source.id, source.baseUrl]),
});
$: if (pullRequestLoadKey !== lastPullRequestLoadKey) {
lastPullRequestLoadKey = pullRequestLoadKey;
void loadPullRequestBadges();
}
loadPreferences();
function loadPreferences() {
@@ -175,127 +149,11 @@
if (!isBusy && badge?.status === "ready") onOpenPullRequests(badge.repository || repo.name, badge.sourceId);
}
function remoteIdentity(value: string): RemoteIdentity | null {
const trimmed = value.trim();
if (!trimmed) return null;
let host = "";
let path = "";
const scp = trimmed.match(/^[^@\s]+@([^:\s]+):(.+)$/);
if (scp) {
host = scp[1];
path = scp[2];
} else {
try {
const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
host = url.hostname;
path = url.pathname;
} catch {
return null;
}
}
const parts = path.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").split("/").filter(Boolean);
const gitIndex = parts.findIndex((part) => part.toLocaleLowerCase() === "_git");
const normalizedHost = host.toLocaleLowerCase();
if (gitIndex > 0 && parts[gitIndex + 1]) return { host: normalizedHost, repository: `${parts[gitIndex - 1]}/${parts[gitIndex + 1]}`.toLocaleLowerCase(), organization: normalizedHost === "dev.azure.com" ? (parts[0] ?? "").toLocaleLowerCase() : "" };
if (parts[0]?.toLocaleLowerCase() === "v3" && parts.length >= 4) return { host: normalizedHost, repository: `${parts[2]}/${parts[3]}`.toLocaleLowerCase(), organization: parts[1].toLocaleLowerCase() };
return { host: normalizedHost, repository: parts.join("/").toLocaleLowerCase(), organization: "" };
}
function sourceMatchesRemote(source: GitIntegrationSource, identity: RemoteIdentity): boolean {
try {
const sourceUrl = new URL(source.baseUrl);
const sourceHost = sourceUrl.hostname.toLocaleLowerCase();
const remoteHost = identity.host;
if (source.provider === "azure-devops" ? sourceHost.replace(/^ssh\./, "") !== remoteHost.replace(/^ssh\./, "") : sourceHost !== remoteHost) return false;
if (source.provider !== "azure-devops") return true;
const organization = sourceUrl.hostname.toLocaleLowerCase() === "dev.azure.com"
? sourceUrl.pathname.split("/").filter(Boolean)[0]?.toLocaleLowerCase() ?? ""
: sourceUrl.hostname.split(".")[0]?.toLocaleLowerCase() ?? "";
return !organization || !identity.organization || organization === identity.organization;
} catch {
return false;
}
}
function requestMatchesRepository(request: IntegrationReviewRequest, repository: string): boolean {
const requestRepository = request.repositoryName.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLocaleLowerCase();
if (!requestRepository) return false;
if (requestRepository === repository || repository.endsWith(`/${requestRepository}`) || requestRepository.endsWith(`/${repository}`)) return true;
const requestParts = requestRepository.split("/");
const remoteParts = repository.split("/");
return requestParts[requestParts.length - 1] === remoteParts[remoteParts.length - 1];
}
function updatePullRequestBadge(path: string, state: PullRequestBadgeState, generation: number) {
if (generation !== pullRequestLoadGeneration) return;
pullRequestBadges = { ...pullRequestBadges, [path]: state };
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs);
promise.then((value) => { window.clearTimeout(timer); resolve(value); }, (error) => { window.clearTimeout(timer); reject(error); });
});
}
async function loadPullRequestBadges() {
const generation = ++pullRequestLoadGeneration;
const sources = integrationSources;
pullRequestBadges = Object.fromEntries(repos.map((repo) => [repo.path, {
status: sources.length ? "loading" : "unavailable",
count: 0,
sourceId: "",
repository: "",
message: sources.length ? "" : (de ? "Keine passende Integration eingerichtet." : "No matching integration configured."),
} satisfies PullRequestBadgeState]));
if (!repos.length || !sources.length) return;
const grouped = new Map<string, Array<{ repo: DashboardRepository; identity: RemoteIdentity }>>();
await Promise.all(repos.map(async (repo) => {
try {
const remotes = await listRemotes(repo.path);
const identities = remotes
.sort((left, right) => Number(right.name === "origin") - Number(left.name === "origin"))
.map((remote) => remoteIdentity(remote.fetch_url))
.filter((identity): identity is RemoteIdentity => Boolean(identity));
const match = identities.map((identity) => ({ identity, source: sources.find((source) => sourceMatchesRemote(source, identity)) })).find((item) => item.source);
if (!match?.source) {
updatePullRequestBadge(repo.path, { status: "unavailable", count: 0, sourceId: "", repository: "", message: de ? "Keine passende Integration für das Remote gefunden." : "No matching integration for this remote." }, generation);
return;
}
grouped.set(match.source.id, [...(grouped.get(match.source.id) ?? []), { repo, identity: match.identity }]);
} catch (error) {
updatePullRequestBadge(repo.path, { status: "error", count: 0, sourceId: "", repository: "", message: error instanceof Error ? error.message : String(error) }, generation);
}
}));
await Promise.all([...grouped.entries()].map(async ([sourceId, entries]) => {
const source = sources.find((candidate) => candidate.id === sourceId);
if (!source) return;
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000, de ? "Der System-Schlüsselbund hat nicht geantwortet." : "The system keychain did not respond.");
if (!credential?.password) throw new Error(de ? "Kein Integrationstoken gespeichert." : "No integration token stored.");
const requests = await withTimeout(listIntegrationReviewRequests(source.provider, source.baseUrl, credential.username, credential.password, "open"), 46_000, de ? `${source.label} hat nicht geantwortet.` : `${source.label} did not respond.`);
for (const { repo, identity } of entries) {
updatePullRequestBadge(repo.path, {
status: "ready",
count: requests.filter((request) => requestMatchesRepository(request, identity.repository)).length,
sourceId,
repository: identity.repository,
message: "",
}, generation);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
for (const { repo, identity } of entries) updatePullRequestBadge(repo.path, { status: "error", count: 0, sourceId, repository: identity.repository, message }, generation);
}
}));
}
function pullRequestTitle(badge: PullRequestBadgeState | undefined): string {
if (!badge || badge.status === "loading") return de ? "Offene Pull Requests werden geladen" : "Loading open pull requests";
if (badge.status === "error" || badge.status === "unavailable") return badge.message;
return de ? `${badge.count} offene Pull Requests` : `${badge.count} open pull requests`;
const title = de ? `${badge.count} offene Pull Requests` : `${badge.count} open pull requests`;
return badge.message ? `${title} (${de ? "Letzter bekannter Stand" : "Last known status"}: ${badge.message})` : title;
}
function toggleCategory(id: string) {
const next = new Set(collapsedCategories);
@@ -368,7 +226,7 @@
</button>
<div class="card-actions">
<button class:has-open-prs={pullRequests?.status === "ready" && pullRequests.count > 0} class:pr-error={pullRequests?.status === "error"} class="pr-badge" type="button" onclick={(event) => openPullRequests(event, repo)} disabled={isBusy || !pullRequests || pullRequests.status !== "ready"} aria-label={`${repo.name}: ${pullRequestTitle(pullRequests)}`} title={pullRequestTitle(pullRequests)}>
{#if !pullRequests || pullRequests.status === "loading"}<LoaderCircle class="spin" size={14} />{:else if pullRequests.status === "error"}<TriangleAlert size={14} />{:else}<GitPullRequest size={14} /><span>{pullRequests.status === "ready" ? pullRequests.count : ""}</span>{/if}
{#if pullRequests?.status === "error"}<TriangleAlert size={14} />{:else}<GitPullRequest size={14} /><span>{pullRequests?.status === "ready" ? pullRequests.count : ""}</span>{/if}
</button>
<button class:active={repo.favorite} type="button" onclick={(event) => favoriteCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Favorit umschalten" : "Toggle favorite"}`} aria-pressed={repo.favorite}><Star size={15} /></button>
{#if repo.isOpen && category.id === "open"}<button type="button" onclick={(event) => closeCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Schließen" : "Close"}`} title={de ? "Repository schließen" : "Close repository"}><X size={15} /></button>{/if}
+37 -32
View File
@@ -1,12 +1,14 @@
<script lang="ts">
import { onMount } from "svelte";
import CreateReviewDialog from "./CreateReviewDialog.svelte";
import CommentEditor from "./CommentEditor.svelte";
import SelectMenu from "./SelectMenu.svelte";
import { cubicOut } from "svelte/easing";
import { fly } from "svelte/transition";
import {
AlertTriangle, 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";
@@ -16,6 +18,7 @@
interface Props {
language: AppLanguage;
localRepositoryPath?: string;
integrations: GitIntegrationSettings;
initialQuery?: string;
initialSourceId?: string;
@@ -31,7 +34,8 @@
onPushLocalResolution?: () => void | Promise<void>;
}
let { language = "en", integrations, initialQuery = "", initialSourceId = "", localResolutionRequestId = "", localResolutionPhase = "idle", localResolutionMessage = "", loadCredential, onOpenSettings = () => {}, onStartLocalResolution = () => {}, onOpenLocalResolver = () => {}, onContinueLocalResolution = () => {}, onAbortLocalResolution = () => {}, onPushLocalResolution = () => {} }: Props = $props();
let { localRepositoryPath = "", language = "en", integrations, initialQuery = "", initialSourceId = "", localResolutionRequestId = "", localResolutionPhase = "idle", localResolutionMessage = "", loadCredential, onOpenSettings = () => {}, onStartLocalResolution = () => {}, onOpenLocalResolver = () => {}, onContinueLocalResolution = () => {}, onAbortLocalResolution = () => {}, onPushLocalResolution = () => {} }: Props = $props();
let createOpen = $state(false);
let requests = $state<IntegrationReviewRequest[]>([]);
let loading = $state(false);
let errors = $state<Array<{ source: string; message: string }>>([]);
@@ -46,7 +50,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 +238,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;
@@ -398,9 +387,24 @@
}
</script>
{#if createOpen && activeSource}
<CreateReviewDialog {localRepositoryPath} source={activeSource} {de} {loadCredential} onClose={() => createOpen = false} onCreated={(request) => {
++loadGeneration;
loading = false;
requests = [request, ...requests.filter(item => item.id !== request.id)];
stateFilter = request.state;
query = "";
collapsedRepositories = new Set();
errors = [];
createOpen = false;
selectRequest(request, true);
}}/>
{/if}
<section class:has-inspector={detailOpen && !!selected} class="review-center" aria-label="Review Center">
<header class="review-header">
<div class="review-heading"><GitPullRequest size={17} /><h1>Review Center</h1></div>
{#if activeSource}<button class="create-review" type="button" disabled={loading} onclick={() => { detailOpen = false; createOpen = true; }}><GitPullRequest size={14}/>{activeSource.provider.startsWith("gitlab") ? (de ? "MR erstellen" : "Create MR") : (de ? "PR erstellen" : "Create PR")}</button>{/if}
</header>
{#if sources.length === 0}
@@ -511,7 +515,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">
@@ -528,6 +532,7 @@
</section>
<style>
.create-review{margin-left:auto;display:flex;align-items:center;gap:7px;padding:7px 11px;border:1px solid var(--color-border-subtle);border-radius:7px;background:var(--color-accent);color:white;cursor:pointer}.create-review:disabled{opacity:.5;cursor:default}
.review-center{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden;color:var(--color-ink);background:var(--app-bg);font-size:12px}.review-center button,.review-center input{font:inherit}.review-header{display:flex;min-height:48px;align-items:center;padding:0 18px;border-bottom:1px solid var(--color-border-subtle);background:var(--color-surface)}.review-heading{display:flex;align-items:center;gap:9px}.review-heading>:global(svg){color:var(--color-accent)}.review-heading h1{margin:0;font-size:16px;font-weight:650}
.state-tabs{display:flex;min-height:42px;align-items:stretch;gap:18px;padding:0 18px;border-bottom:1px solid var(--color-border-subtle);background:var(--color-surface)}.state-tabs button{position:relative;display:flex;align-items:center;gap:7px;padding:0 5px;border:0;color:var(--color-ink-muted);background:transparent;font-size:11px}.state-tabs button:hover{color:var(--color-ink)}.state-tabs button.active{color:var(--color-accent)}.state-tabs button.active:after{position:absolute;right:0;bottom:0;left:0;height:2px;background:var(--color-accent);content:""}.state-tabs span,.group-header>span{display:grid;min-width:18px;height:18px;place-items:center;padding:0 5px;border-radius:9px;color:var(--color-ink-muted);background:var(--color-surface-raised);font-size:9.5px}
.review-toolbar{display:flex;min-height:48px;align-items:center;gap:10px;padding:7px 18px;border-bottom:1px solid var(--color-border-subtle)}.group-actions{display:flex;align-items:center;gap:2px}.group-actions button,.icon-button{display:inline-flex;height:30px;align-items:center;gap:5px;padding:0 7px;border:1px solid transparent;color:var(--color-ink-muted);background:transparent}.group-actions button:hover,.icon-button:hover{border-color:var(--color-border);color:var(--color-ink);background:var(--color-surface-hover)}.group-actions .icon-button{width:30px;justify-content:center;padding:0;border-color:var(--color-border-subtle);margin-left:3px}.review-search{display:flex;min-width:180px;height:30px;align-items:center;gap:7px;flex:1;padding:0 9px;border:1px solid var(--color-border-input);color:var(--color-ink-faint);background:var(--app-input-bg)}.review-search:focus-within{border-color:var(--color-accent)}.review-search input{width:100%;min-width:0;height:100%;padding:0;border:0;outline:0;color:var(--color-ink);background:transparent}
@@ -542,12 +547,12 @@
@media(max-width:760px){.table-head,.request-row{grid-template-columns:72px minmax(260px,1fr) 190px}.table-head>span:nth-child(5),.repo-branch{display:none}.request-groups{min-width:600px}.merge-summary{align-items:flex-start;flex-wrap:wrap;padding:10px 0}}
.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,20 +572,19 @@
.action-menu{top:36px;width:190px;padding:6px;border-color:color-mix(in srgb,var(--color-border) 86%,var(--color-accent));box-shadow:0 12px 30px #000b,0 0 0 1px #0005;background:color-mix(in srgb,var(--color-surface-raised) 92%,#10151a)}.action-menu:before{position:absolute;top:-5px;right:10px;width:8px;height:8px;transform:rotate(45deg);border-top:1px solid var(--color-border);border-left:1px solid var(--color-border);background:inherit;content:""}.action-menu button{position:relative;height:34px;gap:10px;padding:0 10px;border-left:2px solid transparent;color:var(--color-ink-muted);font-size:10.5px}.action-menu button:hover:not(:disabled){color:var(--color-ink);border-left-color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 8%,var(--color-surface-hover))}.action-menu button>:global(svg){flex:0 0 auto}.action-menu .menu-merge>:global(svg){color:#72d58f}.action-menu .menu-approve>:global(svg),.action-menu .menu-provider>:global(svg){color:var(--color-accent)}.action-menu button.danger{margin-top:4px;border-top:1px solid var(--color-border-subtle);color:#e7767e}.action-menu button.danger:hover{border-left-color:#e7767e;background:color-mix(in srgb,#e7767e 8%,var(--color-surface-hover))}.action-menu .menu-conflict{color:#e2ac58}.action-menu button:disabled{opacity:.48}
.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}}
@media(max-width:1280px){.review-toolbar{grid-template-columns:auto minmax(180px,1fr) 155px auto}.state-tabs button{min-width:52px;padding:0 6px}.table-head,.request-row{grid-template-columns:80px minmax(250px,1.5fr) 110px minmax(190px,1fr) 180px}.table-head>span:nth-child(4),.collaborators{display:none}.table-head,.request-groups{min-width:980px}.detail-panel{width:58%;min-width:680px}}
@media(max-width:900px){.review-toolbar{grid-template-columns:1fr 150px auto;grid-template-rows:38px 38px;padding:0 10px}.state-tabs{grid-column:1/-1;grid-row:1}.review-search{grid-column:1;grid-row:2}.group-actions{grid-column:3;grid-row:2}.detail-panel{width:76%;min-width:620px}.detail-content{grid-template-columns:minmax(0,1fr) 220px;gap:14px}.table-head,.request-row{grid-template-columns:76px minmax(240px,1.5fr) minmax(180px,1fr) 180px}.table-head>span:nth-child(3),.request-author{display:none}.request-groups{min-width:760px}}
@media(max-width:680px){.state-tabs button{min-width:0;flex:1}.group-actions .icon-button:nth-child(-n+2){display:none}.detail-panel{width:100%;min-width:0;max-width:none}.detail-content{display:block;padding:0 13px}.detail-main{height:100%}.detail-sidebar{display:none}}
/* Accepted Review Center concept — faithful final layout. */
.review-header{min-height:54px;padding:0 24px;background:color-mix(in srgb,var(--app-bg) 78%,var(--color-surface))}.review-heading{gap:10px}.review-heading h1{font-size:14px;font-weight:700}
.review-toolbar{min-height:55px;grid-template-columns:350px minmax(220px,1fr) 160px 34px;gap:10px;padding:0 16px;border-bottom-color:var(--color-border);background:color-mix(in srgb,var(--app-bg) 88%,var(--color-surface))}.state-tabs{gap:8px}.state-tabs button{min-width:72px;justify-content:flex-start;padding:0 8px;color:var(--color-ink-muted);font-size:11px}.state-tabs button.active{color:var(--color-ink)}.state-tabs button.active:after{right:0;left:0;height:2px}.state-tabs span{min-width:17px;height:17px;margin-left:auto;border:0;background:var(--color-surface-raised)}.review-search,.group-actions .icon-button{height:34px;background:color-mix(in srgb,var(--app-input-bg) 92%,#11171c)}.review-search{padding:0 11px}.group-actions .icon-button{width:34px}
.table-head,.request-row{grid-template-columns:90px minmax(255px,1.65fr) 105px 120px minmax(180px,1fr) 150px}.table-head{min-height:37px;padding:0 16px;background:color-mix(in srgb,var(--color-surface-raised) 88%,var(--app-bg));font-size:8.5px}.request-groups{min-width:900px}.group-header{height:45px;padding:0 17px;background:color-mix(in srgb,var(--app-bg) 72%,var(--color-surface))}.group-header strong{font-size:11.5px}.request-row{min-height:78px;padding:0 16px}.request-row.selected{background:linear-gradient(90deg,color-mix(in srgb,var(--color-accent) 7%,var(--color-surface-raised)),color-mix(in srgb,var(--color-surface-raised) 72%,var(--app-bg)))}.request-title{gap:8px}.request-title strong{font-size:12px}.request-status{font-size:10.5px}.request-author i,.avatar,.collaborators i{width:28px;height:28px;background:color-mix(in srgb,var(--color-accent) 58%,#26333a)}.provider-button,.action-toggle,.panel-button{height:34px}.provider-button{min-width:102px}.action-toggle{width:34px}.panel-button{width:34px}.action-menu{top:38px;width:154px}.action-menu button{height:36px}
.table-head,.request-row{grid-template-columns:90px minmax(255px,1.65fr) 105px 120px minmax(180px,1fr) 150px}.table-head{min-height:37px;padding:0 16px;background:color-mix(in srgb,var(--color-surface-raised) 88%,var(--app-bg));font-size:8.5px}.table-head,.request-groups{min-width:980px}.group-header{height:45px;padding:0 17px;background:color-mix(in srgb,var(--app-bg) 72%,var(--color-surface))}.group-header strong{font-size:11.5px}.request-row{min-height:78px;padding:0 16px}.request-row.selected{background:linear-gradient(90deg,color-mix(in srgb,var(--color-accent) 7%,var(--color-surface-raised)),color-mix(in srgb,var(--color-surface-raised) 72%,var(--app-bg)))}.request-title{gap:8px}.request-title strong{font-size:12px}.request-status{font-size:10.5px}.request-author i,.avatar,.collaborators i{width:28px;height:28px;background:color-mix(in srgb,var(--color-accent) 58%,#26333a)}.provider-button,.action-toggle,.panel-button{height:34px}.provider-button{min-width:102px}.action-toggle{width:34px}.panel-button{width:34px}.action-menu{top:38px;width:154px}.action-menu button{height:36px}
.detail-panel{width:44%;min-width:680px;max-width:none;background:color-mix(in srgb,var(--app-bg) 94%,var(--color-surface))}.detail-header{min-height:54px;padding:0 24px;background:color-mix(in srgb,var(--app-bg) 78%,var(--color-surface))}.detail-provider strong{font-size:13px}.detail-content{grid-template-columns:minmax(0,1fr) 225px;gap:18px;padding:0 0 0 24px}.detail-main{padding-right:0}.detail-title{padding:18px 0 16px}.detail-title-line{display:flex;min-width:0;align-items:baseline;gap:10px}.detail-title-line>span{flex:0 0 auto;color:var(--color-accent);font-size:17px;font-weight:700}.detail-title h2{min-width:0;margin:0;overflow:hidden;font-size:19px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.detail-title .detail-summary{min-height:33px;gap:7px}.detail-summary .avatar{width:23px;height:23px;margin-left:5px}.detail-summary strong{font-size:10.5px}.summary-separator{color:var(--color-ink-faint)}.detail-branch-route{display:flex;min-width:0;align-items:center;gap:6px;color:var(--color-ink-muted)}.detail-branch-route>:global(svg){color:var(--color-ink-muted)}.detail-branch-route code{max-width:95px;overflow:hidden;color:var(--color-ink-muted);font-size:10.5px;text-overflow:ellipsis;white-space:nowrap}.detail-branch-route>span{color:var(--color-ink-faint)}.state-badge{padding:0;border:0;font-size:10.5px}
.detail-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}}
@@ -591,12 +595,12 @@
.review-center.has-inspector{--inspector-width:44%}
.review-header,.detail-header{box-sizing:border-box;height:54px;min-height:54px;background:color-mix(in srgb,var(--app-bg) 94%,var(--color-surface))}
.review-heading h1,.detail-provider strong{font-size:14px}.detail-provider>:global(svg){display:none}.detail-header{gap:4px}.detail-header-actions{display:contents}.detail-header-actions button:first-child{margin-right:auto}.detail-provider{flex:none}
.review-toolbar{height:60px;min-height:60px}.review-search input{font-size:13px}.review-center .state-tabs button{font-size:13px}.state-tabs span{font-size:11px}.table-pane{padding:8px 7px 0;box-sizing:border-box}.table-head{padding:0 16px;height:37px}.table-head,.request-row{grid-template-columns:100px minmax(210px,1fr) 106px 120px 165px 145px}.table-head>span{font-size:10px}.request-groups{min-width:900px}.group-header{height:44px;padding:0 12px}.group-header strong{font-size:13px}.request-row{padding:0 10px;min-height:78px}.request-row.selected{outline-color:color-mix(in srgb,var(--color-accent) 50%,var(--color-border));background:color-mix(in srgb,var(--app-bg) 93%,var(--color-accent));box-shadow:none}.request-row.selected:before{width:2px;box-shadow:none}.request-status{font-size:13px;font-weight:400}.request-status>span{font-weight:400}.request-status small{font-size:11px}.request-title strong{font-size:13px;font-weight:650}.request-title code{font-size:12px}.change-stats{font-size:12px}.request-author{font-size:12px}.repo-branch>strong{font-size:12px;font-weight:500}.branch-route code{padding:0;background:none;font-size:11px}.branch-route{gap:6px}
.review-center .provider-button{min-width:68px;padding:0 8px;font-size:13px;font-weight:500}.provider-button.merge-primary>:global(svg){display:none}.provider-action .action-toggle{width:32px;background:transparent}.row-actions{gap:7px}.panel-button{margin:0;width:32px}.action-menu{width:154px;padding:4px;background:color-mix(in srgb,var(--app-bg) 90%,var(--color-surface-raised));border:1px solid var(--color-border);box-shadow:0 6px 12px #0005}.action-menu button{font-size:13px;gap:10px;height:37px;padding:0 10px}.action-menu button.danger{margin:0;border-top:0;color:var(--color-ink)}.action-menu button.danger>:global(svg){color:#e7767e}.action-menu .menu-merge>:global(svg),.action-menu .menu-approve>:global(svg),.action-menu .menu-provider>:global(svg){color:var(--color-ink-muted)}
.detail-panel{min-width:0;width:var(--inspector-width,44%);box-shadow:-2px 0 8px #0002}.detail-content{grid-template-columns:minmax(0,1fr) 225px;gap:0;padding:0}.detail-main{padding:0 18px 0 24px}.detail-sidebar{padding:28px 17px 16px;background:transparent;border-left:1px solid var(--review-divider)}.detail-title{padding:17px 0 9px;border:0}.detail-title h2{font-size:21px;font-weight:650}.detail-title-line>span{font-size:20px;color:var(--color-ink-muted)}.detail-title .detail-summary{min-height:34px;gap:7px}.detail-summary strong{font-size:12px;font-weight:400}.state-badge{font-size:13px;font-weight:400}.detail-branch-route code{font-size:13px}.detail-main>.merge-summary{min-height:54px;margin:0;padding:0 11px;gap:10px}.merge-summary>span,.merge-summary>strong{font-size:12px}.detail-change-stats{font-size:12px;gap:9px}.merge-summary>:global(svg){flex:none}.detail-main>.description{padding:20px 0 22px}.description h3{font-size:14px;font-weight:600;margin-bottom:10px}.description p{font-size:13px}.detail-main>.comments-section{padding:12px 0 0;overflow:visible;border:0}.comments-section>header{margin-bottom:10px}.comments-section h3{font-size:14px;font-weight:600}.comments-section>header>span{font-size:10px}.comment-list{gap:10px;margin:0;scrollbar-gutter:auto}.comment-list article .comment-card{min-height:78px;border-color:var(--review-divider)!important;background:transparent!important}.comment-list article header{justify-content:flex-start;gap:5px;padding:0 11px}.comment-card header .avatar{margin-right:5px;width:28px;height:28px}.comment-list article strong{font-size:12px;font-weight:500}.comment-list article time{font-size:11px;margin:0}.owner-badge{margin-left:auto;font-size:10px}.comment-list article p{font-size:12px;padding:2px 14px 15px 56px!important}.comment-composer{margin-top:auto;padding:16px 0 26px}.comment-composer textarea{font-size:13px;min-height:86px;height:86px;max-height:86px;border-bottom:1px solid var(--color-border-input);background:transparent}.comment-composer footer{margin-top:8px;border:0;background:transparent;min-height:39px}.comment-composer .submit-comment{height:37px;margin:0;font-size:13px}.format-actions{gap:4px;padding:0}.comment-composer .format-actions button{width:27px}.detail-sidebar .detail-actions{gap:15px;margin-bottom:27px}.review-center .detail-action{font-size:13px;position:relative}.detail-action>:global(svg){position:absolute;left:12px;width:18px;height:18px}.detail-action.primary-action{background:color-mix(in srgb,#63c783 4%,transparent)}.detail-sidebar section{padding:16px 0;border-top:1px solid var(--review-divider);border-bottom:0}.detail-sidebar section h3{font-size:14px;font-weight:600}.people.compact{display:flex;gap:10px}.people strong,.detail-meta strong,.detail-meta span{font-size:12px}.detail-meta strong,.detail-meta span{display:flex;align-items:center;gap:10px}.detail-meta :global(svg){flex:none;color:var(--color-ink-muted)}
@media(max-width:1450px){.review-center.has-inspector{--inspector-width:52%}.detail-content{grid-template-columns:minmax(0,1fr) 190px}.detail-main{padding:0 14px}.detail-title h2{font-size:17px}.detail-title-line>span{font-size:17px}.detail-summary{flex-wrap:wrap}.detail-main>.merge-summary{flex-wrap:wrap;gap:7px;padding:9px}.detail-change-stats{font-size:10px}.detail-main>.merge-summary>span{font-size:11px}.detail-sidebar{padding:24px 12px}.format-actions{gap:0}.comment-composer .format-actions button{width:23px}}
.review-toolbar{height:60px;min-height:60px}.review-search input{font-size:13px}.review-center .state-tabs button{font-size:13px}.state-tabs span{font-size:11px}.table-pane{padding:0 7px;box-sizing:border-box;border-top:8px solid var(--app-bg)}.table-head{padding:0 16px;height:37px}.table-head,.request-row{grid-template-columns:100px minmax(210px,1fr) 106px 120px 165px 230px}.table-head>span{font-size:10px}.table-head,.request-groups{min-width:980px}.group-header{height:44px;padding:0 12px}.group-header strong{font-size:13px}.request-row{padding:0 10px;min-height:78px}.request-row.selected{outline-color:color-mix(in srgb,var(--color-accent) 50%,var(--color-border));background:color-mix(in srgb,var(--app-bg) 93%,var(--color-accent));box-shadow:none}.request-row.selected:before{width:2px;box-shadow:none}.request-status{font-size:13px;font-weight:400}.request-status>span{font-weight:400}.request-status small{font-size:11px}.request-title strong{font-size:13px;font-weight:650}.request-title code{font-size:12px}.change-stats{font-size:12px}.request-author{font-size:12px}.repo-branch>strong{font-size:12px;font-weight:500}.branch-route code{padding:0;background:none;font-size:11px}.branch-route{gap:6px}
.review-center .provider-button{min-width:130px;flex-shrink:0;padding:0 10px;font-size:13px;font-weight:500;white-space:nowrap;line-height:1.2}.provider-button>:global(svg){flex-shrink:0}.provider-action,.action-toggle,.panel-button{flex-shrink:0}.provider-button.merge-primary>:global(svg){display:none}.provider-action .action-toggle{width:32px;background:transparent}.row-actions{gap:7px}.panel-button{margin:0;width:32px}.action-menu{width:154px;padding:4px;background:color-mix(in srgb,var(--app-bg) 90%,var(--color-surface-raised));border:1px solid var(--color-border);box-shadow:0 6px 12px #0005}.action-menu button{font-size:13px;gap:10px;height:37px;padding:0 10px}.action-menu button.danger{margin:0;border-top:0;color:var(--color-ink)}.action-menu button.danger>:global(svg){color:#e7767e}.action-menu .menu-merge>:global(svg),.action-menu .menu-approve>:global(svg),.action-menu .menu-provider>:global(svg){color:var(--color-ink-muted)}
.detail-panel{min-width:0;width:var(--inspector-width,44%);box-shadow:-2px 0 8px #0002}.detail-content{grid-template-columns:minmax(0,1fr) 225px;gap:0;padding:0}.detail-main{padding:0 18px 0 24px}.detail-sidebar{padding:28px 17px 16px;background:transparent;border-left:1px solid var(--review-divider)}.detail-title{padding:17px 0 9px;border:0}.detail-title h2{font-size:21px;font-weight:650}.detail-title-line>span{font-size:20px;color:var(--color-ink-muted)}.detail-title .detail-summary{min-height:34px;gap:7px}.detail-summary strong{font-size:12px;font-weight:400}.state-badge{font-size:13px;font-weight:400}.detail-branch-route code{font-size:13px}.detail-main>.merge-summary{min-height:54px;margin:0;padding:0 11px;gap:10px}.merge-summary>span,.merge-summary>strong{font-size:12px}.detail-change-stats{font-size:12px;gap:9px}.merge-summary>:global(svg){flex:none}.detail-main>.description{padding:20px 0 22px}.description h3{font-size:14px;font-weight:600;margin-bottom:10px}.description p{font-size:13px}.detail-main>.comments-section{padding:12px 0 0;overflow:visible;border:0}.comments-section>header{margin-bottom:10px}.comments-section h3{font-size:14px;font-weight:600}.comments-section>header>span{font-size:10px}.comment-list{gap:10px;margin:0;scrollbar-gutter:auto}.comment-list article .comment-card{min-height:78px;border-color:var(--review-divider)!important;background:transparent!important}.comment-list article header{justify-content:flex-start;gap:5px;padding:0 11px}.comment-card header .avatar{margin-right:5px;width:28px;height:28px}.comment-list article strong{font-size:12px;font-weight:500}.comment-list article time{font-size:11px;margin:0}.owner-badge{margin-left:auto;font-size:10px}.comment-list article p{font-size:12px;padding:2px 14px 15px 56px!important}.detail-sidebar .detail-actions{gap:15px;margin-bottom:27px}.review-center .detail-action{font-size:13px;position:relative}.detail-action>:global(svg){position:absolute;left:12px;width:18px;height:18px}.detail-action.primary-action{background:color-mix(in srgb,#63c783 4%,transparent)}.detail-sidebar section{padding:16px 0;border-top:1px solid var(--review-divider);border-bottom:0}.detail-sidebar section h3{font-size:14px;font-weight:600}.people.compact{display:flex;gap:10px}.people strong,.detail-meta strong,.detail-meta span{font-size:12px}.detail-meta strong,.detail-meta span{display:flex;align-items:center;gap:10px}.detail-meta :global(svg){flex:none;color:var(--color-ink-muted)}
@media(max-width:1450px){.review-center.has-inspector{--inspector-width:52%}.detail-content{grid-template-columns:minmax(0,1fr) 190px}.detail-main{padding:0 14px}.detail-title h2{font-size:17px}.detail-title-line>span{font-size:17px}.detail-summary{flex-wrap:wrap}.detail-main>.merge-summary{flex-wrap:wrap;gap:7px;padding:9px}.detail-change-stats{font-size:10px}.detail-main>.merge-summary>span{font-size:11px}.detail-sidebar{padding:24px 12px}}
@media(max-width:900px){.review-center.has-inspector{--inspector-width:100%}.detail-content{display:grid;grid-template-columns:minmax(0,1fr) 200px}.detail-sidebar{display:flex}.detail-main{height:auto}.detail-panel{inset:0 0 0 auto;min-width:0}.group-actions .icon-button{display:flex!important}}
@media(max-width:520px){.detail-content{display:flex;flex-direction:column}.detail-main{flex:1;min-height:0}.detail-sidebar{flex:none;padding:8px 14px;border-top:1px solid var(--review-divider);border-left:0}.detail-sidebar section{display:none}.detail-sidebar .detail-actions{grid-template-columns:1fr 1fr;gap:6px;margin:0}.detail-action{height:32px}.detail-header{padding:0 14px}.detail-main>.description{padding:10px 0}.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 +632,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>
+41 -11
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { tick } from "svelte";
import { Check, ChevronDown } from "@lucide/svelte";
import { Check, ChevronDown, Search } from "@lucide/svelte";
export interface SelectMenuOption {
value: string;
@@ -17,6 +17,9 @@
ariaLabel?: string;
class?: string;
showSelectedGroup?: boolean;
searchable?: boolean;
searchPlaceholder?: string;
emptyText?: string;
onChange: (value: string) => void;
}
@@ -28,9 +31,16 @@
ariaLabel = "",
class: className = "",
showSelectedGroup = false,
searchable = false,
searchPlaceholder = "Search…",
emptyText = "No results",
onChange,
}: Props = $props();
let search = $state("");
let searchInput = $state<HTMLInputElement>();
const visibleOptions = $derived(options.filter(option => !searchable || `${option.label} ${option.group ?? ""}`.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())));
let root = $state<HTMLDivElement>();
let trigger = $state<HTMLButtonElement>();
let open = $state(false);
@@ -39,10 +49,10 @@
const menuId = `select-menu-${Math.random().toString(36).slice(2)}`;
let selectedOption = $derived(options.find((option) => option.value === value));
let enabledIndices = $derived(options.map((option, index) => option.disabled ? -1 : index).filter((index) => index >= 0));
let enabledIndices = $derived(visibleOptions.map((option, index) => option.disabled ? -1 : index).filter((index) => index >= 0));
function groupCount(group: string): number {
return options.filter((option) => option.group === group).length;
return visibleOptions.filter((option) => option.group === group).length;
}
function positionMenu() {
@@ -50,8 +60,8 @@
const rect = trigger.getBoundingClientRect();
const viewportGap = 8;
const menuGap = 5;
const groupHeaderCount = new Set(options.map((option) => option.group).filter(Boolean)).size;
const desiredHeight = Math.min(300, options.length * 32 + groupHeaderCount * 36 + 12);
const groupHeaderCount = new Set(visibleOptions.map((option) => option.group).filter(Boolean)).size;
const desiredHeight = Math.min(300, visibleOptions.length * 32 + (searchable ? 46 : 0) + groupHeaderCount * 36 + 12);
const spaceBelow = window.innerHeight - rect.bottom - viewportGap;
const spaceAbove = rect.top - viewportGap;
const openAbove = spaceBelow < Math.min(desiredHeight, 180) && spaceAbove > spaceBelow;
@@ -63,12 +73,14 @@
}
async function show() {
if (disabled || enabledIndices.length === 0) return;
const selectedIndex = options.findIndex((option) => option.value === value && !option.disabled);
if (disabled || !options.some(option => !option.disabled)) return;
search = "";
const selectedIndex = visibleOptions.findIndex((option) => option.value === value && !option.disabled);
activeIndex = selectedIndex >= 0 ? selectedIndex : enabledIndices[0];
open = true;
await tick();
positionMenu();
if (searchable) searchInput?.focus();
document.getElementById(`${menuId}-option-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
}
@@ -77,7 +89,7 @@
}
function choose(index: number) {
const option = options[index];
const option = visibleOptions[index];
if (!option || option.disabled) return;
onChange(option.value);
close();
@@ -102,6 +114,8 @@
return;
}
if (!open) return;
const editingSearch = event.target === searchInput;
if (editingSearch && [" ", "Home", "End"].includes(event.key)) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
moveActive(event.key === "ArrowDown" ? 1 : -1);
@@ -148,9 +162,14 @@
</button>
{#if open}
<div id={menuId} class="select-menu-popup" style={menuStyle} role="listbox" aria-label={ariaLabel || undefined}>
{#each options as option, index (`${option.value}:${index}`)}
{#if option.group && (index === 0 || options[index - 1]?.group !== option.group)}
<div class="select-menu-popup" class:searchable style={menuStyle}>
{#if searchable}
<div class="select-search"><Search size={14} aria-hidden="true"/><input bind:this={searchInput} bind:value={search} placeholder={searchPlaceholder} aria-label={searchPlaceholder} role="combobox" aria-expanded={open} aria-controls={menuId} aria-autocomplete="list" aria-activedescendant={activeIndex >= 0 ? `${menuId}-option-${activeIndex}` : undefined} onkeydown={handleKeydown} oninput={async () => { await tick(); activeIndex = enabledIndices[0] ?? -1; positionMenu(); }}/></div>
{/if}
<div id={menuId} class="select-options" role="listbox" aria-label={ariaLabel || undefined}>
{#each visibleOptions as option, index (`${option.value}:${index}`)}
{#if option.group && (index === 0 || visibleOptions[index - 1]?.group !== option.group)}
<div class="select-menu-group" role="presentation">
<span>{option.group}</span>
<small>{groupCount(option.group)}</small>
@@ -173,5 +192,16 @@
</button>
{/each}
</div>
{#if searchable && !visibleOptions.length}<div class="select-empty" role="status">{emptyText}</div>{/if}
</div>
{/if}
</div>
<style>
.select-options{display:grid;gap:2px;min-height:0}
.select-menu-popup.searchable{display:flex;flex-direction:column;overflow:hidden}
.searchable .select-options{overflow:auto}
.select-search{display:flex;flex-shrink:0;align-items:center;gap:9px;margin:2px 3px 5px;padding:0 8px;border-bottom:1px solid var(--color-border-subtle);color:var(--color-ink-faint)}
.select-search input{width:100%;min-width:0;height:36px;padding:0;border:0;background:transparent;color:var(--color-ink);font:inherit;outline:none;box-shadow:none}
.select-empty{padding:15px 12px;color:var(--color-ink-muted);font-size:12px}
</style>
+35
View File
@@ -675,3 +675,38 @@ export function resolveConflictSide(
): Promise<GitStatus> {
return invoke<GitStatus>("resolve_conflict_side", { path, file, side });
}
export function listIntegrationIssues(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, cursor: string | null = null): Promise<import("./types").IntegrationIssuePage> {
return invoke("list_integration_issues", { provider, baseUrl, username, token, cursor });
}
export function getIntegrationBoard(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, boardUrl: string): Promise<import("./types").IntegrationBoard> {
return invoke("get_integration_board", { provider, baseUrl, username, token, boardUrl });
}
export function listIntegrationBoards(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string): Promise<{ boards: import("./types").IntegrationBoardReference[]; warnings: string[] }> {
return invoke("list_integration_boards", { provider, baseUrl, username, token });
}
export function moveIntegrationBoardCard(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, boardUrl: string, cardId: string, sourceColumnId: string, targetColumnId: string): Promise<void> {
return invoke("move_integration_board_card", { provider, baseUrl, username, token, boardUrl, cardId, sourceColumnId, targetColumnId });
}
export function listIntegrationIssueComments(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number, cursor: string | null): Promise<{ comments: import("./types").IssueComment[]; nextCursor: string | null }> {
return invoke("list_integration_issue_comments", { provider, baseUrl, username, token, repository, number, cursor });
}
export function addIntegrationIssueComment(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number, body: string): Promise<import("./types").IssueComment> {
return invoke("add_integration_issue_comment", { provider, baseUrl, username, token, repository, number, body });
}
export function closeIntegrationIssue(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number): Promise<string> {
return invoke("close_integration_issue", { provider, baseUrl, username, token, repository, number });
}
export function createIntegrationReviewRequest(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: GitIntegrationRepository, sourceBranch: string, targetBranch: string, title: string, description: string): Promise<IntegrationReviewRequest> {
return invoke("create_integration_review_request", { provider, baseUrl, username, token, repository, sourceBranch, targetBranch, title, description });
}
export function listIntegrationRepositoryBranches(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: GitIntegrationRepository): Promise<{branches: string[]; defaultBranch: string}> {
return invoke("list_integration_repository_branches", {provider, baseUrl, username, token, repository});
}
+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}
+8
View File
@@ -0,0 +1,8 @@
export interface PullRequestBadgeState {
status: "loading" | "ready" | "error" | "unavailable";
count: number;
sourceId: string;
repository: string;
message: string;
}
+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 }