Compare commits

...
13 Commits
Author SHA1 Message Date
Christoph 40b871f888 style(graph): use graph-supplied lane color for behind segments
publish / Build and publish Windows installer (release) Successful in 13m10s
publish / Build and publish Ubuntu AppImage (release) Successful in 16m55s
publish / Build and publish AUR packages (release) Successful in 19m49s
Remove hardcoded stroke and drop-shadow for "behind" graph segments.
This allows the lane color provided by the graph to determine the stroke.
Keep the dashed stroke pattern so "behind" status remains visually clear.

- Remove explicit stroke and filter for behind segments
- Add clarifying comment and retain dash pattern
2026-09-11 14:26:43 +02:00
Christoph be073a8f39 feat(workspaces): add workspace sessions and management
Introduce workspace support to group repositories and store per-workspace
open tabs and the last active repository. Integrate a workspace picker into
the repository tab bar and add workspace controls to the dashboard to
create, edit, switch and delete workspaces. Persist and migrate workspace
preferences robustly and include tests for migration, corruption and edge
cases.

- Persist per-workspace sessions and active repo to localStorage
- Add workspace picker in tabs and dashboard management hooks
- Include migration/validation logic and unit tests for edge cases
2026-09-11 14:15:27 +02:00
Christoph 54972f24b8 feat(repo-tabs): add drag-and-drop reordering for repo tabs
Add pointer-driven drag-and-drop reordering for repository tabs. A
drag state tracks source and target, supports auto-scrolling, and shows
a live animated preview while dragging; accidental clicks are suppressed.
Reorders commit on pointerup and can be cancelled via Escape, blur, or
pointercancel, and reordering is disabled while the app is busy. The
new order is persisted through a callback to the main app.

- Implement pointer handlers, drag lifecycle, and click suppression
- Add transform-based preview, transitional styling, and reduced-motion support
- Wire reorder callback to persist the updated tab order
2026-09-11 14:10:05 +02:00
Christoph eb4346fce2 feat(git): use soft reset to keep changes staged on undo
Change undo-last-commit behavior to perform a soft reset instead of a
mixed reset. This preserves the index and working tree so the undone
commit's changes remain staged and ready to recommit. The confirmation
dialog text was updated to accurately reflect the new behavior.

- Perform git reset --soft HEAD~1 to move HEAD while preserving index
- Update UI confirmation copy to state that changes remain staged and
  files are preserved
2026-09-11 11:51:33 +02:00
Christoph 5c39fa3e30 feat(integrations): add issue creation and Azure work item support
Add creation flow for integration issues and Azure work items.
Expose Tauri commands to list Azure projects and types.
Also add a command to create integration issues and return the created item.
Preserve the target repository when provider responses omit it and return
the created issue information to the UI.

- Implement Azure-specific URL and payload handling and creation logic
- Add a Svelte CreateIssueDialog and integrate it into IssueCenter
- Add tests to validate creation requests and created-issue parsing
2026-09-11 09:53:59 +02:00
Christoph 088cbc5e18 refactor(clone-dialog): generalize repository grouping and support gitea
Generalize the repository grouping logic so grouping is driven by a single
derived flag and not tied to a single provider. Replace the Azure-specific
group variable with a generic repositoryGroups value and use a shared
usesRepositoryGroups switch so grouping can be enabled for other providers.

- Add usesRepositoryGroups derived flag to centralize grouping logic
- Replace azure-only grouping with repositoryGroups and enable Gitea grouping
2026-09-11 08:55:38 +02:00
Christoph d0dd79354a feat(integrations): add Azure work item state management and UI tweaks
Add support for listing and setting Azure DevOps work item states from the
integration layer. New helpers build and validate JSON-patch payloads and
expose two Tauri commands so the frontend can enumerate available states
and apply state changes. The Issue Center now loads Azure states and can
change an issue's state; dialog dismiss controls and related CSS were
consolidated and board UI feedback was simplified for clarity.

- Add Tauri commands to list and set Azure work item states.
- Load and present Azure states in Issue Center and allow state changes.
- Unify dialog dismiss attributes and refine hover/focus styling.
2026-09-11 08:43:06 +02:00
Christoph c42f8eb352 Merge branch 'main' of https://git.cbsk-tech.de/Christoph/GitLite 2026-09-09 10:30:39 +02:00
Christoph 136afa9921 feat(workspace): persist workspace UI filters and disable drag-drop
Add a lightweight workspace preferences helper to store optional UI
settings in localStorage and integrate it with the issues views so
filters, queries, and view mode are restored per source. The
integration board now remembers repository and query filters, and the
Issue Center restores state filters and view mode across sessions.
Also disable drag and drop for the main Tauri window to avoid accidental
file drops.

- Add read/write helpers for workspace preferences in localStorage
- Persist and restore filters, queries, and view mode for issue views
- Disable window drag-and-drop to prevent accidental file drops
2026-09-09 10:30:18 +02:00
Christoph 67283bf37f Update version to 2026.9.5 2026-09-09 09:26:13 +02:00
Christoph 95c72340fe feat(ui): add consistent page-header layout and styles
publish / Build and publish Ubuntu AppImage (release) Successful in 9m27s
publish / Build and publish Windows installer (release) Successful in 10m56s
publish / Build and publish AUR packages (release) Successful in 19m46s
Introduce a shared page-header/page-heading pattern and CSS to
standardize headers across top-level sections. Components were
updated to adopt the new classes and slightly smaller icon sizing
for a more compact, consistent header appearance. This improves
visual consistency and makes future header adjustments simpler.

- Add reusable page-header styles for spacing, borders, and bg
- Standardize icon sizing and heading layout across screens
2026-09-09 09:24:18 +02:00
Christoph abbd737a8d feat(ui): refine integration board view and compact assignees
Refactor the integration board UI to improve board discovery, loading,
and the card detail experience. Introduce a compact assignee mode and
apply it in list views to save horizontal space, and make several
accessibility and labeling improvements for board links and authors.

- Add a compact prop for assignee rendering and use it across lists.
- Improve board URL handling and add Azure DevOps board label parsing.
- Replace the old inspector with a focused detail drawer and sidebar.
2026-09-09 08:45:55 +02:00
Christoph bb53de3b83 Update version to 2026.9.4 2026-09-08 22:12:16 +02:00
27 changed files with 932 additions and 216 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "gitty", "name": "gitty",
"version": "2026.9.3", "version": "2026.9.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gitty", "name": "gitty",
"version": "2026.9.3", "version": "2026.9.5",
"dependencies": { "dependencies": {
"@lucide/svelte": "^1.21.0", "@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gitty", "name": "gitty",
"version": "2026.9.3", "version": "2026.9.5",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
const source = readFileSync(new URL('../src/lib/workspaces.ts', import.meta.url), 'utf8');
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText;
const { readWorkspaces, WORKSPACES_KEY } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
const storage = values => ({ getItem: key => values[key] ?? null });
test('migrates dashboard assignments without copying unrelated tabs into a workspace', () => {
const result = readWorkspaces(storage({ 'gitty.dashboard.v1': JSON.stringify({
workspaces: [{ id: 'workspace-a', name: 'A' }, { id: 'workspace-b', name: 'B' }],
assignments: { '/a': 'workspace-a', '/b': 'workspace-b', '/closed': 'workspace-a' },
}) }), ['/a', '/b', '/outside']);
assert.deepEqual(result.workspaces[0], { id: 'workspace-a', name: 'A', repositories: ['/a', '/closed'], openPaths: ['/a'], activePath: '/a' });
assert.deepEqual(result.workspaces[1].openPaths, ['/b']);
assert.deepEqual(result.defaultSession.openPaths, ['/a', '/b', '/outside']);
});
test('restores independent tab order and last active repository', () => {
const saved = { selectedId: 'workspace-a', workspaces: [
{ id: 'workspace-a', name: 'A', repositories: ['/a', '/b'], openPaths: ['/b', '/a'], activePath: '/a' },
{ id: 'workspace-b', name: 'B', repositories: ['/b'], openPaths: [], activePath: '' },
], defaultSession: { openPaths: ['/outside'], activePath: '/outside' } };
assert.deepEqual(readWorkspaces(storage({ [WORKSPACES_KEY]: JSON.stringify(saved) }), []), saved);
});
test('drops invalid memberships and stale active paths from saved sessions', () => {
const saved = { selectedId: 'deleted', workspaces: [null, { id: 'workspace-a', name: 'A',
repositories: ['/a', '/a', null], openPaths: ['/removed', '/a', 3], activePath: '/removed' }],
defaultSession: { openPaths: ['/outside'], activePath: '/removed' } };
const result = readWorkspaces(storage({ [WORKSPACES_KEY]: JSON.stringify(saved) }), []);
assert.equal(result.selectedId, '');
assert.deepEqual(result.workspaces[0].openPaths, ['/a']);
assert.equal(result.workspaces[0].activePath, '/a');
assert.deepEqual(result.workspaces[0].repositories, ['/a']);
assert.equal(result.defaultSession.activePath, '/outside');
});
test('unavailable or corrupt preferences preserve existing repository tabs', () => {
for (const source of [storage({ [WORKSPACES_KEY]: '{broken' }), { getItem() { throw new Error('Storage unavailable'); } }]) {
assert.deepEqual(readWorkspaces(source, ['/existing']).defaultSession.openPaths, ['/existing']);
}
});
+3 -4
View File
@@ -2615,10 +2615,9 @@ pub fn undo_last_commit(path: String) -> Result<GitStatus, String> {
return Err("This is the first commit; there is nothing to undo to.".to_string()); return Err("This is the first commit; there is nothing to undo to.".to_string());
} }
// Mixed reset: moves HEAD back one commit and unstages the difference, but // Soft reset: moves HEAD back one commit while preserving the index and
// leaves the working tree files untouched, so the undone commit's changes // working tree, so the undone commit's changes remain staged.
// reappear as ordinary uncommitted changes instead of being discarded. run_git(&repo, ["reset", "--soft", "HEAD~1"])?;
run_git(&repo, ["reset", "HEAD~1"])?;
status_for_repo(&repo) status_for_repo(&repo)
} }
+2
View File
@@ -1,3 +1,5 @@
mod issue_creation;
pub use issue_creation::*;
mod issue_actions; mod issue_actions;
pub use issue_actions::*; pub use issue_actions::*;
mod issue_comments; mod issue_comments;
+65 -15
View File
@@ -10,6 +10,56 @@ fn completed_state(value: &Value) -> Result<String,String> {
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());} 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()) Ok(states[0].into())
} }
fn azure_issue_context(client: &Client, base: &str, username: &str, token: &str, project: &str, number: u64) -> Result<(reqwest::Url, Value, Value), String> {
let mut url = comment_url("azure-devops", base, project, number)?;
url.path_segments_mut().map_err(|_| "Invalid Azure URL.")?.pop();
url.set_query(Some("api-version=7.1"));
let item = read(request(client, reqwest::Method::GET, url.clone(), "azure-devops", username, token)?.send().map_err(|e| e.to_string())?, "azure-devops")?;
let kind = value_string(&item, &["fields", "System.WorkItemType"]);
if kind.is_empty() { return Err("Azure returned no work item type.".into()); }
let mut states_url = reqwest::Url::parse(&normalized_base_url(base)?).map_err(|e| e.to_string())?;
states_url.path_segments_mut().map_err(|_| "Invalid Azure URL.")?.pop_if_empty().extend([project, "_apis", "wit", "workitemtypes", &kind, "states"]);
states_url.set_query(Some("api-version=7.1"));
let states = read(request(client, reqwest::Method::GET, states_url, "azure-devops", username, token)?.send().map_err(|e| e.to_string())?, "azure-devops")?;
Ok((url, item, states))
}
fn azure_state_patch(item: &Value, states: &Value, state: &str) -> Result<Value, String> {
if state.is_empty() || !states["value"].as_array().into_iter().flatten().any(|entry| entry["name"].as_str() == Some(state)) {
return Err("This state is not configured for the work item type.".into());
}
let rev = item["rev"].as_u64().ok_or("Azure returned no revision.")?;
Ok(json!([{"op":"test","path":"/rev","value":rev},{"op":"add","path":"/fields/System.State","value":state}]))
}
fn set_azure_state(client: &Client, url: reqwest::Url, item: &Value, states: &Value, username: &str, token: &str, state: &str) -> Result<String, String> {
let payload = azure_state_patch(item, states, state)?;
if item["fields"]["System.State"] == state { return Ok(state.into()); }
let result = read(request(client, reqwest::Method::PATCH, url, "azure-devops", username, token)?.header("Content-Type", "application/json-patch+json").json(&payload).send().map_err(|e| e.to_string())?, "azure-devops")?;
let actual = value_string(&result, &["fields", "System.State"]);
if actual != state { return Err("Azure did not confirm the requested state.".into()); }
Ok(actual)
}
#[tauri::command]
pub async fn list_azure_issue_states(base_url: String, username: String, token: String, repository: String, number: u64) -> Result<Vec<String>, String> {
tauri::async_runtime::spawn_blocking(move || {
let (_, _, states) = azure_issue_context(&comment_client()?, &base_url, &username, &token, &repository, number)?;
let names: Vec<String> = states["value"].as_array().into_iter().flatten().filter_map(|entry| entry["name"].as_str()).filter(|name| !name.is_empty()).map(str::to_owned).collect();
if names.is_empty() { return Err("Azure returned no work item states.".into()); }
Ok(names)
}).await.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn set_azure_issue_state(base_url: String, username: String, token: String, repository: String, number: u64, state: String) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || {
let client = comment_client()?;
let (url, item, states) = azure_issue_context(&client, &base_url, &username, &token, &repository, number)?;
set_azure_state(&client, url, &item, &states, &username, &token, &state)
}).await.map_err(|e| e.to_string())?
}
#[tauri::command] #[tauri::command]
pub async fn close_integration_issue(provider:String,base_url:String,username:String,token:String,repository:String,number:u64) -> Result<String,String> { 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 || { tauri::async_runtime::spawn_blocking(move || {
@@ -17,21 +67,9 @@ pub async fn close_integration_issue(provider:String,base_url:String,username:St
let mut url=comment_url(&provider,&base_url,&repository,number)?; let mut url=comment_url(&provider,&base_url,&repository,number)?;
url.path_segments_mut().map_err(|_|"Invalid issue URL.")?.pop(); url.path_segments_mut().map_err(|_|"Invalid issue URL.")?.pop();
if provider=="azure-devops" { if provider=="azure-devops" {
url.set_query(Some("api-version=7.1")); let (url, item, states) = azure_issue_context(&client, &base_url, &username, &token, &repository, number)?;
let item=read(request(&client,reqwest::Method::GET,url.clone(),&provider,&username,&token)?.send().map_err(|e|e.to_string())?,&provider)?; let state = completed_state(&states)?;
let kind=value_string(&item,&["fields","System.WorkItemType"]); set_azure_state(&client, url, &item, &states, &username, &token, &state)
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 { } else {
let gitlab=provider.starts_with("gitlab"); let gitlab=provider.starts_with("gitlab");
let method=if gitlab {reqwest::Method::PUT}else{reqwest::Method::PATCH}; let method=if gitlab {reqwest::Method::PUT}else{reqwest::Method::PATCH};
@@ -45,6 +83,18 @@ pub async fn close_integration_issue(provider:String,base_url:String,username:St
} }
#[cfg(test)] mod tests { #[cfg(test)] mod tests {
use super::*; use super::*;
#[test] fn state_changes_validate_custom_states_and_guard_revision() {
let item = json!({"rev":17,"fields":{"System.State":"New"}});
let states = json!({"value":[{"name":"Ready for QA"},{"name":"Active"}]});
let patch = azure_state_patch(&item, &states, "Ready for QA").unwrap();
assert_eq!(patch, json!([
{"op":"test","path":"/rev","value":17},
{"op":"add","path":"/fields/System.State","value":"Ready for QA"}
]));
assert!(azure_state_patch(&item, &states, "Closed").is_err());
assert!(azure_state_patch(&item, &states, "").is_err());
assert!(azure_state_patch(&json!({}), &states, "Active").is_err());
}
#[test] fn uses_custom_completed_state_and_rejects_ambiguous_configuration() { #[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_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":[]})).is_err());
@@ -0,0 +1,109 @@
use super::*;
use super::issue_comments::{comment_client, comment_url, request};
use serde_json::{json, Value};
fn azure_url(base: &str, segments: &[&str]) -> Result<reqwest::Url, String> {
let mut url = reqwest::Url::parse(&normalized_base_url(base)?).map_err(|e| e.to_string())?;
url.path_segments_mut().map_err(|_| "Invalid Azure URL.")?.pop_if_empty().extend(segments.iter().copied());
url.set_query(Some("api-version=7.1"));
Ok(url)
}
fn read(response: Response, provider: &str) -> Result<Value, String> {
if !response.status().is_success() { return Err(response_error(response, provider)); }
response.json().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn list_azure_issue_projects(base_url: String, username: String, token: String) -> Result<Vec<String>, String> {
tauri::async_runtime::spawn_blocking(move || {
let client = comment_client()?;
let mut projects = BTreeSet::new();
let mut cursor = String::new();
let mut seen = BTreeSet::new();
loop {
let mut url = azure_url(&base_url, &["_apis", "projects"])?;
url.query_pairs_mut().append_pair("$top", "100");
if !cursor.is_empty() { url.query_pairs_mut().append_pair("continuationToken", &cursor); }
let response = request(&client, reqwest::Method::GET, url, "azure-devops", &username, &token)?.send().map_err(|e| e.to_string())?;
let next = response.headers().get("x-ms-continuationtoken").and_then(|v| v.to_str().ok()).unwrap_or("").to_owned();
let value = read(response, "azure-devops")?;
let entries = value["value"].as_array().ok_or("Azure returned no project list.")?;
for entry in entries {
if let Some(name) = entry["name"].as_str().filter(|name| !name.is_empty()) { projects.insert(name.to_owned()); }
}
if next.is_empty() { break; }
if !seen.insert(next.clone()) { return Err("Azure repeated a project page.".into()); }
cursor = next;
}
Ok(projects.into_iter().collect())
}).await.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn list_azure_issue_types(base_url: String, username: String, token: String, project: String) -> Result<Vec<String>, String> {
tauri::async_runtime::spawn_blocking(move || {
if project.trim().is_empty() { return Err("Select an Azure project.".into()); }
let url = azure_url(&base_url, &[&project, "_apis", "wit", "workitemtypes"])?;
let value = read(request(&comment_client()?, reqwest::Method::GET, url, "azure-devops", &username, &token)?.send().map_err(|e| e.to_string())?, "azure-devops")?;
let entries = value["value"].as_array().ok_or("Azure returned no work item types.")?;
Ok(entries.iter().filter(|entry| entry["isDisabled"] != true).filter_map(|entry| entry["name"].as_str()).filter(|name| !name.is_empty()).map(str::to_owned).collect())
}).await.map_err(|e| e.to_string())?
}
fn creation_request(provider: &str, base: &str, repository: &str, title: &str, description: &str, work_item_type: &str) -> Result<(reqwest::Url, Value), String> {
if title.trim().is_empty() { return Err("An issue title is required.".into()); }
if repository.trim().is_empty() { return Err("Select a repository or project.".into()); }
if provider == "azure-devops" {
if work_item_type.trim().is_empty() { return Err("Select a work item type.".into()); }
let url = azure_url(base, &[repository, "_apis", "wit", "workitems", &format!("${work_item_type}")])?;
// Azure descriptions are HTML; preserve literal user text and line breaks.
let html = description.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\n', "<br>");
let mut payload = vec![json!({"op":"add","path":"/fields/System.Title","value":title.trim()})];
if !description.is_empty() { payload.push(json!({"op":"add","path":"/fields/System.Description","value":html})); }
Ok((url, json!(payload)))
} else {
let mut url = comment_url(provider, base, repository, 1)?;
url.path_segments_mut().map_err(|_| "Invalid issue URL.")?.pop().pop();
let payload = if provider.starts_with("gitlab") { json!({"title":title.trim(),"description":description}) } else { json!({"title":title.trim(),"body":description}) };
Ok((url, payload))
}
}
#[tauri::command]
pub async fn create_integration_issue(provider: String, base_url: String, username: String, token: String, repository: String, title: String, description: String, work_item_type: String) -> Result<IntegrationIssue, String> {
tauri::async_runtime::spawn_blocking(move || {
let (url, payload) = creation_request(&provider, &base_url, &repository, &title, &description, &work_item_type)?;
let client = comment_client()?;
let mut req = request(&client, reqwest::Method::POST, url, &provider, &username, &token)?;
if provider == "azure-devops" { req = req.header("Content-Type", "application/json-patch+json"); }
// Never retry a creation automatically: a lost response may still mean success.
let response = req.json(&payload).send().map_err(|e| format!("Creation was not confirmed. Check the provider before retrying: {e}"))?;
let value = read(response, &provider)?;
super::issues::created_issue(&value, &provider, &base_url, &repository)
}).await.map_err(|e| e.to_string())?
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn routes_issue_creation_and_preserves_markdown() {
for (provider, path) in [("gitea", "/sub/api/v1/repos/team/repo/issues"), ("github", "/repos/team/repo/issues"), ("gitlab", "/sub/api/v4/projects/team%2Frepo/issues"), ("gitlab-self-hosted", "/sub/api/v4/projects/team%2Frepo/issues")] {
let (url, body) = creation_request(provider, "https://git.test/sub", "team/repo", " Title ", "**Details**", "").unwrap();
assert_eq!(url.path(), path);
assert_eq!(body["title"], "Title");
assert_eq!(body[if provider.starts_with("gitlab") { "description" } else { "body" }], "**Details**");
}
}
#[test]
fn azure_uses_custom_type_and_escapes_description() {
let (url, body) = creation_request("azure-devops", "https://dev.azure.com/org", "My Project", "Task", "<script>&\nNext", "Custom Task").unwrap();
assert_eq!(url.path(), "/org/My%20Project/_apis/wit/workitems/$Custom%20Task");
assert_eq!(url.query(), Some("api-version=7.1"));
assert_eq!(body[1]["value"], "&lt;script&gt;&amp;<br>Next");
assert!(creation_request("gitea", "https://git.test", "team/repo", " ", "", "").is_err());
assert!(creation_request("azure-devops", "https://dev.azure.com/org", "Project", "Task", "", "").is_err());
assert!(creation_request("gitea", "https://git.test", "invalid", "Task", "", "").is_err());
}
}
+17
View File
@@ -87,6 +87,13 @@ fn parse_issue(value: &serde_json::Value, provider: &str) -> Option<IntegrationI
}) })
} }
pub(super) fn created_issue(value: &serde_json::Value, provider: &str, base: &str, repository: &str) -> Result<IntegrationIssue, String> {
let mut issue = if provider == "azure-devops" { azure_issue(value, base) } else { parse_issue(value, provider).ok_or("The provider returned no issue. Check the provider before retrying.")? };
if issue.number == 0 || value_u64(value, "id") == 0 { return Err("The provider returned no issue number. Check the provider before retrying.".into()); }
issue.repository_name = repository.to_owned();
Ok(issue)
}
fn json_response( fn json_response(
request: reqwest::blocking::RequestBuilder, request: reqwest::blocking::RequestBuilder,
provider: &str, provider: &str,
@@ -299,6 +306,16 @@ mod tests {
use super::*; use super::*;
use serde_json::json; use serde_json::json;
#[test] #[test]
fn created_issue_keeps_target_when_response_omits_repository() {
let issue = created_issue(&serde_json::json!({"id":71,"number":4,"title":"New","state":"open"}), "gitea", "https://git.test", "team/repo").unwrap();
assert_eq!(issue.id, "gitea:71");
assert_eq!(issue.repository_name, "team/repo");
let azure = created_issue(&serde_json::json!({"id":42,"fields":{"System.Title":"Task","System.State":"New"}}), "azure-devops", "https://dev.azure.com/org", "Project").unwrap();
assert_eq!(azure.repository_name, "Project");
assert_eq!(azure.number, 42);
assert!(created_issue(&serde_json::json!({"number":4}), "gitea", "https://git.test", "team/repo").is_err());
}
#[test]
fn excludes_pull_requests_and_maps_github_issues() { fn excludes_pull_requests_and_maps_github_issues() {
assert!(parse_issue(&json!({"number": 2, "pull_request": {}}), "github").is_none()); 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(); 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();
+7 -1
View File
@@ -36,7 +36,8 @@ use git::{
use integrations::{ use integrations::{
create_integration_review_request, list_integration_repository_branches, create_integration_review_request, list_integration_repository_branches,
add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser, add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser,
run_integration_review_action, list_integration_issues, get_integration_board, list_integration_boards, move_integration_board_card, list_integration_issue_comments, add_integration_issue_comment, close_integration_issue, create_integration_issue, list_azure_issue_projects, list_azure_issue_types,
run_integration_review_action, list_integration_issues, get_integration_board, list_integration_boards, move_integration_board_card, list_integration_issue_comments, add_integration_issue_comment, close_integration_issue, list_azure_issue_states, set_azure_issue_state,
}; };
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Mutex; use std::sync::Mutex;
@@ -448,7 +449,12 @@ async fn main() {
move_integration_board_card, move_integration_board_card,
list_integration_issue_comments, list_integration_issue_comments,
add_integration_issue_comment, add_integration_issue_comment,
create_integration_issue,
list_azure_issue_projects,
list_azure_issue_types,
close_integration_issue, close_integration_issue,
list_azure_issue_states,
set_azure_issue_state,
get_integration_review_details, get_integration_review_details,
add_integration_review_comment, add_integration_review_comment,
run_integration_review_action, run_integration_review_action,
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty", "productName": "Gitty",
"version": "2026.9.3", "version": "2026.9.5",
"identifier": "com.gitty", "identifier": "com.gitty",
"build": { "build": {
"beforeDevCommand": "npm run prepare:lfs && npm run dev", "beforeDevCommand": "npm run prepare:lfs && npm run dev",
@@ -13,6 +13,7 @@
"windows": [ "windows": [
{ {
"label": "main", "label": "main",
"dragDropEnabled": false,
"title": "Gitty", "title": "Gitty",
"width": 1200, "width": 1200,
"height": 800, "height": 800,
+89 -2
View File
@@ -14,6 +14,7 @@
import type { PullRequestBadgeState } from "./lib/pullRequestBadges"; import type { PullRequestBadgeState } from "./lib/pullRequestBadges";
let pullRequestBadges: Record<string, PullRequestBadgeState> = {}; let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
import IssueCenter from "./lib/components/IssueCenter.svelte"; import IssueCenter from "./lib/components/IssueCenter.svelte";
import { readWorkspaces, WORKSPACES_KEY, type Workspace, type WorkspaceState } from "./lib/workspaces";
import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte"; import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte";
import ReviewCenter from "./lib/components/ReviewCenter.svelte"; import ReviewCenter from "./lib/components/ReviewCenter.svelte";
import RepoTabs from "./lib/RepoTabs.svelte"; import RepoTabs from "./lib/RepoTabs.svelte";
@@ -331,13 +332,15 @@
let reviewConflictRemote = ""; let reviewConflictRemote = "";
let reviewConflictPushRequested = false; let reviewConflictPushRequested = false;
let repoTabs: RepoTab[] = []; let repoTabs: RepoTab[] = [];
let workspaceState: WorkspaceState = { selectedId: "", workspaces: [], defaultSession: { openPaths: [], activePath: "" } };
$: workspaceOptions = [{ value: "", label: appLanguage === "de" ? "Alle Repositories" : "All repositories" }, ...workspaceState.workspaces.map(item => ({ value: item.id, label: item.name }))];
let repoTabContextMenu: RepoTabContextMenu | null = null; let repoTabContextMenu: RepoTabContextMenu | null = null;
let recentRepoPaths: string[] = []; let recentRepoPaths: string[] = [];
let favoriteRepoPaths: string[] = []; let favoriteRepoPaths: string[] = [];
// Last-seen branch/ahead/behind/changed for repos that are known (recent/favorites) // Last-seen branch/ahead/behind/changed for repos that are known (recent/favorites)
// but not currently open as a tab — keyed by normalized path (repoKey). // but not currently open as a tab — keyed by normalized path (repoKey).
let repoStatusCache: Record<string, RepoTab> = {}; let repoStatusCache: Record<string, RepoTab> = {};
$: dashboardRepos = uniqueRepoPaths([...repoTabs.map(tab => tab.path), ...recentRepoPaths, ...favoriteRepoPaths]) $: dashboardRepos = uniqueRepoPaths([...repoTabs.map(tab => tab.path), ...recentRepoPaths, ...favoriteRepoPaths, ...workspaceState.workspaces.flatMap(item => item.repositories)])
.map(path => ({ ...repoRowFromPath(path, repoTabs, repoStatusCache), .map(path => ({ ...repoRowFromPath(path, repoTabs, repoStatusCache),
isOpen: repoTabs.some(tab => sameRepoPath(tab.path, path)), isOpen: repoTabs.some(tab => sameRepoPath(tab.path, path)),
known: Boolean(repoStatusCache[repoKey(path)]), known: Boolean(repoStatusCache[repoKey(path)]),
@@ -701,6 +704,9 @@
initAnalytics(); initAnalytics();
loadRepoLists(); loadRepoLists();
workspaceState = readWorkspaces(localStorage, repoTabs.map(tab => tab.path));
repoTabs = currentWorkspaceSession().openPaths.map(path => repoRowFromPath(path));
persistWorkspaces();
void checkForUpdates(); void checkForUpdates();
aiSettings = loadAiSettings(); aiSettings = loadAiSettings();
@@ -714,6 +720,7 @@
await closeStartupSplashscreen(); await closeStartupSplashscreen();
startupReady = true; startupReady = true;
await receiveStartupRepository(); await receiveStartupRepository();
if (!activeRepoPath && currentWorkspaceSession().activePath) await openRepo(currentWorkspaceSession().activePath);
startBackgroundTimers(); startBackgroundTimers();
// Remote access can take seconds (offline networks, SSH negotiation, // Remote access can take seconds (offline networks, SSH negotiation,
// credential helpers). It must never hold the startup screen hostage. // credential helpers). It must never hold the startup screen hostage.
@@ -1672,7 +1679,63 @@
} }
} }
function currentWorkspaceSession() {
return workspaceState.workspaces.find(item => item.id === workspaceState.selectedId) ?? workspaceState.defaultSession;
}
function persistWorkspaces() {
const session = currentWorkspaceSession();
session.openPaths = repoTabs.map(tab => tab.path);
session.activePath = session.openPaths.find(path => sameRepoPath(path, activeRepoPath))
?? session.openPaths.find(path => sameRepoPath(path, session.activePath)) ?? session.openPaths[0] ?? "";
workspaceState = { ...workspaceState };
try { localStorage.setItem(WORKSPACES_KEY, JSON.stringify(workspaceState)); } catch { /* Optional local preferences. */ }
}
async function switchWorkspace(id: string) {
if (isBusy || id === workspaceState.selectedId) return;
persistWorkspaces();
repoOpenRequestId += 1;
closeRepoTabContextMenu();
resetRepositoryState(true);
workspaceState = { ...workspaceState, selectedId: id };
const session = currentWorkspaceSession();
repoTabs = session.openPaths.map(path => repoRowFromPath(path));
activeView = "management";
persistRepoLists();
if (session.activePath) await openRepo(session.activePath);
}
async function saveWorkspace(id: string, name: string, repositories: string[]) {
if (isBusy) return;
persistWorkspaces();
const existing = workspaceState.workspaces.find(item => item.id === id);
const workspace: Workspace = existing
? { ...existing, name, repositories, openPaths: existing.openPaths.filter(path => repositories.some(repo => sameRepoPath(repo, path))) }
: { id: `workspace-${crypto.randomUUID()}`, name, repositories, openPaths: [], activePath: "" };
workspace.activePath = workspace.openPaths.includes(workspace.activePath) ? workspace.activePath : workspace.openPaths[0] ?? "";
workspaceState = { ...workspaceState, workspaces: [...workspaceState.workspaces.filter(item => item.id !== workspace.id), workspace] };
if (workspaceState.selectedId === workspace.id) {
repoTabs = repoTabs.filter(tab => repositories.some(path => sameRepoPath(path, tab.path)));
if (!repositories.some(path => sameRepoPath(path, activeRepoPath))) {
repoOpenRequestId += 1;
resetRepositoryState(true);
activeView = "management";
}
persistRepoLists();
} else await switchWorkspace(workspace.id);
}
async function deleteWorkspace() {
if (isBusy || !workspaceState.selectedId) return;
const id = workspaceState.selectedId;
await switchWorkspace("");
workspaceState = { ...workspaceState, workspaces: workspaceState.workspaces.filter(item => item.id !== id) };
persistWorkspaces();
}
function persistRepoLists() { function persistRepoLists() {
persistWorkspaces();
try { try {
localStorage.setItem(OPEN_REPOS_KEY, JSON.stringify(repoTabs.map((tab) => tab.path))); localStorage.setItem(OPEN_REPOS_KEY, JSON.stringify(repoTabs.map((tab) => tab.path)));
localStorage.setItem(RECENT_REPOS_KEY, JSON.stringify(recentRepoPaths)); localStorage.setItem(RECENT_REPOS_KEY, JSON.stringify(recentRepoPaths));
@@ -2073,6 +2136,11 @@
} }
function upsertRepoTab(path: string, nextStatus?: GitStatus | null) { function upsertRepoTab(path: string, nextStatus?: GitStatus | null) {
const workspace = workspaceState.workspaces.find(item => item.id === workspaceState.selectedId);
if (workspace && !workspace.repositories.some(repo => sameRepoPath(repo, path))) {
workspace.repositories = [...workspace.repositories, path];
workspaceState = { ...workspaceState };
}
const existing = repoTabs.find((tab) => sameRepoPath(tab.path, path)); const existing = repoTabs.find((tab) => sameRepoPath(tab.path, path));
const next: RepoTab = { const next: RepoTab = {
path, path,
@@ -2799,6 +2867,19 @@
return repoTabs.findIndex((tab) => sameRepoPath(tab.path, path)); return repoTabs.findIndex((tab) => sameRepoPath(tab.path, path));
} }
function reorderRepoTab(path: string, targetPath: string, after: boolean) {
if (isBusy || sameRepoPath(path, targetPath)) return;
const sourceIndex = repoTabIndex(path);
if (sourceIndex < 0 || repoTabIndex(targetPath) < 0) return;
const reordered = [...repoTabs];
const [moved] = reordered.splice(sourceIndex, 1);
const targetIndex = reordered.findIndex(tab => sameRepoPath(tab.path, targetPath));
reordered.splice(targetIndex + (after ? 1 : 0), 0, moved);
repoTabs = reordered;
closeRepoTabContextMenu();
persistRepoLists();
}
function closeRepoTabContextMenu() { function closeRepoTabContextMenu() {
repoTabContextMenu = null; repoTabContextMenu = null;
} }
@@ -4719,7 +4800,7 @@
async function undoLastCommitChange() { async function undoLastCommitChange() {
if (!activeRepoPath || !canAmend || isBusy) return; if (!activeRepoPath || !canAmend || isBusy) return;
const confirmed = window.confirm( const confirmed = window.confirm(
"Undo the last commit?\n\nIts changes come back as uncommitted changes in the working tree — nothing is discarded.", "Undo the last commit?\n\nIts changes remain staged, ready to commit again. Your working tree files are preserved.",
); );
if (!confirmed) return; if (!confirmed) return;
@@ -5392,6 +5473,9 @@
<RepoTabs <RepoTabs
{activeView} {activeView}
{workspaceOptions}
workspaceId={workspaceState.selectedId}
onWorkspaceChange={switchWorkspace}
{repoTabs} {repoTabs}
{isBusy} {isBusy}
language={appLanguage} language={appLanguage}
@@ -5405,6 +5489,7 @@
onOpenIssues={() => { activeView = "issues"; }} onOpenIssues={() => { activeView = "issues"; }}
isActive={(path) => activeView === "repository" && sameRepoPath(activeRepoPath, path)} isActive={(path) => activeView === "repository" && sameRepoPath(activeRepoPath, path)}
onSelect={selectRepoTab} onSelect={selectRepoTab}
onReorder={reorderRepoTab}
onClose={closeRepoTab} onClose={closeRepoTab}
onContextMenu={openRepoTabContextMenu} onContextMenu={openRepoTabContextMenu}
onAdd={chooseRepositoryFolder} onAdd={chooseRepositoryFolder}
@@ -5567,6 +5652,8 @@
<RepositoryDashboard <RepositoryDashboard
repos={dashboardRepos} language={appLanguage} {isBusy} repos={dashboardRepos} language={appLanguage} {isBusy}
{pullRequestBadges} {pullRequestBadges}
workspaces={workspaceState.workspaces} selectedWorkspace={workspaceState.selectedId}
onWorkspaceChange={switchWorkspace} onSaveWorkspace={saveWorkspace} onDeleteWorkspace={deleteWorkspace}
onOpen={selectRepoTab} onAdd={chooseRepositoryFolder} onClone={openCloneDialog} onOpen={selectRepoTab} onAdd={chooseRepositoryFolder} onClone={openCloneDialog}
onInit={initializeRepository} onInit={initializeRepository}
onFavorite={toggleFavoriteRepo} onClose={closeDashboardRepository} onFavorite={toggleFavoriteRepo} onClose={closeDashboardRepository}
+41 -15
View File
@@ -3368,10 +3368,9 @@
stroke: var(--color-sync-ahead); stroke: var(--color-sync-ahead);
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--color-sync-ahead) 24%, transparent)); filter: drop-shadow(0 0 3px color-mix(in srgb, var(--color-sync-ahead) 24%, transparent));
} }
/* Keep the lane color supplied by the graph; dashes indicate behind status. */
.graph-svg path.graph-segment-behind { .graph-svg path.graph-segment-behind {
stroke: var(--color-sync-behind);
stroke-dasharray: 4 4; stroke-dasharray: 4 4;
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--color-sync-behind) 20%, transparent));
} }
.graph-svg path.graph-ref-connector { .graph-svg path.graph-ref-connector {
opacity: 0.35; opacity: 0.35;
@@ -9031,30 +9030,24 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
color: #d7dae0; color: #d7dae0;
} }
/* Keep every dialog close action neutral until it is intentionally targeted. */ /* All dialog dismiss buttons share hover and keyboard-focus feedback. */
:root .dialog-close:hover:not(:disabled), :root :is(.dialog-close, .dialog-icon-button, .cred-close, .help-close, [data-dialog-close]):is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]) {
:root .dialog-close:focus-visible:not(:disabled),
:root .dialog-icon-button:hover:not(:disabled),
:root .dialog-icon-button:focus-visible:not(:disabled),
:root .cred-close:hover:not(:disabled),
:root .cred-close:focus-visible:not(:disabled) {
color: #ffffff; color: #ffffff;
border-color: #f0646d; border-color: #f0646d;
background: #d93641; background: #d93641;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
} }
:root[data-theme="light"] .dialog-close:hover:not(:disabled), :root[data-theme="light"] :is(.dialog-close, .dialog-icon-button, .cred-close, .help-close, [data-dialog-close]):is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]) {
:root[data-theme="light"] .dialog-close:focus-visible:not(:disabled),
:root[data-theme="light"] .dialog-icon-button:hover:not(:disabled),
:root[data-theme="light"] .dialog-icon-button:focus-visible:not(:disabled),
:root[data-theme="light"] .cred-close:hover:not(:disabled),
:root[data-theme="light"] .cred-close:focus-visible:not(:disabled) {
color: #ffffff; color: #ffffff;
border-color: #a9212b; border-color: #a9212b;
background: #c92f3a; background: #c92f3a;
} }
:root :is(.dialog-close, .dialog-icon-button, .cred-close, .help-close, [data-dialog-close]):is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]) svg {
color: inherit;
}
/* Keep the repository overview as a compact icon tab. */ /* Keep the repository overview as a compact icon tab. */
.repo-tab.management { .repo-tab.management {
flex: 0 0 38px; flex: 0 0 38px;
@@ -9069,3 +9062,36 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
--color-sync-ahead: #9a5200; --color-sync-ahead: #9a5200;
--color-sync-behind: #0755c8; --color-sync-behind: #0755c8;
} }
/* Consistent page headers for repositories, pull requests, and issues. */
section > header.page-header.page-header {
box-sizing: border-box;
display: flex;
flex-shrink: 0;
min-height: 54px;
height: auto;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 8px 24px;
border-bottom: 1px solid var(--color-border-subtle);
background: color-mix(in srgb, var(--app-bg) 94%, var(--color-surface));
}
.page-header .page-heading {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.page-header .page-heading > svg {
flex: 0 0 auto;
color: var(--color-accent);
}
.page-header.page-header .page-heading h1 {
margin: 0;
color: var(--color-ink);
font-size: 14px;
font-weight: 700;
line-height: 20px;
letter-spacing: normal;
}
+92 -5
View File
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Columns3, Database, Folder, House, GitPullRequest, Plus, X } from "@lucide/svelte"; import { Columns3, Database, Folder, House, GitPullRequest, Plus, X } from "@lucide/svelte";
import SelectMenu from "./components/SelectMenu.svelte";
interface RepositoryTabItem { interface RepositoryTabItem {
path: string; path: string;
name: string; name: string;
@@ -20,8 +22,71 @@
export let onClose: (path: string, event: MouseEvent) => void | Promise<void> = () => {}; export let onClose: (path: string, event: MouseEvent) => void | Promise<void> = () => {};
export let onContextMenu: (path: string, event: MouseEvent) => void = () => {}; export let onContextMenu: (path: string, event: MouseEvent) => void = () => {};
export let onAdd: () => void | Promise<void> = () => {}; export let onAdd: () => void | Promise<void> = () => {};
export let onReorder: (path: string, targetPath: string, after: boolean) => void = () => {};
export let workspaceId = "";
export let workspaceOptions: { value: string; label: string }[] = [];
export let onWorkspaceChange: (id: string) => unknown = () => {};
let navigation: HTMLElement;
let drag: { path: string; pointerId: number; startX: number; startScroll: number; source: number; target: number; width: number; centers: number[]; element: HTMLElement } | null = null;
let dragging = false;
let offset = 0;
let suppressClick = false;
function startDrag(event: PointerEvent, path: string) {
if (isBusy || event.button !== 0 || !event.isPrimary) return;
const element = event.currentTarget as HTMLElement;
const tabs = Array.from(navigation.querySelectorAll<HTMLElement>(".repository-tab"));
const source = repoTabs.findIndex(tab => tab.path === path);
drag = { path, pointerId: event.pointerId, startX: event.clientX,
startScroll: navigation.scrollLeft, source, target: source,
width: tabs[source].getBoundingClientRect().width + 4,
centers: tabs.map(tab => { const rect = tab.getBoundingClientRect(); return rect.left + rect.width / 2; }), element };
suppressClick = false;
element.setPointerCapture(event.pointerId);
}
function moveDrag(event: PointerEvent) {
if (!drag || event.pointerId !== drag.pointerId) return;
if (isBusy) { finishDrag(false); return; }
if (!dragging && Math.abs(event.clientX - drag.startX) < 5) return;
dragging = true;
suppressClick = true;
const bounds = navigation.getBoundingClientRect();
if (event.clientX < bounds.left + 35) navigation.scrollLeft -= 15;
if (event.clientX > bounds.right - 35) navigation.scrollLeft += 15;
offset = event.clientX - drag.startX + navigation.scrollLeft - drag.startScroll;
const center = drag.centers[drag.source] + offset;
let target = drag.source;
while (target < drag.centers.length - 1 && center > drag.centers[target + 1]) target++;
while (target > 0 && center < drag.centers[target - 1]) target--;
drag = { ...drag, target };
}
function finishDrag(commit: boolean) {
const current = drag;
if (!current) return;
drag = null;
if (current.element.hasPointerCapture(current.pointerId)) current.element.releasePointerCapture(current.pointerId);
if (commit && dragging && !isBusy && current.target !== current.source) {
onReorder(current.path, repoTabs[current.target].path, current.target > current.source);
}
dragging = false;
offset = 0;
}
function tabOffset(index: number, current: typeof drag, moving: boolean, displacement: number) {
if (!current || !moving) return 0;
if (index === current.source) return displacement;
if (current.source < index && index <= current.target) return -current.width;
if (current.target <= index && index < current.source) return current.width;
return 0;
}
</script> </script>
<svelte:window onpointermove={moveDrag} onpointerup={(event) => { if (event.pointerId === drag?.pointerId) finishDrag(true); }} onpointercancel={() => finishDrag(false)} onblur={() => finishDrag(false)} onkeydown={(event) => { if (event.key === "Escape") finishDrag(false); }}/>
<header class="workspace-navigation"> <header class="workspace-navigation">
<nav class="global-navigation" aria-label={language === "de" ? "Hauptnavigation" : "Main 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 === "management"} aria-current={activeView === "management" ? "page" : undefined} disabled={isBusy} onclick={onOpenManagement}><House size={17}/><span>Dashboard</span></button>
@@ -30,19 +95,31 @@
<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> <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> </nav>
{#if activeView === "repository"} {#if activeView === "repository"}
<nav class="repository-navigation" aria-label={language === "de" ? "Geöffnete Repositories" : "Open repositories"}> <div class="repository-row">
{#each repoTabs as repo (repo.path)} <nav bind:this={navigation} class="repository-navigation" class:reordering={dragging} aria-label={language === "de" ? "Geöffnete Repositories" : "Open repositories"}>
<div class="repository-tab" class:active={isActive(repo.path)} role="presentation" oncontextmenu={(event) => onContextMenu(repo.path, event)}> {#each repoTabs as repo, index (repo.path)}
<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> <div class="repository-tab" class:active={isActive(repo.path)}
class:dragging={dragging && drag?.path === repo.path}
style:transform={`translateX(${tabOffset(index, drag, dragging, offset)}px)`}
role="presentation" oncontextmenu={(event) => onContextMenu(repo.path, event)}>
<button class="repository-select" type="button" onpointerdown={(event) => startDrag(event, repo.path)}
onlostpointercapture={() => { if (drag) finishDrag(false); }}
onclick={(event) => { if (suppressClick) { event.preventDefault(); suppressClick = false; return; } onSelect(repo.path); }} disabled={isBusy} title={repo.path} aria-current={isActive(repo.path) ? "page" : undefined}><Folder size={16}/><span>{repo.name}</span></button>
<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> <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> </div>
{/each} {/each}
<button class="repository-add" type="button" onclick={onAdd} disabled={isBusy} title={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"} aria-label={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"}><Plus size={18}/></button> <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> </nav>
<div class="workspace-picker"><SelectMenu options={workspaceOptions} value={workspaceId} onChange={onWorkspaceChange} disabled={isBusy} ariaLabel={language === "de" ? "Workspace wechseln" : "Switch workspace"} /></div>
</div>
{/if} {/if}
</header> </header>
<style> <style>
.repository-row{display:flex;min-width:0;align-items:stretch;background:color-mix(in srgb,var(--color-surface) 45%,var(--app-bg))}
.workspace-picker{flex:0 0 180px;min-width:0;align-self:center;padding:3px 5px 3px 8px;border-left:1px solid var(--color-border-subtle)}
.workspace-picker :global(.select-menu-trigger){min-height:28px;height:28px;font-size:12px}
@media(max-width:600px){.workspace-picker{flex-basis:140px}}
.workspace-navigation{flex:0 0 auto;min-width:0;background:var(--app-bg);color:var(--color-ink-muted);font-family:var(--font-sans);border-bottom:1px solid var(--color-border-subtle)} .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)} .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{font:inherit;cursor:pointer;color:inherit;background:transparent;border:0;box-shadow:none}
@@ -53,10 +130,20 @@
.global-navigation button.active{color:var(--color-ink);font-weight:600} .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::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)} .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-navigation{flex:1;min-width:0;display:flex;align-items:stretch;gap:4px;min-height:34px;padding:4px 5px 0;overflow-x:auto;scrollbar-width:thin;background:color-mix(in srgb,var(--color-surface) 45%,var(--app-bg))}
.repository-tab{display:flex;flex:0 0 auto;align-items:center;min-width:100px;max-width:250px;border:1px solid var(--color-border);border-bottom:0;border-radius:5px 5px 0 0;background:var(--app-bg)} .repository-tab{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.active{background:var(--color-surface-raised);border-color:var(--color-border-input);color:var(--color-ink)}
.repository-tab:hover{background:var(--color-surface-hover)} .repository-tab:hover{background:var(--color-surface-hover)}
.repository-tab{position:relative;will-change:transform}
/* Animate the preview only. On drop, the new DOM order replaces the
transforms in the same render, so resetting them must not animate. */
.reordering .repository-tab{transition:transform 160ms ease}
.reordering .repository-tab.dragging{z-index:2;transition:none;background:var(--color-surface-raised);border-color:var(--color-accent);box-shadow:0 2px 12px #0005}
.repository-navigation.reordering,.reordering .repository-select{cursor:grabbing}
.repository-select{touch-action:pan-y;user-select:none}
@media(prefers-reduced-motion:reduce){.reordering .repository-tab{transition:none}}
.repository-select:not(:disabled){cursor:grab}
.reordering .repository-select:not(:disabled){cursor:grabbing}
.repository-select{display:flex;flex:1;min-width:0;align-items:center;gap:7px;min-height:29px;padding:0 9px;font-size:12px;text-align:left} .repository-select{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 span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.repository-select>:global(svg){flex-shrink:0} .repository-select>:global(svg){flex-shrink:0}
@@ -64,8 +64,9 @@
if (!query) return activeRepositories; if (!query) return activeRepositories;
return activeRepositories.filter((repository) => `${repository.fullName} ${repository.description}`.toLocaleLowerCase().includes(query)); return activeRepositories.filter((repository) => `${repository.fullName} ${repository.description}`.toLocaleLowerCase().includes(query));
}); });
const azureRepositoryGroups = $derived.by(() => { const usesRepositoryGroups = $derived(activeSource?.provider === "azure-devops" || activeSource?.provider === "gitea");
if (activeSource?.provider !== "azure-devops") return []; const repositoryGroups = $derived.by(() => {
if (!usesRepositoryGroups) return [];
const groups = new Map<string, GitIntegrationRepository[]>(); const groups = new Map<string, GitIntegrationRepository[]>();
for (const repository of filteredRepositories) { for (const repository of filteredRepositories) {
const separator = repository.fullName.indexOf("/"); const separator = repository.fullName.indexOf("/");
@@ -390,8 +391,8 @@
<div class="repository-state repository-state-error"><strong>{isGerman ? "Repositories konnten nicht geladen werden" : "Could not load repositories"}</strong><span>{repositoryError}</span></div> <div class="repository-state repository-state-error"><strong>{isGerman ? "Repositories konnten nicht geladen werden" : "Could not load repositories"}</strong><span>{repositoryError}</span></div>
{:else if filteredRepositories.length === 0} {:else if filteredRepositories.length === 0}
<div class="repository-state"><GitBranch size={20} /><span>{repositorySearch ? (isGerman ? "Keine passenden Repositories." : "No matching repositories.") : (isGerman ? "Keine Repositories gefunden." : "No repositories found.")}</span></div> <div class="repository-state"><GitBranch size={20} /><span>{repositorySearch ? (isGerman ? "Keine passenden Repositories." : "No matching repositories.") : (isGerman ? "Keine Repositories gefunden." : "No repositories found.")}</span></div>
{:else if activeSource?.provider === "azure-devops"} {:else if usesRepositoryGroups}
{#each azureRepositoryGroups as group (group.project)} {#each repositoryGroups as group (group.project)}
<section class="repository-project-group" aria-label={group.project}> <section class="repository-project-group" aria-label={group.project}>
<div class="repository-project-header"><span>{group.project}</span><em>{group.repositories.length}</em></div> <div class="repository-project-header"><span>{group.project}</span><em>{group.repositories.length}</em></div>
{#each group.repositories as repository (repository.id)} {#each group.repositories as repository (repository.id)}
+123
View File
@@ -0,0 +1,123 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { CirclePlus, X } from "@lucide/svelte";
import SelectMenu from "./SelectMenu.svelte";
import { createIntegrationIssue, listIntegrationRepositories, listAzureIssueProjects, listAzureIssueTypes } from "../git";
import { integrationCredentialKey } from "../integrations";
import type { GitIntegrationSource, IntegrationIssue, StoredCredential } from "../types";
let { source, de, initialRepository = "", loadCredential, onClose, onCreated }: {
source: GitIntegrationSource; de: boolean; initialRepository?: string;
loadCredential: (key: string) => Promise<StoredCredential | null>;
onClose: () => void; onCreated: (issue: IntegrationIssue) => void;
} = $props();
let dialog: HTMLDialogElement;
let titleInput: HTMLInputElement;
let targets = $state<{ value: string; label: string; group?: string }[]>([]);
let repository = $state("");
let types = $state<string[]>([]);
let workItemType = $state("");
let title = $state("");
let description = $state("");
let loading = $state(true);
let typesLoading = $state(false);
let busy = $state(false);
let loadError = $state("");
let typeError = $state("");
let error = $state("");
let destroyed = false;
let typeGeneration = 0;
const azure = $derived(source.provider === "azure-devops");
const canSubmit = $derived(!loading && !typesLoading && !busy && targets.some(item => item.value === repository) && !!title.trim() && (!azure || types.includes(workItemType)));
async function credential() {
const result = await loadCredential(integrationCredentialKey(source.provider, source.accountId));
if (!result?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
return result;
}
async function loadTargets() {
loading = true; loadError = "";
try {
let options: typeof targets;
if (azure) {
const auth = await credential();
const projects = await listAzureIssueProjects(source.baseUrl, auth.username, auth.password);
options = projects.map(value => ({ value, label: value }));
} else {
const repos = await listIntegrationRepositories(source.provider, source.baseUrl, source.accountId);
options = [...repos].sort((a,b) => a.fullName.localeCompare(b.fullName)).map(repo => ({value: repo.fullName, label: repo.name, group: repo.fullName.includes("/") ? repo.fullName.slice(0, repo.fullName.lastIndexOf("/")) : undefined}));
}
if (destroyed) return;
targets = options;
const preferred = options.some(item => item.value === initialRepository) ? initialRepository : options.length === 1 ? options[0].value : "";
await selectTarget(preferred);
} catch (cause) { if (!destroyed) loadError = String(cause); }
finally { if (!destroyed) loading = false; }
}
async function selectTarget(value: string) {
repository = value; types = []; workItemType = ""; typeError = "";
const generation = ++typeGeneration;
typesLoading = azure && !!value;
if (!typesLoading) return;
try {
const auth = await credential();
const result = await listAzureIssueTypes(source.baseUrl, auth.username, auth.password, value);
if (destroyed || generation !== typeGeneration) return;
types = result;
if (result.length === 1) workItemType = result[0];
} catch (cause) { if (!destroyed && generation === typeGeneration) typeError = String(cause); }
finally { if (!destroyed && generation === typeGeneration) typesLoading = false; }
}
async function submit(event: SubmitEvent) {
event.preventDefault();
if (!canSubmit) return;
busy = true; error = "";
try {
const auth = await credential();
const issue = await createIntegrationIssue(source.provider, source.baseUrl, auth.username, auth.password, repository, title.trim(), description, workItemType);
onCreated(issue);
} catch (cause) { if (!destroyed) error = String(cause); }
finally { if (!destroyed) busy = false; }
}
onMount(() => { dialog.showModal(); titleInput.focus(); void loadTargets(); });
onDestroy(() => { destroyed = true; typeGeneration++; });
</script>
<dialog bind:this={dialog} aria-labelledby="create-issue-title" oncancel={event => { event.preventDefault(); if (!busy) onClose(); }}>
<form onsubmit={submit}>
<header><CirclePlus size={20}/><div><h2 id="create-issue-title">{de ? "Neues Issue" : "New issue"}</h2><p>{source.label}</p></div><button class="dialog-close" type="button" aria-label={de ? "Schließen" : "Close"} disabled={busy} onclick={onClose}><X size={18}/></button></header>
<div class="body">
<div class="field"><span>{azure ? (de ? "Projekt" : "Project") : "Repository"}</span>
<SelectMenu value={repository} options={targets} showSelectedGroup searchable disabled={loading || busy} ariaLabel={azure ? (de ? "Projekt" : "Project") : "Repository"} placeholder={loading ? (de ? "Wird geladen …" : "Loading …") : (de ? "Bitte auswählen" : "Select an option")} searchPlaceholder={de ? "Suchen …" : "Search …"} onChange={value => void selectTarget(value)}/>
</div>
{#if loadError}<p class="error" role="alert">{loadError}</p><button type="button" disabled={loading || busy} onclick={loadTargets}>{de ? "Erneut laden" : "Retry"}</button>
{:else if !loading && !targets.length}<p>{de ? "Keine Repositories oder Projekte verfügbar." : "No repositories or projects available."}</p>{/if}
{#if azure}
<div class="field"><span>{de ? "Work-Item-Typ" : "Work item type"}</span><SelectMenu value={workItemType} options={types.map(value => ({value,label:value}))} disabled={!repository || typesLoading || busy} ariaLabel={de ? "Work-Item-Typ" : "Work item type"} placeholder={typesLoading ? (de ? "Wird geladen …" : "Loading …") : (de ? "Typ auswählen" : "Select type")} onChange={value => workItemType = value}/></div>
{#if typeError}<p class="error" role="alert">{typeError}</p><button type="button" disabled={typesLoading || busy} onclick={() => selectTarget(repository)}>{de ? "Typen erneut laden" : "Retry types"}</button>
{:else if repository && !typesLoading && !types.length}<p>{de ? "Keine Work-Item-Typen verfügbar." : "No work item types available."}</p>{/if}
{/if}
<label>{de ? "Titel" : "Title"}<input bind:this={titleInput} bind:value={title} disabled={busy} required placeholder={de ? "Was soll erledigt werden?" : "What needs to be done?"}/></label>
<label>{de ? "Beschreibung" : "Description"}<textarea bind:value={description} disabled={busy} rows="7" placeholder={de ? "Details zum Issue (optional)" : "Issue details (optional)"}></textarea></label>
{#if error}<p class="error" role="alert">{de ? "Issue konnte nicht bestätigt werden." : "Issue creation could not be confirmed."} {error}</p>{/if}
</div>
<footer><button type="button" disabled={busy} onclick={onClose}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit" disabled={!canSubmit}>{busy ? (de ? "Wird erstellt …" : "Creating …") : (de ? "Issue erstellen" : "Create issue")}</button></footer>
</form>
</dialog>
<style>
dialog {margin:auto;width:min(640px,calc(100vw - 32px));max-height:calc(100dvh - 40px);padding:0;border:1px solid var(--color-border);background:var(--app-dialog-bg);color:var(--color-ink);box-shadow:var(--app-dialog-shadow);font:inherit;overflow:auto}
dialog::backdrop {background:#0007}
header {display:flex;align-items:center;gap:12px;padding:18px 24px;background:var(--app-dialog-chrome);border-bottom:1px solid var(--color-border)}
header>div {flex:1} header>:global(svg) {color:var(--color-accent)}
h2 {font-size:17px;margin:0} p {margin:4px 0;color:var(--color-ink-dim);font-size:12px}
.body {display:grid;gap:18px;padding:22px 24px}.field,label {display:grid;gap:8px;min-width:0;font-size:12px}
button,input,textarea {font:inherit;color:var(--color-ink);border:1px solid var(--color-border);background:var(--color-surface);border-radius:0}
input,textarea {box-sizing:border-box;width:100%;padding:10px;font-size:13px}textarea {resize:vertical;line-height:1.5}
button {padding:8px 12px;cursor:pointer;font-size:12px}button:disabled {opacity:.5;cursor:default}
input:focus-visible,textarea:focus-visible,button:focus-visible {outline:2px solid var(--color-accent);outline-offset:2px}
.dialog-close {display:grid;place-items:center;padding:6px;border:0;background:transparent}
.error {color:var(--color-danger,#e0737b);overflow-wrap:anywhere;line-height:1.5}
footer {display:flex;justify-content:flex-end;gap:10px;padding:16px 24px;border-top:1px solid var(--color-border);background:var(--app-dialog-chrome)}
.primary {background:var(--color-accent);border-color:var(--color-accent);color:#fff}
</style>
+1 -1
View File
@@ -97,7 +97,7 @@
<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(); } }}> <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}> <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> <header><div class="heading-icon"><GitPullRequest size={19} /></div><div><h2 id="create-review-title">{heading}</h2><p>{source.label}</p></div><button data-dialog-close class="close" type="button" aria-label={de ? "Schließen" : "Close"} disabled={busy} onclick={onClose}><X size={18}/></button></header>
<div class="body"> <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} {#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> <div class="repository-field"><div class="field-heading"><span>Repository</span><small>{loading ? "…" : `${repositories.length} ${de ? "verfügbar" : "available"}`}</small></div>
+58 -47
View File
@@ -4,8 +4,9 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { Columns3, ExternalLink, Link2, RefreshCw, Search, X } from "@lucide/svelte"; import { Columns3, ExternalLink, RefreshCw, Search, X } from "@lucide/svelte";
import SelectMenu from "./SelectMenu.svelte"; import SelectMenu from "./SelectMenu.svelte";
import { readWorkspacePreferences, writeWorkspacePreferences } from "../workspacePreferences";
import IssueLabels from "./IssueLabels.svelte"; import IssueLabels from "./IssueLabels.svelte";
import IssueAssignees from "./IssueAssignees.svelte"; import IssueAssignees from "./IssueAssignees.svelte";
import { issueTone } from "../issuePresentation"; import { issueTone } from "../issuePresentation";
@@ -22,19 +23,17 @@
let discovering = false; let discovering = false;
let discoveryWarnings: string[] = []; let discoveryWarnings: string[] = [];
let destroyed = false; let destroyed = false;
let connectOpen = true;
let boardUrl = "";
let activeUrl = ""; let activeUrl = "";
let board: IntegrationBoard | null = null; let board: IntegrationBoard | null = null;
let loading = false; let loading = false;
let moving = false; let moving = false;
let dragged: { cardId: string; columnId: string } | null = null; let dragged: { cardId: string; columnId: string } | null = null;
let dropColumn = ""; let dropColumn = "";
let moveMessage = "";
$: canMove = board?.columns.some(column => !!column.moveTarget) ?? false; $: canMove = board?.columns.some(column => !!column.moveTarget) ?? false;
let error = ""; let error = "";
let query = ""; let query = "";
let repositoryFilter = ""; let repositoryFilter = "";
$: if (activeUrl) writeWorkspacePreferences(`${preferenceKey}:filters:${activeUrl}`, { repositoryFilter, query });
let selected: IntegrationBoardCard | null = null; let selected: IntegrationBoardCard | null = null;
let selectedColumnId = ""; let selectedColumnId = "";
let generation = 0; let generation = 0;
@@ -43,12 +42,8 @@
$: 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 }))]; $: 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) && $: 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)) })) ?? []; `${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(() => { onMount(() => {
let boardUrl = "";
try { boardUrl = localStorage.getItem(preferenceKey) ?? ""; } catch { /* Optional preference. */ } try { boardUrl = localStorage.getItem(preferenceKey) ?? ""; } catch { /* Optional preference. */ }
try { try {
const saved: unknown = JSON.parse(localStorage.getItem(directoryKey) ?? "[]"); const saved: unknown = JSON.parse(localStorage.getItem(directoryKey) ?? "[]");
@@ -56,7 +51,7 @@
} catch { /* Invalid optional preference. */ } } catch { /* Invalid optional preference. */ }
if (boardUrl && validBoardUrl(boardUrl)) rememberBoard({ title: boardUrl, webUrl: boardUrl, scope: "" }); if (boardUrl && validBoardUrl(boardUrl)) rememberBoard({ title: boardUrl, webUrl: boardUrl, scope: "" });
void discoverBoards(); void discoverBoards();
if (boardUrl) { connectOpen = false; void loadBoard(boardUrl); } if (boardUrl && validBoardUrl(boardUrl)) void loadBoard(boardUrl);
const timer = window.setInterval(() => { if (activeUrl && !loading && !moving) void loadBoard(activeUrl); }, 180_000); const timer = window.setInterval(() => { if (activeUrl && !loading && !moving) void loadBoard(activeUrl); }, 180_000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}); });
@@ -72,6 +67,26 @@
try { localStorage.setItem(directoryKey, JSON.stringify(savedBoards)); } catch { /* Optional preference. */ } try { localStorage.setItem(directoryKey, JSON.stringify(savedBoards)); } catch { /* Optional preference. */ }
} }
function boardLabel(item: IntegrationBoardReference): string {
if (source.provider === "azure-devops") {
try {
const path = new URL(item.webUrl).pathname.split("/").filter(Boolean).map(decodeURIComponent);
const index = path.indexOf("_boards");
if (index > 0 && path[index + 1] === "board" && path[index + 2] === "t") {
const project = path[index - 1];
const team = path[index + 3];
const name = path[index + 4];
if (team && name) {
// Use the stable URL names: loaded titles also contain project and team.
const defaultTeam = team === project || team === `${project} Team`;
return `${project}${defaultTeam ? "" : ` / ${team}`} · ${name}`;
}
}
} catch { /* Keep the original label for nonstandard board links. */ }
}
return `${item.scope ? `${item.scope} · ` : ""}${item.title}`;
}
function boardError(message: string) { 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("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."; 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.";
@@ -94,14 +109,16 @@
} finally { if (!destroyed) discovering = false; } } finally { if (!destroyed) discovering = false; }
} }
async function loadBoard(url: string, collapseEditor = false) { async function loadBoard(url: string) {
if (moving) return; if (moving) return;
const requestedUrl = url.trim(); const requestedUrl = url.trim();
if (!requestedUrl) return; if (!requestedUrl) return;
const requestGeneration = ++generation; const requestGeneration = ++generation;
if (requestedUrl !== activeUrl) { if (requestedUrl !== activeUrl) {
selected = null; selected = null;
repositoryFilter = ""; const saved = readWorkspacePreferences(`${preferenceKey}:filters:${requestedUrl}`);
repositoryFilter = saved.repositoryFilter ?? "";
query = saved.query ?? "";
board = boardCache.get(`${preferenceKey}:${requestedUrl}`) ?? null; board = boardCache.get(`${preferenceKey}:${requestedUrl}`) ?? null;
} }
activeUrl = requestedUrl; activeUrl = requestedUrl;
@@ -119,7 +136,6 @@
if (requestGeneration !== generation) return; if (requestGeneration !== generation) return;
board = result; board = result;
rememberBoard({ title: result.title, webUrl: requestedUrl, scope: savedBoards.find(item => item.webUrl === requestedUrl)?.scope ?? "" }); 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); boardCache.set(`${preferenceKey}:${requestedUrl}`, result);
selected = selected ? result.columns.flatMap(column => column.cards).find(card => card.id === selected!.id) ?? null : null; 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. */ } try { localStorage.setItem(preferenceKey, requestedUrl); } catch { /* Keep the board in memory. */ }
@@ -152,7 +168,6 @@
dragged = null; dragged = null;
dropColumn = ""; dropColumn = "";
error = ""; error = "";
moveMessage = de ? "Verschiebung wird gespeichert …" : "Saving move …";
try { try {
const credential = await timeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000); 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."); if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
@@ -161,17 +176,14 @@
boardCache.delete(`${preferenceKey}:${url}`); boardCache.delete(`${preferenceKey}:${url}`);
if (destroyed || generation !== requestGeneration) return; if (destroyed || generation !== requestGeneration) return;
boardCache.set(`${preferenceKey}:${url}`, board!); boardCache.set(`${preferenceKey}:${url}`, board!);
moveMessage = de ? "Verschoben. Board wird aktualisiert …" : "Moved. Refreshing board …";
moving = false; moving = false;
if (selected?.id === cardId) selectedColumnId = to; if (selected?.id === cardId) selectedColumnId = to;
await loadBoard(url); 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) { } catch (cause) {
if (!destroyed && generation === requestGeneration) { if (!destroyed && generation === requestGeneration) {
board = previousBoard; board = previousBoard;
if (selected?.id === cardId) selectedColumnId = previousSelectedColumn; 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)}`; 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; } } finally { if (!destroyed) moving = false; }
} }
@@ -188,49 +200,31 @@
async function openOriginal(url: string) { async function openOriginal(url: string) {
try { await openInBrowser(url); } catch (cause) { error = String(cause); } 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> </script>
<svelte:window onkeydown={event => { if (event.key === "Escape") selected = null; }} /> <svelte:window onkeydown={event => { if (event.key === "Escape") selected = null; }} />
<section class="board-view" aria-label={de ? "Kanban-Board" : "Kanban board"}> <section class="board-view" aria-label={de ? "Kanban-Board" : "Kanban board"}>
<div class="board-directory"> <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); } }} /> <SelectMenu class="board-select" value={activeUrl} options={[{ value: "", label: de ? "Board auswählen" : "Select board" }, ...savedBoards.map(item => ({ value: item.webUrl, label: boardLabel(item) }))]} ariaLabel={de ? "Board auswählen" : "Select board"} onChange={value => { if (value && !moving) { void loadBoard(value); } }} />
<button class="workspace-button" disabled={discovering} onclick={discoverBoards}><RefreshCw size={14} />{de ? "Boards suchen" : "Find boards"}</button> <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> <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} {#if !board && activeUrl && validBoardUrl(activeUrl)}<button class="workspace-button" onclick={() => openOriginal(activeUrl)}><ExternalLink size={14} />{de ? "Original öffnen" : "Open original"}</button>{/if}
</div> </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 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} {#if board}
<div class="board-header"> <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-title"><h2>{boardLabel({ title: board.title, webUrl: board.webUrl, scope: "" })}</h2></div>
<div class="board-tools"> <div class="board-tools">
<SelectMenu class="repository-select" value={repositoryFilter} options={repositoryOptions} ariaLabel={de ? "Repository filtern" : "Filter repository"} onChange={value => { repositoryFilter = value; selected = null; }} /> <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> <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 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> <button class="workspace-button" onclick={() => openOriginal(board!.webUrl)}><ExternalLink size={14} />{de ? "Original öffnen" : "Open original"}</button>
</div> </div>
</div> </div>
{/if} {/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 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}
{#if board.notice}<p class="board-notice">{notice(board.notice)}</p>{/if}
<div class="board-body"> <div class="board-body">
<div class="board-columns" aria-label={de ? "Board-Spalten" : "Board columns"}> <div class="board-columns" aria-label={de ? "Board-Spalten" : "Board columns"}>
{#each columns as column (column.id)} {#each columns as column (column.id)}
@@ -248,7 +242,7 @@
<strong>{card.title}</strong> <strong>{card.title}</strong>
{#if card.lane}<span class="lane">{card.lane}</span>{/if} {#if card.lane}<span class="lane">{card.lane}</span>{/if}
<IssueLabels labels={card.labels} /> <IssueLabels labels={card.labels} />
{#if card.assignees.length}<IssueAssignees names={card.assignees} />{/if} {#if card.assignees.length}<IssueAssignees names={card.assignees} compact />{/if}
</button> </button>
{:else}<p class="column-empty">{de ? "Keine Karten" : "No cards"}</p>{/each} {:else}<p class="column-empty">{de ? "Keine Karten" : "No cards"}</p>{/each}
</div> </div>
@@ -256,24 +250,41 @@
{:else}<p class="column-empty">{de ? "Dieses Board hat keine Spalten." : "This board has no columns."}</p>{/each} {:else}<p class="column-empty">{de ? "Dieses Board hat keine Spalten." : "This board has no columns."}</p>{/each}
</div> </div>
{#if selected} {#if selected}
<aside class="issue-inspector board-inspector" aria-label={de ? "Kartendetails" : "Card details"}> {@const selectedColumn = board.columns.find(column => column.id === selectedColumnId)}
<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> <aside class="issue-detail-drawer" aria-label={de ? "Kartendetails" : "Card details"}>
<h2>{selected.title}</h2> <header class="issue-detail-header">
<div><Columns3 size={17} /><strong>{source.label} · {de ? "Karte" : "Card"}</strong></div>
<div class="issue-detail-header-actions">
{#if selected.webUrl}<button class="workspace-button close-button" onclick={() => openOriginal(selected!.webUrl)} aria-label={de ? "Im Anbieter öffnen" : "Open in provider"}><ExternalLink size={16} /></button>{/if}
<button data-dialog-close class="workspace-button close-button" onclick={() => { selected = null; }} aria-label={de ? "Kartendetails schließen" : "Close card details"}><X size={17} /></button>
</div>
</header>
<div class="issue-detail-body">
<div class="issue-detail-main">
<div class="issue-detail-title"><div>{#if selected.number}<span>#{selected.number}</span>{/if}<h2>{selected.title}</h2></div>
{#if selectedColumn}<div class="issue-detail-summary"><span class="issue-status" data-tone={issueTone(selectedColumn.title)}><span class="status-dot"></span>{selectedColumn.title}</span></div>{/if}
</div>
<section class="issue-detail-description"><h3>{de ? "Beschreibung" : "Description"}</h3><div class="description">{selected.description || (de ? "Weitere Details im Original-Board." : "More details in the original board.")}</div></section>
</div>
<div class="issue-detail-sidebar">
{#if selected.webUrl}<button class="workspace-button inspector-open" onclick={() => openOriginal(selected!.webUrl)}><ExternalLink size={14} />{de ? "Im Browser öffnen" : "Open in browser"}</button>{/if}
{#if canMove} {#if canMove}
{@const currentColumn = board.columns.find(column => column.id === selectedColumnId && column.cards.some(card => card.id === selected?.id))} {@const currentColumn = board.columns.find(column => column.id === selectedColumnId && column.cards.some(card => card.id === selected?.id))}
{#if currentColumn?.moveTarget} {#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> <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}
{/if} {/if}
<div class="inspector-properties"><IssueAssignees names={selected.assignees} />{#if selected.lane}<span class="lane">{selected.lane}</span>{/if}</div> <section><h3>{de ? "Zugewiesen" : "Assignees"}</h3><IssueAssignees names={selected.assignees} /></section>
{#if selected.labels.length}<div class="inspector-labels"><small>Labels</small><IssueLabels labels={selected.labels} /></div>{/if} <section><h3>Labels</h3>{#if selected.labels.length}<IssueLabels labels={selected.labels} />{:else}<small>{de ? "Keine Labels" : "No labels"}</small>{/if}</section>
<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.repository}<section><h3>Repository</h3><strong class="issue-detail-repo">{selected.repository}</strong></section>{/if}
{#if selected.webUrl}<button class="workspace-button inspector-open" onclick={() => openOriginal(selected!.webUrl)}><ExternalLink size={14} />{de ? "Karte öffnen" : "Open card"}</button>{/if} {#if selected.lane}<section><h3>Swimlane</h3><span class="lane">{selected.lane}</span></section>{/if}
</div>
</div>
</aside> </aside>
{/if} {/if}
</div> </div>
{:else} {: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> <div class="workspace-empty"><span class="empty-symbol"><Columns3 size={26} /></span><h2>{loading ? (de ? "Board wird im Hintergrund geladen" : "Loading board in background") : (de ? "Dein vorhandenes Board übernehmen" : "Load your existing board")}</h2><p>{de ? "Boards werden automatisch aus der Integration gesucht. Wähle oben ein Board aus, um seine Spalten und Karten zu laden." : "Boards are discovered automatically from the integration. Select a board above to load its columns and cards."}</p>
{#if source.provider === "gitea"}<p>{de ? "Gitea benötigt eine Serverversion mit Projects-/Columns-API." : "Gitea requires a server version with the Projects/Columns API."}</p>{/if} {#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> </div>
{/if} {/if}
+4 -2
View File
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
export let names: string[] = []; export let names: string[] = [];
export let emptyLabel = "—"; export let emptyLabel = "—";
export let compact = false;
$: initials = names[0]?.trim().split(/[\s._-]+/).filter(Boolean).slice(0, 2).map(part => part[0]).join("").toLocaleUpperCase() ?? ""; $: initials = names[0]?.trim().split(/[\s._-]+/).filter(Boolean).slice(0, 2).map(part => part[0]).join("").toLocaleUpperCase() ?? "";
$: avatarText = compact && initials.length === 1 ? names[0]?.trim().slice(0, 2).toLocaleUpperCase() : initials;
</script> </script>
<span class="issue-person" title={names.join(", ")}> <span class="issue-person" title={names.join(", ")} aria-label={names.length ? names.join(", ") : undefined}>
{#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} {#if names.length}<span class="issue-avatar" aria-hidden="true">{avatarText}</span>{#if !compact}<span class="person-name">{names[0]}{names.length > 1 ? ` +${names.length - 1}` : ""}</span>{/if}{:else}<span class="unassigned">{emptyLabel}</span>{/if}
</span> </span>
+104 -16
View File
@@ -6,23 +6,28 @@
import { onMount, onDestroy, tick } from "svelte"; import { onMount, onDestroy, tick } from "svelte";
import { fly } from "svelte/transition"; import { fly } from "svelte/transition";
import { cubicOut } from "svelte/easing"; import { cubicOut } from "svelte/easing";
import { ChevronRight, CircleDot, FolderGit2, ExternalLink, RefreshCw, Search, Settings2, X, XCircle } from "@lucide/svelte"; import { ChevronRight, CircleDot, Plus, FolderGit2, ExternalLink, RefreshCw, Search, Settings2, X, XCircle } from "@lucide/svelte";
import "../issueWorkspace.css"; import "../issueWorkspace.css";
import CreateIssueDialog from "./CreateIssueDialog.svelte";
import IssueComments from "./IssueComments.svelte"; import IssueComments from "./IssueComments.svelte";
import IssueLabels from "./IssueLabels.svelte"; import IssueLabels from "./IssueLabels.svelte";
import IssueAssignees from "./IssueAssignees.svelte"; import IssueAssignees from "./IssueAssignees.svelte";
import { issueTone, issueStateLabel } from "../issuePresentation"; import { issueTone, issueStateLabel } from "../issuePresentation";
import IntegrationBoardView from "./IntegrationBoardView.svelte"; import IntegrationBoardView from "./IntegrationBoardView.svelte";
import SelectMenu from "./SelectMenu.svelte"; import SelectMenu from "./SelectMenu.svelte";
import { listIntegrationIssues, closeIntegrationIssue, openInBrowser } from "../git"; import { readWorkspacePreferences, writeWorkspacePreferences } from "../workspacePreferences";
import { listIntegrationIssues, closeIntegrationIssue, listAzureIssueStates, setAzureIssueState, openInBrowser } from "../git";
import { configuredIntegrationSources, integrationCredentialKey } from "../integrations"; import { configuredIntegrationSources, integrationCredentialKey } from "../integrations";
import type { GitIntegrationSettings, IntegrationIssue, StoredCredential } from "../types"; import type { GitIntegrationSettings, GitIntegrationSource, IntegrationIssue, StoredCredential } from "../types";
export let language: "de" | "en" = "en"; export let language: "de" | "en" = "en";
export let integrations: GitIntegrationSettings; export let integrations: GitIntegrationSettings;
export let loadCredential: (key: string) => Promise<StoredCredential | null>; export let loadCredential: (key: string) => Promise<StoredCredential | null>;
export let onOpenSettings: () => void; export let onOpenSettings: () => void;
let createSource: GitIntegrationSource | null = null;
let createKey = "";
let createRepository = "";
let viewMode: "list" | "board" = "list"; let viewMode: "list" | "board" = "list";
let sourceId = ""; let sourceId = "";
let issues: IntegrationIssue[] = []; let issues: IntegrationIssue[] = [];
@@ -39,11 +44,18 @@
let closingId = ""; let closingId = "";
let closingKey = ""; let closingKey = "";
let actionError = ""; let actionError = "";
let azureStates: string[] = [];
let statesLoading = false;
let statesError = "";
let statesGeneration = 0;
let lastStateKey = "";
let targetState = "";
let closedStates = new Map<string, string>(); let closedStates = new Map<string, string>();
let error = ""; let error = "";
let mounted = false; let mounted = false;
let generation = 0; let generation = 0;
let lastKey = ""; let lastKey = "";
const preferenceKey = "gitty.issues.v1";
$: de = language === "de"; $: de = language === "de";
$: sources = configuredIntegrationSources(integrations); $: sources = configuredIntegrationSources(integrations);
$: source = sources.find(item => item.id === sourceId) ?? sources[0]; $: source = sources.find(item => item.id === sourceId) ?? sources[0];
@@ -56,11 +68,14 @@
issues = saved?.issues ?? []; issues = saved?.issues ?? [];
nextCursor = saved?.nextCursor ?? null; nextCursor = saved?.nextCursor ?? null;
selectedId = ""; selectedId = "";
stateFilter = ""; restoreFilters(sourceKey);
repositoryFilter = "";
error = ""; error = "";
if (source) void loadIssues(); if (source) void loadIssues();
} }
$: if (mounted && sourceKey && sourceKey === lastKey) {
writeWorkspacePreferences(preferenceKey, { sourceId: source?.id ?? "", viewMode });
writeWorkspacePreferences(`${preferenceKey}:${sourceKey}`, { stateFilter, repositoryFilter, query });
}
$: normalizedQuery = query.trim().toLocaleLowerCase(); $: normalizedQuery = query.trim().toLocaleLowerCase();
$: filtered = issues.filter(issue => (!repositoryFilter || issue.repositoryName === repositoryFilter) && (!stateFilter || issue.state === stateFilter) && $: 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)); `${issue.title} ${issue.number} ${issue.repositoryName} ${issue.author} ${issue.labels.join(" ")} ${issue.assignees.join(" ")}`.toLocaleLowerCase().includes(normalizedQuery));
@@ -69,8 +84,22 @@
$: stateOptions = [{ value: "", label: de ? "Alle Status" : "All states" }, ...[...new Set(issues.map(issue => issue.state))].sort().map(value => ({ value, label: value }))]; $: 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); $: selected = issues.find(issue => issue.id === selectedId);
$: description = selected ? plainDescription(selected) : ""; $: description = selected ? plainDescription(selected) : "";
$: stateKey = source?.provider === "azure-devops" && selected ? JSON.stringify([sourceKey, selected.id]) : "";
$: if (stateKey !== lastStateKey) {
lastStateKey = stateKey;
statesGeneration++;
azureStates = [];
statesError = "";
statesLoading = false;
actionError = "";
targetState = "";
if (stateKey) void loadAzureStates();
}
onMount(() => { onMount(() => {
const saved = readWorkspacePreferences(preferenceKey);
sourceId = saved.sourceId ?? "";
viewMode = saved.viewMode === "board" ? "board" : "list";
mounted = true; mounted = true;
reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const closeDetailsOutside = (event: PointerEvent) => { const closeDetailsOutside = (event: PointerEvent) => {
@@ -85,7 +114,14 @@
window.removeEventListener("pointerdown", closeDetailsOutside, true); window.removeEventListener("pointerdown", closeDetailsOutside, true);
}; };
}); });
onDestroy(() => { generation++; }); onDestroy(() => { generation++; statesGeneration++; });
function restoreFilters(key: string) {
const saved = readWorkspacePreferences(`${preferenceKey}:${key}`);
stateFilter = saved.stateFilter ?? "";
repositoryFilter = saved.repositoryFilter ?? "";
query = saved.query ?? "";
}
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> { async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout>; let timer: ReturnType<typeof setTimeout>;
@@ -142,7 +178,26 @@
return document.body.textContent?.trim() ?? ""; return document.body.textContent?.trim() ?? "";
} }
async function closeIssue() { async function loadAzureStates() {
if (!selected || source?.provider !== "azure-devops") return;
const current = source;
const issue = selected;
const requestGeneration = ++statesGeneration;
statesLoading = true;
statesError = "";
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(current.provider, current.accountId)), 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
const states = await withTimeout(listAzureIssueStates(current.baseUrl, credential.username, credential.password, issue.repositoryName, issue.number), 70_000);
if (requestGeneration !== statesGeneration) return;
azureStates = states;
targetState = issue.state;
} catch (cause) {
if (requestGeneration === statesGeneration) statesError = `${de ? "Status konnten nicht geladen werden." : "Could not load states."} ${String(cause)}`;
} finally { if (requestGeneration === statesGeneration) statesLoading = false; }
}
async function changeIssueState(nextState?: string) {
if (!selected || !source || closingId || loading) return; if (!selected || !source || closingId || loading) return;
const issue = selected; const issue = selected;
const current = source; const current = source;
@@ -153,16 +208,37 @@
try { try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(current.provider, current.accountId)), 15_000); 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."); 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 state = nextState && current.provider === "azure-devops"
? await setAzureIssueState(current.baseUrl, credential.username, credential.password, issue.repositoryName, issue.number, nextState)
: await closeIntegrationIssue(current.provider, current.baseUrl, credential.username, credential.password, issue.repositoryName, issue.number);
const saved = cache.get(key); const saved = cache.get(key);
if (saved) cache.set(key, { ...saved, issues: saved.issues.map(item => item.id === issue.id ? { ...item, state } : item) }); 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 (!nextState) closedStates = new Map(closedStates).set(`${key}:${issue.id}`, state);
if (sourceKey === key) issues = issues.map(item => item.id === issue.id ? { ...item, state } : item); if (sourceKey === key) issues = issues.map(item => item.id === issue.id ? { ...item, state } : item);
} catch (cause) { } 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)}`; if (sourceKey === key && selectedId === issue.id) actionError = `${de ? "Status konnte nicht geändert werden. Bitte Schreibrechte und Übergangsregeln prüfen." : "Could not change state. Check write permissions and transition rules."} ${String(cause)}`;
} finally { closingId = ""; } } finally { closingId = ""; }
} }
function issueCreated(issue: IntegrationIssue) {
const key = createKey;
const saved = cache.get(key);
const next = [issue, ...(saved?.issues ?? []).filter(item => item.id !== issue.id)];
cache.set(key, { issues: next, nextCursor: saved?.nextCursor ?? null });
createSource = null;
if (sourceKey !== key) return;
generation++; // Ignore any list response started before creation.
loading = false;
issues = next;
viewMode = "list";
repositoryFilter = ""; stateFilter = ""; query = "";
const collapsed = new Set(collapsedRepositories);
collapsed.delete(JSON.stringify([key, issue.repositoryName]));
collapsedRepositories = collapsed;
selectedId = issue.id;
error = ""; actionError = "";
}
async function openIssue(url: string) { async function openIssue(url: string) {
try { await openInBrowser(url); } try { await openInBrowser(url); }
catch (cause) { error = String(cause); } catch (cause) { error = String(cause); }
@@ -172,8 +248,8 @@
<svelte:window onkeydown={event => { if (event.key === "Escape" && selectedId) void closeDetails(); }} /> <svelte:window onkeydown={event => { if (event.key === "Escape" && selectedId) void closeDetails(); }} />
<section class="issue-center" aria-label="Issues"> <section class="issue-center" aria-label="Issues">
<header class="workspace-heading"> <header class="workspace-heading page-header">
<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> <div class="page-heading"><CircleDot size={17} /><h1>Issues</h1></div>
<button class="workspace-button" onclick={onOpenSettings} title={de ? "Integrationen konfigurieren" : "Configure integrations"}><Settings2 size={14} />{de ? "Integrationen" : "Integrations"}</button> <button class="workspace-button" onclick={onOpenSettings} title={de ? "Integrationen konfigurieren" : "Configure integrations"}><Settings2 size={14} />{de ? "Integrationen" : "Integrations"}</button>
</header> </header>
{#if !sources.length} {#if !sources.length}
@@ -196,6 +272,7 @@
<SelectMenu class="repository-select" value={repositoryFilter} options={repositoryOptions} ariaLabel={de ? "Repository filtern" : "Filter repository"} onChange={value => { repositoryFilter = value; selectedId = ""; }} /> <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; }} /> <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> <button class="workspace-button icon-button" disabled={loading} onclick={() => loadIssues()} title={de ? "Aktualisieren" : "Refresh"} aria-label={de ? "Aktualisieren" : "Refresh"}><RefreshCw size={15} /></button>
{#if source}<button class="workspace-button issue-create-button" onclick={() => { createKey = sourceKey; createRepository = repositoryFilter; createSource = source; }}><Plus size={14}/>{de ? "Neues Issue" : "New issue"}</button>{/if}
</div> </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} {#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"> <div class="issue-list">
@@ -211,7 +288,7 @@
<button class="issue-row" class:selected={selectedId === issue.id} aria-pressed={selectedId === issue.id} onclick={event => { detailTrigger = event.currentTarget; selectedId = issue.id; actionError = ""; }}> <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-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> <span class="issue-status" data-tone={issueTone(issue.state)}><span class="status-dot"></span>{issueStateLabel(issue.state, de)}</span>
<IssueAssignees names={issue.assignees} /> <IssueAssignees names={issue.assignees} compact />
</button> </button>
{/each} {/each}
{/if} {/if}
@@ -229,7 +306,7 @@
<div><CircleDot size={17} /><strong>{source?.label} · Issue</strong></div> <div><CircleDot size={17} /><strong>{source?.label} · Issue</strong></div>
<div class="issue-detail-header-actions"> <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} {#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> <button data-dialog-close class="workspace-button close-button" onclick={closeDetails} aria-label={de ? "Details schließen" : "Close details"}><X size={17} /></button>
</div> </div>
</header> </header>
<div class="issue-detail-body"> <div class="issue-detail-body">
@@ -241,8 +318,15 @@
{#if source}{#key `${sourceKey}:${selected.id}`}<IssueComments {source} issue={selected} {language} {loadCredential} />{/key}{/if} {#if source}{#key `${sourceKey}:${selected.id}`}<IssueComments {source} issue={selected} {language} {loadCredential} />{/key}{/if}
</div> </div>
<div class="issue-detail-sidebar"> <div class="issue-detail-sidebar">
{#if selected.state !== "closed" && closedStates.get(`${sourceKey}:${selected.id}`) !== selected.state} {#if source?.provider === "azure-devops"}
<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> <section>
<h3>{de ? "Status ändern" : "Change state"}</h3>
<SelectMenu value={targetState} options={azureStates.map(value => ({ value, label: value }))} disabled={statesLoading || !!closingId || loading || !azureStates.length} placeholder={statesLoading ? (de ? "Status werden geladen …" : "Loading states …") : selected.state} ariaLabel={de ? "Neuer Status" : "New state"} onChange={value => { targetState = value; }} />
<button class="workspace-button issue-state-action" disabled={statesLoading || !!closingId || loading || !targetState || targetState === selected.state || !azureStates.includes(targetState)} onclick={() => changeIssueState(targetState)}>{closingId === selected.id ? (de ? "Wird gespeichert …" : "Saving …") : (de ? "Status übernehmen" : "Apply state")}</button>
{#if statesError}<p class="comment-error" role="alert">{statesError}</p><button class="workspace-button" onclick={loadAzureStates}>{de ? "Erneut versuchen" : "Retry"}</button>{/if}
</section>
{:else if selected.state !== "closed" && closedStates.get(`${sourceKey}:${selected.id}`) !== selected.state}
<button class="workspace-button issue-close-action" disabled={!!closingId || loading} onclick={() => changeIssueState()}><XCircle size={15} />{closingId === selected.id ? (de ? "Wird geschlossen …" : "Closing …") : (de ? "Issue schließen" : "Close issue")}</button>
{/if} {/if}
{#if actionError}<p class="comment-error" role="alert">{actionError}</p>{/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} {#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}
@@ -257,3 +341,7 @@
{/if} {/if}
{/if} {/if}
</section> </section>
{#if createSource}
<CreateIssueDialog source={createSource} {de} initialRepository={createRepository} {loadCredential} onClose={() => { createSource = null; }} onCreated={issueCreated}/>
{/if}
+1 -1
View File
@@ -302,7 +302,7 @@
<div class="dialog-header-actions"> <div class="dialog-header-actions">
<button class="external-diff" type="button" onclick={onExternalDiff} disabled={isBusy || isLoading} title={t(`In ${diffName} öffnen`, `Open in ${diffName}`)}><span>{t("Extern öffnen", "Open externally")}</span><ExternalLink size={14} /><span>·</span><span>{diffName}</span></button> <button class="external-diff" type="button" onclick={onExternalDiff} disabled={isBusy || isLoading} title={t(`In ${diffName} öffnen`, `Open in ${diffName}`)}><span>{t("Extern öffnen", "Open externally")}</span><ExternalLink size={14} /><span>·</span><span>{diffName}</span></button>
<button class="icon-action" type="button" onclick={onRefresh} disabled={isBusy || isLoading} aria-label={t("Aktualisieren", "Refresh")} title={t("Aktualisieren", "Refresh")}><RefreshCw size={16} /></button> <button class="icon-action" type="button" onclick={onRefresh} disabled={isBusy || isLoading} aria-label={t("Aktualisieren", "Refresh")} title={t("Aktualisieren", "Refresh")}><RefreshCw size={16} /></button>
<button class="icon-action" type="button" onclick={onClose} disabled={isBusy} aria-label={t("Schließen", "Close")}><X size={16} /></button> <button data-dialog-close class="icon-action" type="button" onclick={onClose} disabled={isBusy} aria-label={t("Schließen", "Close")}><X size={16} /></button>
</div> </div>
</header> </header>
+43 -58
View File
@@ -17,7 +17,7 @@
recent: boolean; recent: boolean;
} }
interface Workspace { id: string; name: string; } import type { Workspace } from "../workspaces";
export let repos: DashboardRepository[] = []; export let repos: DashboardRepository[] = [];
export let language: "de" | "en" = "en"; export let language: "de" | "en" = "en";
export let isBusy = false; export let isBusy = false;
@@ -33,10 +33,13 @@
const STORAGE_KEY = "gitty.dashboard.v1"; const STORAGE_KEY = "gitty.dashboard.v1";
let query = ""; let query = "";
let viewMode: "cards" | "list" = "list"; let viewMode: "cards" | "list" = "list";
let selectedWorkspace = ""; export let selectedWorkspace = "";
export let onWorkspaceChange: (id: string) => unknown;
export let onSaveWorkspace: (id: string, name: string, repositories: string[]) => unknown;
export let onDeleteWorkspace: () => unknown;
let editingWorkspaceId = "";
let collapsedCategories = new Set<string>(); let collapsedCategories = new Set<string>();
let workspaces: Workspace[] = []; export let workspaces: Workspace[] = [];
let assignments: Record<string, string> = {};
let workspaceDialog: HTMLDialogElement; let workspaceDialog: HTMLDialogElement;
let workspaceInput: HTMLInputElement; let workspaceInput: HTMLInputElement;
let workspaceName = ""; let workspaceName = "";
@@ -46,21 +49,22 @@
$: de = language === "de"; $: de = language === "de";
$: normalizedQuery = query.trim().toLocaleLowerCase(); $: normalizedQuery = query.trim().toLocaleLowerCase();
$: filteredRepos = repos.filter((repo) => { $: workspaceRepos = selectedWorkspace ? repos.filter(repo => workspaces.find(item => item.id === selectedWorkspace)?.repositories.includes(repo.path)) : repos;
$: filteredRepos = workspaceRepos.filter((repo) => {
const matchesSearch = !normalizedQuery || `${repo.name} ${repo.path} ${repo.branch ?? ""}`.toLocaleLowerCase().includes(normalizedQuery); const matchesSearch = !normalizedQuery || `${repo.name} ${repo.path} ${repo.branch ?? ""}`.toLocaleLowerCase().includes(normalizedQuery);
return matchesSearch && (!selectedWorkspace || assignments[repo.path] === selectedWorkspace); return matchesSearch;
}); });
$: openRepos = filteredRepos.filter((repo) => repo.isOpen); $: openRepos = filteredRepos.filter((repo) => repo.isOpen);
$: favoriteRepos = filteredRepos.filter((repo) => repo.favorite); $: favoriteRepos = filteredRepos.filter((repo) => repo.favorite);
$: recentRepos = filteredRepos.filter((repo) => repo.recent && !repo.isOpen); $: recentRepos = filteredRepos.filter((repo) => (repo.recent || !!selectedWorkspace) && !repo.isOpen);
$: categories = [ $: categories = [
{ id: "open", label: "Open", repos: openRepos }, { id: "open", label: "Open", repos: openRepos },
{ id: "favorites", label: de ? "Favoriten" : "Favorites", repos: favoriteRepos }, { id: "favorites", label: de ? "Favoriten" : "Favorites", repos: favoriteRepos },
{ id: "recent", label: "Recent", repos: recentRepos }, { id: "recent", label: selectedWorkspace ? (de ? "Weitere Repositories" : "Other repositories") : "Recent", repos: recentRepos },
]; ];
$: changedCount = repos.filter((repo) => repo.known && repo.changed > 0).length; $: changedCount = workspaceRepos.filter((repo) => repo.known && repo.changed > 0).length;
$: workspaceOptions = [ $: workspaceOptions = [
{ value: "", label: de ? "Alle Workspaces" : "All workspaces" }, { value: "", label: de ? "Alle Repositories" : "All repositories" },
...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name, group: "Workspaces" })), ...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name, group: "Workspaces" })),
]; ];
loadPreferences(); loadPreferences();
@@ -69,26 +73,12 @@
try { try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") as { workspaces?: unknown; assignments?: unknown; viewMode?: unknown }; const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") as { workspaces?: unknown; assignments?: unknown; viewMode?: unknown };
viewMode = saved.viewMode === "cards" ? "cards" : "list"; viewMode = saved.viewMode === "cards" ? "cards" : "list";
if (Array.isArray(saved.workspaces)) { } catch { /* Optional display preferences. */ }
workspaces = saved.workspaces.filter((item): item is Workspace => Boolean(
item && typeof item === "object"
&& "id" in item && typeof item.id === "string" && item.id.startsWith("workspace-")
&& "name" in item && typeof item.name === "string" && item.name.trim(),
));
}
if (saved.assignments && typeof saved.assignments === "object" && !Array.isArray(saved.assignments)) {
assignments = Object.fromEntries(Object.entries(saved.assignments).filter(([, workspaceId]) =>
typeof workspaceId === "string" && workspaces.some((workspace) => workspace.id === workspaceId)));
}
} catch {
workspaces = [];
assignments = {};
}
} }
function savePreferences() { function savePreferences() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ workspaces, assignments, viewMode })); } try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ viewMode })); }
catch { /* Preferences remain optional when storage is unavailable. */ } catch { /* Optional display preferences. */ }
} }
function setViewMode(mode: "cards" | "list") { function setViewMode(mode: "cards" | "list") {
@@ -96,10 +86,12 @@
savePreferences(); savePreferences();
} }
function openWorkspaceDialog() { function openWorkspaceDialog(edit = false) {
workspaceName = ""; const workspace = edit ? workspaces.find(item => item.id === selectedWorkspace) : undefined;
editingWorkspaceId = workspace?.id ?? "";
workspaceName = workspace?.name ?? "";
workspaceError = ""; workspaceError = "";
workspaceSelection = new Set(); workspaceSelection = new Set(workspace?.repositories ?? []);
workspaceDialog.showModal(); workspaceDialog.showModal();
requestAnimationFrame(() => workspaceInput.focus()); requestAnimationFrame(() => workspaceInput.focus());
} }
@@ -118,27 +110,14 @@
workspaceError = de ? "Bitte einen Namen eingeben." : "Please enter a name."; workspaceError = de ? "Bitte einen Namen eingeben." : "Please enter a name.";
return; return;
} }
if (workspaces.some((workspace) => workspace.name.toLocaleLowerCase() === name.toLocaleLowerCase())) { if (workspaces.some((workspace) => workspace.id !== editingWorkspaceId && workspace.name.toLocaleLowerCase() === name.toLocaleLowerCase())) {
workspaceError = de ? "Dieser Workspace existiert bereits." : "This workspace already exists."; workspaceError = de ? "Dieser Workspace existiert bereits." : "This workspace already exists.";
return; return;
} }
const id = `workspace-${crypto.randomUUID()}`; void onSaveWorkspace(editingWorkspaceId, name, [...workspaceSelection]);
workspaces = [...workspaces, { id, name }];
assignments = { ...assignments, ...Object.fromEntries([...workspaceSelection].map((path) => [path, id])) };
selectedWorkspace = id;
savePreferences();
workspaceDialog.close(); workspaceDialog.close();
} }
function deleteSelectedWorkspace() {
if (!selectedWorkspace) return;
const deletedWorkspace = selectedWorkspace;
workspaces = workspaces.filter((workspace) => workspace.id !== deletedWorkspace);
assignments = Object.fromEntries(Object.entries(assignments).filter(([, workspaceId]) => workspaceId !== deletedWorkspace));
selectedWorkspace = "";
savePreferences();
}
function openCard(path: string) { if (!isBusy) onOpen(path); } function openCard(path: string) { if (!isBusy) onOpen(path); }
function closeCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onClose(path); } function closeCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onClose(path); }
function favoriteCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onFavorite(path); } function favoriteCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onFavorite(path); }
@@ -164,8 +143,8 @@
</script> </script>
<section class="repo-dashboard" aria-label={de ? "Repository-Verwaltung" : "Repository management"}> <section class="repo-dashboard" aria-label={de ? "Repository-Verwaltung" : "Repository management"}>
<header class="dashboard-header"> <header class="dashboard-header page-header">
<h1>Repository Management</h1> <div class="page-heading"><FolderGit2 size={17} /><h1>Dashboard</h1></div>
<div class="header-actions"> <div class="header-actions">
<button type="button" onclick={onClone} disabled={isBusy}><Download size={14} />{de ? "Klonen" : "Clone"}</button> <button type="button" onclick={onClone} disabled={isBusy}><Download size={14} />{de ? "Klonen" : "Clone"}</button>
<button type="button" onclick={onInit} disabled={isBusy}><FolderGit2 size={14} />{de ? "Initialisieren" : "Initialize"}</button> <button type="button" onclick={onInit} disabled={isBusy}><FolderGit2 size={14} />{de ? "Initialisieren" : "Initialize"}</button>
@@ -188,16 +167,18 @@
value={selectedWorkspace} value={selectedWorkspace}
options={workspaceOptions} options={workspaceOptions}
ariaLabel={de ? "Workspace auswählen" : "Select workspace"} ariaLabel={de ? "Workspace auswählen" : "Select workspace"}
onChange={(value) => { selectedWorkspace = value; }} disabled={isBusy}
onChange={onWorkspaceChange}
/> />
{#if selectedWorkspace} {#if selectedWorkspace}
<button class="workspace-delete" type="button" onclick={deleteSelectedWorkspace} aria-label={de ? "Ausgewählten Workspace löschen" : "Delete selected workspace"} title={de ? "Workspace löschen" : "Delete workspace"}><Trash2 size={15} strokeWidth={2} /></button> <button type="button" disabled={isBusy} onclick={() => openWorkspaceDialog(true)}>{de ? "Bearbeiten" : "Edit"}</button>
<button class="workspace-delete" type="button" disabled={isBusy} onclick={onDeleteWorkspace} aria-label={de ? "Ausgewählten Workspace löschen" : "Delete selected workspace"} title={de ? "Workspace löschen" : "Delete workspace"}><Trash2 size={15} strokeWidth={2} /></button>
{/if} {/if}
<button type="button" onclick={openWorkspaceDialog}><Plus size={14} />Workspace</button> <button type="button" disabled={isBusy} onclick={() => openWorkspaceDialog()}><Plus size={14} />Workspace</button>
</div> </div>
</div> </div>
<div class="dashboard-summary">{repos.length} Repositories <span>·</span> {changedCount} {de ? "mit Änderungen" : "with changes"}</div> <div class="dashboard-summary">{workspaceRepos.length} Repositories <span>·</span> {changedCount} {de ? "mit Änderungen" : "with changes"}</div>
<div class="dashboard-content" class:table-view={viewMode === "list"}> <div class="dashboard-content" class:table-view={viewMode === "list"}>
{#each categories as category (category.id)} {#each categories as category (category.id)}
@@ -230,7 +211,7 @@
</button> </button>
<button class:active={repo.favorite} type="button" onclick={(event) => favoriteCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Favorit umschalten" : "Toggle favorite"}`} aria-pressed={repo.favorite}><Star size={15} /></button> <button class:active={repo.favorite} type="button" onclick={(event) => favoriteCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Favorit umschalten" : "Toggle favorite"}`} aria-pressed={repo.favorite}><Star size={15} /></button>
{#if repo.isOpen && category.id === "open"}<button type="button" onclick={(event) => closeCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Schließen" : "Close"}`} title={de ? "Repository schließen" : "Close repository"}><X size={15} /></button>{/if} {#if repo.isOpen && category.id === "open"}<button type="button" onclick={(event) => closeCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Schließen" : "Close"}`} title={de ? "Repository schließen" : "Close repository"}><X size={15} /></button>{/if}
{#if category.id === "recent"}<button type="button" onclick={(event) => removeRecentCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Aus Recent entfernen" : "Remove from recent"}`} title={de ? "Aus Recent entfernen" : "Remove from recent"}><X size={15} /></button>{/if} {#if category.id === "recent" && !selectedWorkspace}<button type="button" onclick={(event) => removeRecentCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Aus Recent entfernen" : "Remove from recent"}`} title={de ? "Aus Recent entfernen" : "Remove from recent"}><X size={15} /></button>{/if}
</div> </div>
</article> </article>
{/each} {/each}
@@ -242,21 +223,21 @@
</section> </section>
{/each} {/each}
</div> </div>
<footer class="dashboard-footer">{repos.length} {de ? "lokale Repositories" : "local repositories"}</footer> <footer class="dashboard-footer">{workspaceRepos.length} {de ? "lokale Repositories" : "local repositories"}</footer>
</section> </section>
<dialog class="workspace-dialog" bind:this={workspaceDialog} aria-labelledby="workspace-title"> <dialog class="workspace-dialog" bind:this={workspaceDialog} aria-labelledby="workspace-title">
<form onsubmit={createWorkspace}> <form onsubmit={createWorkspace}>
<header><h2 id="workspace-title">{de ? "Workspace anlegen" : "Create workspace"}</h2><button type="button" onclick={() => workspaceDialog.close()} aria-label={de ? "Dialog schließen" : "Close dialog"}><X size={14} /></button></header> <header><h2 id="workspace-title">{editingWorkspaceId ? (de ? "Workspace bearbeiten" : "Edit workspace") : (de ? "Workspace anlegen" : "Create workspace")}</h2><button data-dialog-close type="button" onclick={() => workspaceDialog.close()} aria-label={de ? "Dialog schließen" : "Close dialog"}><X size={14} /></button></header>
<label for="workspace-name">Name</label> <label for="workspace-name">Name</label>
<input id="workspace-name" bind:this={workspaceInput} bind:value={workspaceName} maxlength="64" autocomplete="off" aria-invalid={Boolean(workspaceError)} /> <input id="workspace-name" bind:this={workspaceInput} bind:value={workspaceName} maxlength="64" autocomplete="off" aria-invalid={Boolean(workspaceError)} />
{#if workspaceError}<p class="dialog-error" role="alert">{workspaceError}</p>{/if} {#if workspaceError}<p class="dialog-error" role="alert">{workspaceError}</p>{/if}
{#if repos.length > 0} {#if repos.length > 0}
<fieldset><legend>{de ? "Repositories zuordnen" : "Assign repositories"}</legend><div class="workspace-repos"> <fieldset><legend>{de ? "Repositories zuordnen" : "Assign repositories"} · {workspaceSelection.size} {de ? "ausgewählt" : "selected"}</legend><div class="workspace-repos">
{#each repos as repo (repo.path)}<label><input type="checkbox" checked={workspaceSelection.has(repo.path)} onchange={() => toggleWorkspaceRepo(repo.path)} /><span>{repo.name}</span><small>{repo.path}</small></label>{/each} {#each repos as repo (repo.path)}<label class:selected={workspaceSelection.has(repo.path)}><input type="checkbox" checked={workspaceSelection.has(repo.path)} onchange={() => toggleWorkspaceRepo(repo.path)} /><span>{repo.name}</span><small>{repo.path}</small></label>{/each}
</div></fieldset> </div></fieldset>
{/if} {/if}
<footer><button type="button" onclick={() => workspaceDialog.close()}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit"><Check size={14} />{de ? "Anlegen" : "Create"}</button></footer> <footer><button type="button" onclick={() => workspaceDialog.close()}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit"><Check size={14} />{editingWorkspaceId ? (de ? "Speichern" : "Save") : (de ? "Anlegen" : "Create")}</button></footer>
</form> </form>
</dialog> </dialog>
@@ -268,7 +249,11 @@
.repo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.repo-card{position:relative;min-width:0;height:138px;border:1px solid var(--color-border);background:var(--color-surface)}.repo-card:hover,.repo-card:focus-within{border-color:var(--color-border-input);background:var(--color-surface-hover)}.card-main{display:flex;width:100%;height:100%;flex-direction:column;align-items:flex-start;gap:7px;padding:12px 104px 11px 14px;border:0;background:transparent;text-align:left}.card-main:focus-visible{outline:2px solid var(--color-accent);outline-offset:-2px}.card-title{display:flex;max-width:100%;align-items:center;gap:8px}.card-title strong{overflow:hidden;font-size:14px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.card-branch,.card-status,.card-status>span{display:flex;align-items:center;gap:6px}.card-branch{max-width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-status{flex-wrap:wrap;gap:8px 18px;margin-top:auto}.card-status :global(.filled){fill:currentColor}.changed{color:#eeb94e}.behind{color:var(--color-sync-behind)}.clean{color:#68c878}.ahead{color:var(--color-sync-ahead)}.muted{color:var(--color-ink-muted)} .repo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.repo-card{position:relative;min-width:0;height:138px;border:1px solid var(--color-border);background:var(--color-surface)}.repo-card:hover,.repo-card:focus-within{border-color:var(--color-border-input);background:var(--color-surface-hover)}.card-main{display:flex;width:100%;height:100%;flex-direction:column;align-items:flex-start;gap:7px;padding:12px 104px 11px 14px;border:0;background:transparent;text-align:left}.card-main:focus-visible{outline:2px solid var(--color-accent);outline-offset:-2px}.card-title{display:flex;max-width:100%;align-items:center;gap:8px}.card-title strong{overflow:hidden;font-size:14px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.card-branch,.card-status,.card-status>span{display:flex;align-items:center;gap:6px}.card-branch{max-width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-status{flex-wrap:wrap;gap:8px 18px;margin-top:auto}.card-status :global(.filled){fill:currentColor}.changed{color:#eeb94e}.behind{color:var(--color-sync-behind)}.clean{color:#68c878}.ahead{color:var(--color-sync-ahead)}.muted{color:var(--color-ink-muted)}
.card-actions{position:absolute;top:8px;right:8px;z-index:2;display:flex;align-items:center;gap:2px}.card-actions button{display:grid;width:26px;height:26px;place-items:center;padding:0;border:0;color:var(--color-ink-muted);background:transparent}.card-actions button:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-dim)}.card-actions button.active{color:var(--color-accent)}.card-actions button.active :global(svg){fill:color-mix(in srgb,var(--color-accent) 22%,transparent)}.card-actions .pr-badge{display:flex;width:auto;min-width:31px;grid-template-columns:none;align-items:center;justify-content:center;gap:4px;padding:0 5px;color:var(--color-ink-faint);font-size:11px}.card-actions .pr-badge:disabled{opacity:.72}.card-actions .pr-badge.has-open-prs{color:var(--color-accent)}.card-actions .pr-badge.pr-error{color:#e0a35b}.card-actions .pr-badge:hover:not(:disabled){color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 9%,transparent)} .card-actions{position:absolute;top:8px;right:8px;z-index:2;display:flex;align-items:center;gap:2px}.card-actions button{display:grid;width:26px;height:26px;place-items:center;padding:0;border:0;color:var(--color-ink-muted);background:transparent}.card-actions button:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-dim)}.card-actions button.active{color:var(--color-accent)}.card-actions button.active :global(svg){fill:color-mix(in srgb,var(--color-accent) 22%,transparent)}.card-actions .pr-badge{display:flex;width:auto;min-width:31px;grid-template-columns:none;align-items:center;justify-content:center;gap:4px;padding:0 5px;color:var(--color-ink-faint);font-size:11px}.card-actions .pr-badge:disabled{opacity:.72}.card-actions .pr-badge.has-open-prs{color:var(--color-accent)}.card-actions .pr-badge.pr-error{color:#e0a35b}.card-actions .pr-badge:hover:not(:disabled){color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 9%,transparent)}
.dashboard-footer{display:flex;min-height:30px;align-items:center;padding:0 20px;border-top:1px solid var(--color-border-subtle);color:var(--color-ink-muted)} .dashboard-footer{display:flex;min-height:30px;align-items:center;padding:0 20px;border-top:1px solid var(--color-border-subtle);color:var(--color-ink-muted)}
.workspace-dialog{width:min(420px,calc(100vw - 32px));margin:auto;padding:0;border:1px solid var(--color-border);color:var(--color-ink);background:var(--app-dialog-bg);box-shadow:var(--app-dialog-shadow);font-size:12px}.workspace-dialog::backdrop{background:var(--app-dialog-backdrop)}.workspace-dialog form>header,.workspace-dialog form>footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--app-dialog-chrome)}.workspace-dialog form>header{border-bottom:1px solid var(--color-border)}.workspace-dialog form>footer{justify-content:flex-end;gap:8px;border-top:1px solid var(--color-border)}.workspace-dialog h2{margin:0;font-size:13px}.workspace-dialog header button{width:26px;height:26px;padding:0;border:0;background:transparent}.workspace-dialog form>label,.workspace-dialog form>input,.workspace-dialog fieldset,.dialog-error{margin-right:12px;margin-left:12px}.workspace-dialog form>label{display:block;margin-top:12px;margin-bottom:5px;color:var(--color-ink-muted)}.workspace-dialog form>input{width:calc(100% - 24px);height:30px;padding:0 8px;border:1px solid var(--color-border-input);color:var(--color-ink);background:var(--app-input-bg)}.dialog-error{margin-top:7px;color:#ed9292}.workspace-dialog fieldset{margin-top:14px;margin-bottom:14px;padding:0;border:0}.workspace-dialog legend{margin-bottom:6px;color:var(--color-ink-muted)}.workspace-repos{max-height:190px;overflow:auto;border:1px solid var(--color-border-subtle)}.workspace-repos label{display:grid;grid-template-columns:auto minmax(100px,auto) 1fr;align-items:center;gap:8px;min-height:32px;padding:4px 8px;border-bottom:1px solid var(--color-border-subtle)}.workspace-repos label:last-child{border-bottom:0}.workspace-repos small{overflow:hidden;color:var(--color-ink-faint);text-overflow:ellipsis;white-space:nowrap}.workspace-dialog footer button{display:inline-flex;min-height:28px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)} .workspace-repos input[type="checkbox"]{appearance:auto;-webkit-appearance:checkbox;width:16px;min-width:16px;height:16px;margin:0;padding:0;accent-color:var(--color-primary);cursor:pointer}
.workspace-repos input[type="checkbox"]:focus-visible{outline:2px solid var(--color-accent);outline-offset:3px}
.workspace-repos label{cursor:pointer}
.workspace-repos label.selected{background:color-mix(in srgb,var(--color-primary) 14%,var(--app-dialog-bg));box-shadow:inset 3px 0 var(--color-primary)}
.workspace-dialog{width:min(420px,calc(100vw - 32px));margin:auto;padding:0;border:1px solid var(--color-border);color:var(--color-ink);background:var(--app-dialog-bg);box-shadow:var(--app-dialog-shadow);font-size:12px}.workspace-dialog::backdrop{background:var(--app-dialog-backdrop)}.workspace-dialog form>header,.workspace-dialog form>footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--app-dialog-chrome)}.workspace-dialog form>header{border-bottom:1px solid var(--color-border)}.workspace-dialog form>footer{justify-content:flex-end;gap:8px;border-top:1px solid var(--color-border)}.workspace-dialog h2{margin:0;font-size:13px}.workspace-dialog header button{width:26px;height:26px;padding:0;border:0;background:transparent}.workspace-dialog form>label,.workspace-dialog form>input,.workspace-dialog fieldset,.dialog-error{margin-right:12px;margin-left:12px}.workspace-dialog form>label{display:block;margin-top:12px;margin-bottom:5px;color:var(--color-ink-muted)}.workspace-dialog form>input{width:calc(100% - 24px);height:30px;padding:0 8px;border:1px solid var(--color-border-input);color:var(--color-ink);background:var(--app-input-bg)}.dialog-error{margin-top:7px;color:#ed9292}.workspace-dialog fieldset{margin-top:14px;margin-bottom:14px;padding:0;border:0}.workspace-dialog legend{margin-bottom:6px;color:var(--color-ink-muted)}.workspace-repos{max-height:190px;overflow:auto;border:1px solid var(--color-border-subtle)}.workspace-repos label{display:grid;grid-template-columns:16px minmax(100px,auto) 1fr;align-items:center;gap:8px;min-height:32px;padding:4px 8px;border-bottom:1px solid var(--color-border-subtle)}.workspace-repos label:last-child{border-bottom:0}.workspace-repos small{overflow:hidden;color:var(--color-ink-faint);text-overflow:ellipsis;white-space:nowrap}.workspace-dialog footer button{display:inline-flex;min-height:28px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)}
@media(max-width:1000px){.dashboard-toolbar{flex-wrap:wrap;gap:8px}.dashboard-search{flex:1 1 260px}.workspace-tools{margin-left:0}.repo-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} @media(max-width:1000px){.dashboard-toolbar{flex-wrap:wrap;gap:8px}.dashboard-search{flex:1 1 260px}.workspace-tools{margin-left:0}.repo-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:680px){.dashboard-header{min-height:auto;align-items:flex-start;flex-direction:column}.header-actions{flex-wrap:wrap}.workspace-tools{width:100%}.workspace-tools :global(.workspace-select){min-width:0;flex:1}.repo-grid{grid-template-columns:1fr}.repo-card{height:132px}} @media(max-width:680px){.dashboard-header{min-height:auto;align-items:flex-start;flex-direction:column}.header-actions{flex-wrap:wrap}.workspace-tools{width:100%}.workspace-tools :global(.workspace-select){min-width:0;flex:1}.repo-grid{grid-template-columns:1fr}.repo-card{height:132px}}
.view-switch{display:flex;flex:0 0 auto;height:30px;border:1px solid var(--color-border-input);background:var(--app-input-bg)} .view-switch{display:flex;flex:0 0 auto;height:30px;border:1px solid var(--color-border-input);background:var(--app-input-bg)}
+1 -1
View File
@@ -285,7 +285,7 @@
<h2><GitMerge size={24} />{t("Konflikte lösen", "Resolve conflicts")}</h2> <h2><GitMerge size={24} />{t("Konflikte lösen", "Resolve conflicts")}</h2>
<div class="header-actions"> <div class="header-actions">
{#if conflictTarget}<button type="button" onclick={() => onExternalMerge(conflictTarget)} disabled={isBusy} title={mergeName}><ExternalLink size={18} />{t("Externes Merge-Tool", "External merge tool")}</button>{/if} {#if conflictTarget}<button type="button" onclick={() => onExternalMerge(conflictTarget)} disabled={isBusy} title={mergeName}><ExternalLink size={18} />{t("Externes Merge-Tool", "External merge tool")}</button>{/if}
<button class="close-button" type="button" onclick={onClose} disabled={isBusy} aria-label={t("Schließen", "Close")}><X size={22} /></button> <button data-dialog-close class="close-button" type="button" onclick={onClose} disabled={isBusy} aria-label={t("Schließen", "Close")}><X size={22} /></button>
</div> </div>
</header> </header>
{#if conflictedFiles.length === 0} {#if conflictedFiles.length === 0}
+5 -5
View File
@@ -402,8 +402,8 @@
{/if} {/if}
<section class:has-inspector={detailOpen && !!selected} class="review-center" aria-label="Review Center"> <section class:has-inspector={detailOpen && !!selected} class="review-center" aria-label="Review Center">
<header class="review-header"> <header class="review-header page-header">
<div class="review-heading"><GitPullRequest size={17} /><h1>Review Center</h1></div> <div class="review-heading page-heading"><GitPullRequest size={17} /><h1>Review Center</h1></div>
{#if activeSource}<button class="create-review" type="button" disabled={loading} onclick={() => { detailOpen = false; createOpen = true; }}><GitPullRequest size={14}/>{activeSource.provider.startsWith("gitlab") ? (de ? "MR erstellen" : "Create MR") : (de ? "PR erstellen" : "Create PR")}</button>{/if} {#if activeSource}<button class="create-review" type="button" disabled={loading} onclick={() => { detailOpen = false; createOpen = true; }}><GitPullRequest size={14}/>{activeSource.provider.startsWith("gitlab") ? (de ? "MR erstellen" : "Create MR") : (de ? "PR erstellen" : "Create PR")}</button>{/if}
</header> </header>
@@ -459,7 +459,7 @@
<span>{stateLabel(request.state)}<small title={formatDate(request.updatedAt || request.createdAt)}>{formatRelativeDate(request.updatedAt || request.createdAt)}</small></span> <span>{stateLabel(request.state)}<small title={formatDate(request.updatedAt || request.createdAt)}>{formatRelativeDate(request.updatedAt || request.createdAt)}</small></span>
</span> </span>
<span class="request-title"><span><code>#{request.number}</code><strong title={request.title}>{request.title}</strong></span><small class="change-stats"><b>+{request.additions ?? ""}</b><i>/</i><em>{request.deletions ?? ""}</em>{#if request.changedFiles !== null}<span title={de ? "Geänderte Dateien" : "Changed files"}>{request.changedFiles} {request.changedFiles === 1 ? (de ? "Datei geändert" : "file changed") : (de ? "Dateien geändert" : "files changed")}</span>{:else if detailLoadingId === request.id}<span>{de ? "Lädt …" : "Loading …"}</span>{/if}</small></span> <span class="request-title"><span><code>#{request.number}</code><strong title={request.title}>{request.title}</strong></span><small class="change-stats"><b>+{request.additions ?? ""}</b><i>/</i><em>{request.deletions ?? ""}</em>{#if request.changedFiles !== null}<span title={de ? "Geänderte Dateien" : "Changed files"}>{request.changedFiles} {request.changedFiles === 1 ? (de ? "Datei geändert" : "file changed") : (de ? "Dateien geändert" : "files changed")}</span>{:else if detailLoadingId === request.id}<span>{de ? "Lädt …" : "Loading …"}</span>{/if}</small></span>
<span class="request-author"><i>{initials(request.author)}</i><span>{request.author || (de ? "Unbekannt" : "Unknown")}</span></span> <span class="request-author" title={request.author || (de ? "Unbekannt" : "Unknown")} aria-label={request.author || (de ? "Unbekannt" : "Unknown")}><i aria-hidden="true">{initials(request.author)}</i></span>
<span class="collaborators">{#if request.collaborators.length}{#each request.collaborators.slice(0, 3) as collaborator}<i title={collaborator}>{initials(collaborator)}</i>{/each}{:else}<span></span>{/if}</span> <span class="collaborators">{#if request.collaborators.length}{#each request.collaborators.slice(0, 3) as collaborator}<i title={collaborator}>{initials(collaborator)}</i>{/each}{:else}<span></span>{/if}</span>
<span class="repo-branch"><strong>{request.repositoryName}</strong>{#if request.sourceBranch && request.targetBranch}<span class="branch-route"><GitBranch size={11} /><code title={request.sourceBranch}>{request.sourceBranch}</code><b></b><code title={request.targetBranch}>{request.targetBranch}</code></span>{:else}<span class="branch-loading"><LoaderCircle class="spin" size={11} />{de ? "Branches werden geladen …" : "Loading branches …"}</span>{/if}</span> <span class="repo-branch"><strong>{request.repositoryName}</strong>{#if request.sourceBranch && request.targetBranch}<span class="branch-route"><GitBranch size={11} /><code title={request.sourceBranch}>{request.sourceBranch}</code><b></b><code title={request.targetBranch}>{request.targetBranch}</code></span>{:else}<span class="branch-loading"><LoaderCircle class="spin" size={11} />{de ? "Branches werden geladen …" : "Loading branches …"}</span>{/if}</span>
<span class="row-actions"> <span class="row-actions">
@@ -494,7 +494,7 @@
<aside bind:this={detailPanel} class="detail-panel" transition:fly={{ x: 140, duration: 210, easing: cubicOut }}> <aside bind:this={detailPanel} class="detail-panel" transition:fly={{ x: 140, duration: 210, easing: cubicOut }}>
<header class="detail-header"> <header class="detail-header">
<div class="detail-provider"><GitPullRequest size={17} /><strong>{providerLabel(selected.provider)} {requestTypeLabel(selected.provider)}</strong></div> <div class="detail-provider"><GitPullRequest size={17} /><strong>{providerLabel(selected.provider)} {requestTypeLabel(selected.provider)}</strong></div>
<div class="detail-header-actions"><button class="icon-button" type="button" aria-label={de ? "Im Anbieter öffnen" : "Open in provider"} onclick={() => void openRequest()}><ExternalLink size={16} /></button><button class="icon-button" type="button" aria-label={de ? "Detailansicht schließen" : "Close details"} onclick={() => { detailOpen = false; }}><X size={17} /></button></div> <div class="detail-header-actions"><button class="icon-button" type="button" aria-label={de ? "Im Anbieter öffnen" : "Open in provider"} onclick={() => void openRequest()}><ExternalLink size={16} /></button><button data-dialog-close class="icon-button" type="button" aria-label={de ? "Detailansicht schließen" : "Close details"} onclick={() => { detailOpen = false; }}><X size={17} /></button></div>
</header> </header>
<div class="detail-content"> <div class="detail-content">
<main class="detail-main"> <main class="detail-main">
@@ -537,7 +537,7 @@
.state-tabs{display:flex;min-height:42px;align-items:stretch;gap:18px;padding:0 18px;border-bottom:1px solid var(--color-border-subtle);background:var(--color-surface)}.state-tabs button{position:relative;display:flex;align-items:center;gap:7px;padding:0 5px;border:0;color:var(--color-ink-muted);background:transparent;font-size:11px}.state-tabs button:hover{color:var(--color-ink)}.state-tabs button.active{color:var(--color-accent)}.state-tabs button.active:after{position:absolute;right:0;bottom:0;left:0;height:2px;background:var(--color-accent);content:""}.state-tabs span,.group-header>span{display:grid;min-width:18px;height:18px;place-items:center;padding:0 5px;border-radius:9px;color:var(--color-ink-muted);background:var(--color-surface-raised);font-size:9.5px} .state-tabs{display:flex;min-height:42px;align-items:stretch;gap:18px;padding:0 18px;border-bottom:1px solid var(--color-border-subtle);background:var(--color-surface)}.state-tabs button{position:relative;display:flex;align-items:center;gap:7px;padding:0 5px;border:0;color:var(--color-ink-muted);background:transparent;font-size:11px}.state-tabs button:hover{color:var(--color-ink)}.state-tabs button.active{color:var(--color-accent)}.state-tabs button.active:after{position:absolute;right:0;bottom:0;left:0;height:2px;background:var(--color-accent);content:""}.state-tabs span,.group-header>span{display:grid;min-width:18px;height:18px;place-items:center;padding:0 5px;border-radius:9px;color:var(--color-ink-muted);background:var(--color-surface-raised);font-size:9.5px}
.review-toolbar{display:flex;min-height:48px;align-items:center;gap:10px;padding:7px 18px;border-bottom:1px solid var(--color-border-subtle)}.group-actions{display:flex;align-items:center;gap:2px}.group-actions button,.icon-button{display:inline-flex;height:30px;align-items:center;gap:5px;padding:0 7px;border:1px solid transparent;color:var(--color-ink-muted);background:transparent}.group-actions button:hover,.icon-button:hover{border-color:var(--color-border);color:var(--color-ink);background:var(--color-surface-hover)}.group-actions .icon-button{width:30px;justify-content:center;padding:0;border-color:var(--color-border-subtle);margin-left:3px}.review-search{display:flex;min-width:180px;height:30px;align-items:center;gap:7px;flex:1;padding:0 9px;border:1px solid var(--color-border-input);color:var(--color-ink-faint);background:var(--app-input-bg)}.review-search:focus-within{border-color:var(--color-accent)}.review-search input{width:100%;min-width:0;height:100%;padding:0;border:0;outline:0;color:var(--color-ink);background:transparent} .review-toolbar{display:flex;min-height:48px;align-items:center;gap:10px;padding:7px 18px;border-bottom:1px solid var(--color-border-subtle)}.group-actions{display:flex;align-items:center;gap:2px}.group-actions button,.icon-button{display:inline-flex;height:30px;align-items:center;gap:5px;padding:0 7px;border:1px solid transparent;color:var(--color-ink-muted);background:transparent}.group-actions button:hover,.icon-button:hover{border-color:var(--color-border);color:var(--color-ink);background:var(--color-surface-hover)}.group-actions .icon-button{width:30px;justify-content:center;padding:0;border-color:var(--color-border-subtle);margin-left:3px}.review-search{display:flex;min-width:180px;height:30px;align-items:center;gap:7px;flex:1;padding:0 9px;border:1px solid var(--color-border-input);color:var(--color-ink-faint);background:var(--app-input-bg)}.review-search:focus-within{border-color:var(--color-accent)}.review-search input{width:100%;min-width:0;height:100%;padding:0;border:0;outline:0;color:var(--color-ink);background:transparent}
.source-warning{padding:7px 18px;border-bottom:1px solid color-mix(in srgb,#d3a64d 28%,var(--color-border));color:#d3a64d;background:color-mix(in srgb,#d3a64d 6%,var(--app-bg))}.source-warning summary{display:flex;align-items:center;gap:7px;cursor:pointer}.source-warning ul{display:grid;gap:4px;margin:7px 0 2px;padding-left:22px;color:var(--color-ink-muted);font-size:10.5px}.source-warning li strong{color:var(--color-ink)}.action-notice{display:flex;min-height:30px;align-items:center;gap:7px;padding:0 18px;border-bottom:1px solid color-mix(in srgb,#63c783 28%,var(--color-border));color:#63c783;background:color-mix(in srgb,#63c783 6%,var(--app-bg));font-size:10.5px} .source-warning{padding:7px 18px;border-bottom:1px solid color-mix(in srgb,#d3a64d 28%,var(--color-border));color:#d3a64d;background:color-mix(in srgb,#d3a64d 6%,var(--app-bg))}.source-warning summary{display:flex;align-items:center;gap:7px;cursor:pointer}.source-warning ul{display:grid;gap:4px;margin:7px 0 2px;padding-left:22px;color:var(--color-ink-muted);font-size:10.5px}.source-warning li strong{color:var(--color-ink)}.action-notice{display:flex;min-height:30px;align-items:center;gap:7px;padding:0 18px;border-bottom:1px solid color-mix(in srgb,#63c783 28%,var(--color-border));color:#63c783;background:color-mix(in srgb,#63c783 6%,var(--app-bg));font-size:10.5px}
.review-layout{display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-height:0}.review-layout.inspector-open{grid-template-columns:minmax(560px,1fr) minmax(360px,38%)}.table-pane{min-width:0;min-height:0;overflow:auto}.table-head,.request-row{display:grid;grid-template-columns:95px minmax(280px,1.5fr) 135px 145px minmax(210px,1fr) 190px;align-items:center}.table-head{position:sticky;z-index:2;top:0;min-height:30px;padding:0 12px;border-bottom:1px solid var(--color-border);color:var(--color-ink-faint);background:var(--color-surface-raised);font-size:9px;font-weight:700;letter-spacing:.035em;text-transform:uppercase}.request-groups{min-width:1075px}.request-group{border-bottom:1px solid var(--color-border-subtle)}.group-header{display:flex;width:100%;height:34px;align-items:center;justify-content:flex-start;gap:7px;padding:0 12px;border:0;border-bottom:1px solid var(--color-border-subtle);color:var(--color-ink);background:var(--color-surface);text-align:left}.group-header:hover{background:var(--color-surface-hover)}.group-header>:global(svg){color:var(--color-ink-faint)}.group-header strong{font-size:11px}.group-header>span{margin-left:2px}.request-row{position:relative;min-height:52px;padding:0 12px;border-bottom:1px solid var(--color-border-subtle);outline:1px solid transparent;outline-offset:-1px;color:var(--color-ink-muted);background:transparent;cursor:default}.request-row:hover{background:var(--color-surface-hover)}.request-row.selected{z-index:1;outline-color:var(--color-accent);background:var(--color-surface-raised)}.request-status{display:flex;align-items:center;gap:6px;font-size:10px}.request-status>span{display:grid;gap:2px}.request-status small{color:var(--color-ink-faint);font-size:9px}.request-title{display:grid;min-width:0;gap:5px;padding-right:14px}.request-title>span{display:flex;min-width:0;gap:8px}.request-title code{color:var(--color-accent);font-size:10px}.request-title strong{overflow:hidden;color:var(--color-ink);font-size:11px;font-weight:560;text-overflow:ellipsis;white-space:nowrap}.change-stats{display:flex;align-items:center;gap:6px;font-size:9.5px}.change-stats b{color:#63c783}.change-stats em{color:#e0737b;font-style:normal}.change-stats i{color:var(--color-ink-faint);font-style:normal}.change-stats span{color:var(--color-ink-faint)}.request-author{display:flex;min-width:0;align-items:center;gap:7px;padding-right:10px}.request-author i,.avatar,.collaborators i{display:grid;width:23px;height:23px;flex:0 0 auto;place-items:center;border:1px solid color-mix(in srgb,var(--color-accent) 35%,var(--color-border));border-radius:50%;color:#fff;background:color-mix(in srgb,var(--color-accent) 45%,var(--color-surface-raised));font-size:9px;font-style:normal;font-weight:750}.request-author>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.collaborators{display:flex;padding-left:3px}.collaborators i+ i{margin-left:-5px}.repo-branch{display:grid;min-width:0;gap:4px}.repo-branch>strong{overflow:hidden;color:var(--color-ink-muted);font-size:10px;text-overflow:ellipsis;white-space:nowrap}.branch-route{display:flex;min-width:0;align-items:center;gap:5px;padding-right:12px}.branch-route code{max-width:42%;overflow:hidden;color:var(--color-ink-faint);font-size:9px;text-overflow:ellipsis;white-space:nowrap}.branch-route b{color:var(--color-ink-faint);font-weight:400}.row-actions{display:flex;align-items:center;justify-content:flex-end;gap:5px}.provider-action{position:relative;display:flex}.provider-button,.action-toggle,.detail-action{display:inline-flex;height:28px;align-items:center;justify-content:center;gap:6px;border:1px solid var(--color-accent);color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 5%,transparent)}.provider-button{min-width:120px;padding:0 8px;border-right:0}.provider-button.merge-primary{color:#63c783;border-color:#4fa565;background:color-mix(in srgb,#4fa565 15%,var(--color-surface))}.provider-button.conflict{color:#e0737b;border-color:#a74b55}.action-toggle{width:27px;padding:0}.provider-button:hover,.action-toggle:hover,.action-toggle.active,.detail-action:hover{color:#fff;background:color-mix(in srgb,var(--color-accent) 24%,var(--color-surface))}.action-menu{position:absolute;z-index:8;top:31px;right:0;display:grid;width:178px;padding:4px;border:1px solid var(--color-border);box-shadow:0 8px 22px #0008;background:var(--color-surface-raised)}.action-menu button{display:flex;height:29px;align-items:center;gap:8px;padding:0 8px;border:0;color:var(--color-ink-muted);background:transparent;text-align:left}.action-menu button:hover{color:var(--color-ink);background:var(--color-surface-hover)}.action-menu button.danger{color:#e0737b}.panel-button{display:grid;width:28px;height:28px;place-items:center;border:1px solid var(--color-border);color:var(--color-ink-muted);background:var(--app-button-bg)}.panel-button:hover,.panel-button.active{border-color:var(--color-accent);color:var(--color-accent)} .review-layout{display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-height:0}.review-layout.inspector-open{grid-template-columns:minmax(560px,1fr) minmax(360px,38%)}.table-pane{min-width:0;min-height:0;overflow:auto}.table-head,.request-row{display:grid;grid-template-columns:95px minmax(280px,1.5fr) 135px 145px minmax(210px,1fr) 190px;align-items:center}.table-head{position:sticky;z-index:2;top:0;min-height:30px;padding:0 12px;border-bottom:1px solid var(--color-border);color:var(--color-ink-faint);background:var(--color-surface-raised);font-size:9px;font-weight:700;letter-spacing:.035em;text-transform:uppercase}.request-groups{min-width:1075px}.request-group{border-bottom:1px solid var(--color-border-subtle)}.group-header{display:flex;width:100%;height:34px;align-items:center;justify-content:flex-start;gap:7px;padding:0 12px;border:0;border-bottom:1px solid var(--color-border-subtle);color:var(--color-ink);background:var(--color-surface);text-align:left}.group-header:hover{background:var(--color-surface-hover)}.group-header>:global(svg){color:var(--color-ink-faint)}.group-header strong{font-size:11px}.group-header>span{margin-left:2px}.request-row{position:relative;min-height:52px;padding:0 12px;border-bottom:1px solid var(--color-border-subtle);outline:1px solid transparent;outline-offset:-1px;color:var(--color-ink-muted);background:transparent;cursor:default}.request-row:hover{background:var(--color-surface-hover)}.request-row.selected{z-index:1;outline-color:var(--color-accent);background:var(--color-surface-raised)}.request-status{display:flex;align-items:center;gap:6px;font-size:10px}.request-status>span{display:grid;gap:2px}.request-status small{color:var(--color-ink-faint);font-size:9px}.request-title{display:grid;min-width:0;gap:5px;padding-right:14px}.request-title>span{display:flex;min-width:0;gap:8px}.request-title code{color:var(--color-accent);font-size:10px}.request-title strong{overflow:hidden;color:var(--color-ink);font-size:11px;font-weight:560;text-overflow:ellipsis;white-space:nowrap}.change-stats{display:flex;align-items:center;gap:6px;font-size:9.5px}.change-stats b{color:#63c783}.change-stats em{color:#e0737b;font-style:normal}.change-stats i{color:var(--color-ink-faint);font-style:normal}.change-stats span{color:var(--color-ink-faint)}.request-author{display:flex;min-width:0;align-items:center;gap:7px;padding-right:10px}.request-author i,.avatar,.collaborators i{display:grid;width:23px;height:23px;flex:0 0 auto;place-items:center;border:1px solid color-mix(in srgb,var(--color-accent) 35%,var(--color-border));border-radius:50%;color:#fff;background:color-mix(in srgb,var(--color-accent) 45%,var(--color-surface-raised));font-size:9px;font-style:normal;font-weight:750}.collaborators{display:flex;padding-left:3px}.collaborators i+ i{margin-left:-5px}.repo-branch{display:grid;min-width:0;gap:4px}.repo-branch>strong{overflow:hidden;color:var(--color-ink-muted);font-size:10px;text-overflow:ellipsis;white-space:nowrap}.branch-route{display:flex;min-width:0;align-items:center;gap:5px;padding-right:12px}.branch-route code{max-width:42%;overflow:hidden;color:var(--color-ink-faint);font-size:9px;text-overflow:ellipsis;white-space:nowrap}.branch-route b{color:var(--color-ink-faint);font-weight:400}.row-actions{display:flex;align-items:center;justify-content:flex-end;gap:5px}.provider-action{position:relative;display:flex}.provider-button,.action-toggle,.detail-action{display:inline-flex;height:28px;align-items:center;justify-content:center;gap:6px;border:1px solid var(--color-accent);color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 5%,transparent)}.provider-button{min-width:120px;padding:0 8px;border-right:0}.provider-button.merge-primary{color:#63c783;border-color:#4fa565;background:color-mix(in srgb,#4fa565 15%,var(--color-surface))}.provider-button.conflict{color:#e0737b;border-color:#a74b55}.action-toggle{width:27px;padding:0}.provider-button:hover,.action-toggle:hover,.action-toggle.active,.detail-action:hover{color:#fff;background:color-mix(in srgb,var(--color-accent) 24%,var(--color-surface))}.action-menu{position:absolute;z-index:8;top:31px;right:0;display:grid;width:178px;padding:4px;border:1px solid var(--color-border);box-shadow:0 8px 22px #0008;background:var(--color-surface-raised)}.action-menu button{display:flex;height:29px;align-items:center;gap:8px;padding:0 8px;border:0;color:var(--color-ink-muted);background:transparent;text-align:left}.action-menu button:hover{color:var(--color-ink);background:var(--color-surface-hover)}.action-menu button.danger{color:#e0737b}.panel-button{display:grid;width:28px;height:28px;place-items:center;border:1px solid var(--color-border);color:var(--color-ink-muted);background:var(--app-button-bg)}.panel-button:hover,.panel-button.active{border-color:var(--color-accent);color:var(--color-accent)}
.detail-panel{min-width:0;min-height:0;overflow:auto;border-left:1px solid var(--color-border);background:var(--color-surface)}.detail-header{display:flex;min-height:78px;align-items:flex-start;justify-content:space-between;gap:14px;padding:15px 16px;border-bottom:1px solid var(--color-border)}.detail-header>div{min-width:0}.detail-content{display:grid;gap:0;padding:0 16px}.detail-summary{display:flex;min-height:48px;align-items:center;gap:8px;border-bottom:1px solid var(--color-border-subtle)}.state-badge{padding:3px 7px;border:1px solid currentColor;font-size:9px;font-weight:700}.detail-summary .avatar{margin-left:4px}.detail-summary strong{font-size:11px}.description{padding:15px 0;border-bottom:1px solid var(--color-border-subtle)}.description h3{margin:0 0 9px;font-size:11px}.description p{margin:0;white-space:pre-wrap;color:var(--color-ink-muted);font-size:11px;line-height:1.55}.description p.muted{color:var(--color-ink-faint)}.detail-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:16px}.detail-action{width:100%;height:32px;padding:0 8px}.detail-action.primary-action{color:#63c783;border-color:#63c783}.detail-action.danger-action{color:#e0737b;border-color:color-mix(in srgb,#e0737b 75%,var(--color-border))}.open{color:#63c783}.draft{color:var(--color-ink-muted)}.merged{color:#b785e8}.closed{color:#e0737b} .detail-panel{min-width:0;min-height:0;overflow:auto;border-left:1px solid var(--color-border);background:var(--color-surface)}.detail-header{display:flex;min-height:78px;align-items:flex-start;justify-content:space-between;gap:14px;padding:15px 16px;border-bottom:1px solid var(--color-border)}.detail-header>div{min-width:0}.detail-content{display:grid;gap:0;padding:0 16px}.detail-summary{display:flex;min-height:48px;align-items:center;gap:8px;border-bottom:1px solid var(--color-border-subtle)}.state-badge{padding:3px 7px;border:1px solid currentColor;font-size:9px;font-weight:700}.detail-summary .avatar{margin-left:4px}.detail-summary strong{font-size:11px}.description{padding:15px 0;border-bottom:1px solid var(--color-border-subtle)}.description h3{margin:0 0 9px;font-size:11px}.description p{margin:0;white-space:pre-wrap;color:var(--color-ink-muted);font-size:11px;line-height:1.55}.description p.muted{color:var(--color-ink-faint)}.detail-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:16px}.detail-action{width:100%;height:32px;padding:0 8px}.detail-action.primary-action{color:#63c783;border-color:#63c783}.detail-action.danger-action{color:#e0737b;border-color:color-mix(in srgb,#e0737b 75%,var(--color-border))}.open{color:#63c783}.draft{color:var(--color-ink-muted)}.merged{color:#b785e8}.closed{color:#e0737b}
.loading-state,.list-empty,.empty-state{display:flex;min-height:180px;align-items:center;justify-content:center;gap:8px;color:var(--color-ink-muted)}.list-empty,.empty-state{flex-direction:column}.list-empty strong{color:var(--color-ink);font-size:12px}.list-empty span{font-size:10px}.empty-state{flex:1;text-align:center}.empty-state h2{margin:4px 0 0;font-size:14px}.empty-state p{max-width:430px;margin:0 0 8px;line-height:1.5}.empty-icon{display:grid;width:42px;height:42px;place-items:center;border:1px solid var(--color-border);color:var(--color-accent);background:var(--color-surface)}.primary{display:inline-flex;min-height:30px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-primary);color:#fff;background:var(--color-primary)} .loading-state,.list-empty,.empty-state{display:flex;min-height:180px;align-items:center;justify-content:center;gap:8px;color:var(--color-ink-muted)}.list-empty,.empty-state{flex-direction:column}.list-empty strong{color:var(--color-ink);font-size:12px}.list-empty span{font-size:10px}.empty-state{flex:1;text-align:center}.empty-state h2{margin:4px 0 0;font-size:14px}.empty-state p{max-width:430px;margin:0 0 8px;line-height:1.5}.empty-icon{display:grid;width:42px;height:42px;place-items:center;border:1px solid var(--color-border);color:var(--color-accent);background:var(--color-surface)}.primary{display:inline-flex;min-height:30px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-primary);color:#fff;background:var(--color-primary)}
@media(max-width:1100px){.review-layout.inspector-open{grid-template-columns:minmax(500px,1fr) 360px}.table-head,.request-row{grid-template-columns:78px minmax(230px,1.5fr) minmax(180px,1fr) 90px 170px}.table-head>span:nth-child(3),.request-author{display:none}.request-groups{min-width:760px}} @media(max-width:1100px){.review-layout.inspector-open{grid-template-columns:minmax(500px,1fr) 360px}.table-head,.request-row{grid-template-columns:78px minmax(230px,1.5fr) minmax(180px,1fr) 90px 170px}.table-head>span:nth-child(3),.request-author{display:none}.request-groups{min-width:760px}}
+18
View File
@@ -710,3 +710,21 @@ export function createIntegrationReviewRequest(provider: GitIntegrationProvider,
export function listIntegrationRepositoryBranches(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: GitIntegrationRepository): Promise<{branches: string[]; defaultBranch: string}> { 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}); return invoke("list_integration_repository_branches", {provider, baseUrl, username, token, repository});
} }
export function listAzureIssueStates(baseUrl: string, username: string, token: string, repository: string, number: number): Promise<string[]> {
return invoke("list_azure_issue_states", { baseUrl, username, token, repository, number });
}
export function setAzureIssueState(baseUrl: string, username: string, token: string, repository: string, number: number, state: string): Promise<string> {
return invoke("set_azure_issue_state", { baseUrl, username, token, repository, number, state });
}
export function listAzureIssueProjects(baseUrl: string, username: string, token: string): Promise<string[]> {
return invoke("list_azure_issue_projects", { baseUrl, username, token });
}
export function listAzureIssueTypes(baseUrl: string, username: string, token: string, project: string): Promise<string[]> {
return invoke("list_azure_issue_types", { baseUrl, username, token, project });
}
export function createIntegrationIssue(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, title: string, description: string, workItemType: string): Promise<import("./types").IntegrationIssue> {
return invoke("create_integration_issue", { provider, baseUrl, username, token, repository, title, description, workItemType });
}
+40 -35
View File
@@ -21,8 +21,8 @@
.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-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 {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-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-symbol {display:grid;place-items:center;flex-shrink:0;width:34px;height:34px;color:var(--color-accent);border:1px solid color-mix(in srgb,var(--color-accent) 55%,transparent);border-radius:0;background:color-mix(in srgb,var(--color-accent) 5%,transparent)}
.issue-center .workspace-button {display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:32px;padding:6px 10px;white-space:nowrap;border:1px solid var(--color-border);border-radius:4px;background:transparent;color:var(--color-ink-muted);box-shadow:none} .issue-center .workspace-button {display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:32px;padding:6px 10px;white-space:nowrap;border:1px solid var(--color-border);border-radius:0;background:transparent;color:var(--color-ink-muted);box-shadow:none}
.issue-center .workspace-button:hover:not(:disabled) {background:var(--color-surface-hover);border-color:var(--color-border-input);color:var(--color-ink)} .issue-center .workspace-button: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.active {color:var(--color-accent)}
.issue-center .workspace-button svg {flex-shrink:0} .issue-center .workspace-button svg {flex-shrink:0}
@@ -34,13 +34,13 @@
.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.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 .view-toggle button:hover {color:var(--color-ink)}
.issue-center .source-select {width:190px;flex:0 1 190px} .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 .source-select button,.issue-center .state-select button,.issue-center .board-select button,.issue-center .repository-select button {border-radius:0;font-size:12px;font-weight:400;min-height:32px;background:transparent}
.issue-center .workspace-search {display:flex;align-items:center;gap:9px;min-width:0;height:34px;padding:0 10px;border:1px solid var(--color-border);border-radius:4px;color:var(--issue-muted);background:color-mix(in srgb,var(--color-surface) 35%,transparent)} .issue-center .workspace-search {display:flex;align-items:center;gap:9px;min-width:0;height:34px;padding:0 10px;border:1px solid var(--color-border);border-radius:0;color:var(--issue-muted);background:color-mix(in srgb,var(--color-surface) 35%,transparent)}
.issue-center .workspace-search:focus-within {border-color:var(--color-accent)} .issue-center .workspace-search:focus-within {border-color:var(--color-accent)}
.issue-center .workspace-search svg {flex-shrink:0} .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 .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 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 {margin:0 24px 12px;padding:10px 12px;border:1px solid var(--issue-line);border-radius:0;color:var(--color-ink-muted);font-size:12px;overflow-wrap:anywhere}
.issue-center .workspace-notice.error {border-color:color-mix(in srgb,#d6a555 45%,transparent);background:color-mix(in srgb,#d6a555 4%,transparent)} .issue-center .workspace-notice.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 .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-content {display:flex;flex:1;min-height:0;min-width:0;position:relative;overflow:hidden}
@@ -50,7 +50,7 @@
.issue-center .state-select {flex:0 0 150px;width:150px} .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-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,.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-table-heading {min-height:32px;padding:0 14px 0 16px;color:var(--issue-muted);font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:.65px;background:color-mix(in srgb,var(--color-surface) 35%,transparent);border-radius:0;border-bottom:1px solid var(--issue-line);position:sticky;top:0;z-index:1}
.issue-center .issue-row {position:relative;width:100%;min-height:56px;padding:8px 14px 8px 16px;border:0;border-bottom:1px solid var(--issue-line);border-radius:0;color:var(--color-ink);background:transparent;box-shadow:none} .issue-center .issue-row {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: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-row.selected {background:color-mix(in srgb,var(--color-accent) 9%,transparent);box-shadow:inset 2px 0 var(--color-accent)}
@@ -73,7 +73,7 @@
.issue-center .person-name {min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .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 .unassigned {color:var(--color-ink-faint)}
.issue-center .issue-labels {display:flex;flex-wrap:wrap;gap:4px;min-width:0} .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 {padding:2px 6px;border-radius:0;font-size:10px;line-height:1.5;letter-spacing:.1px;color:var(--color-ink-muted);background:var(--color-surface-dim);overflow-wrap:anywhere;font-weight:400}
.issue-center .issue-label[data-tone="red"] {background:#9e3d43;color:#fff0f1} .issue-center .issue-label[data-tone="red"] {background:#9e3d43;color:#fff0f1}
.issue-center .issue-label[data-tone="blue"] {background:#3e5699;color:#eef2ff} .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="red"] {color:#972e3c;background:#fbe5e8}
@@ -94,36 +94,31 @@
.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 {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 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 .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 .empty-symbol {display:grid;place-items:center;width:52px;height:52px;margin-bottom:6px;border:1px solid var(--issue-line);border-radius:0;color:var(--issue-muted)}
.issue-center .board-view {display:flex;flex:1;flex-direction:column;min-height:0;min-width:0;overflow:hidden} .issue-center .board-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-header {display:flex;align-items:center;justify-content:space-between;gap:20px;padding:12px 24px 10px;flex-shrink:0}
.issue-center .board-title {min-width:0} .issue-center .board-title {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 h2 {font-size:16px;line-height:1.4;font-weight:600;letter-spacing:-.25px}
.issue-center .board-title small {display:block;margin-top:4px} .issue-center .board-title small {display:block;margin-top:4px}
.issue-center .board-title small>span {padding:0 5px} .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 {display:flex;align-items:center;gap:10px;flex-shrink:0}
.issue-center .board-tools .workspace-search {width:240px} .issue-center .board-tools .workspace-search {width:360px;max-width:100%}
.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-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-body {display:flex;flex:1;min-height:0;overflow:hidden}
.issue-center .board-columns {display:flex;flex:1;min-width:0;gap:14px;padding:0 24px 20px;overflow:auto;align-items:stretch} .issue-center .board-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 {display:flex;flex:1 0 278px;max-width:400px;min-width:0;flex-direction:column;border:1px solid var(--issue-line);border-radius:0;background:transparent;overflow:hidden}
.issue-center .board-column>header {display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 12px 12px;flex-shrink:0} .issue-center .board-column>header {display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:34px;padding:8px 14px;flex-shrink:0;border-bottom:1px solid var(--issue-line);background:color-mix(in srgb,var(--color-surface) 35%,transparent)}
.issue-center .board-column h3 {display:flex;align-items:center;gap:9px;min-width:0;font-size:12px;font-weight:600;overflow-wrap:anywhere} .issue-center .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-dot {width:13px;height:13px;flex:0 0 13px;border:2px solid var(--status-color);border-radius:50%;background:transparent}
.issue-center .column-count {color:var(--issue-muted);font-size:11px;white-space:nowrap} .issue-center .column-count {color:var(--issue-muted);font-size:11px;white-space:nowrap}
.issue-center .wip-limit {font-size:10px} .issue-center .wip-limit {font-size:10px}
.issue-center .column-cards {padding:0 10px 10px;min-height:0;overflow:auto;flex:1} .issue-center .column-cards {display:flex;flex-direction:column;gap:8px;padding:10px;min-height:0;overflow:auto;flex:1}
.issue-center .board-card {display:flex;flex-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 {display:flex;flex-shrink:0;flex-direction:column;align-items:flex-start;gap:5px;width:100%;text-align:left;padding:9px 11px;margin:0;border:1px solid var(--issue-line);border-radius:0;box-shadow:0 1px 2px rgb(0 0 0 / 12%);background:var(--color-surface);color:var(--color-ink);overflow-wrap:anywhere}
.issue-center .board-card:hover {border-color:var(--color-border-input);background:color-mix(in srgb,var(--color-surface) 55%,var(--issue-canvas))} .issue-center .board-card:hover {background:var(--color-surface-hover);border-color:color-mix(in srgb,var(--color-ink-dim) 55%,var(--issue-line))}
.issue-center .board-card.chosen {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.chosen {background:color-mix(in srgb,var(--color-accent) 9%,var(--color-surface));border-color:var(--color-accent);box-shadow:inset 2px 0 var(--color-accent)}
.issue-center .board-card strong {font-size:13px;line-height:1.5;font-weight:550} .issue-center .board-card strong {font-size:13px;line-height:1.45;font-weight:550}
.issue-center .card-reference {display:flex;align-items:center;flex-wrap:wrap;gap:8px;font-size:11px;line-height:1.4} .issue-center .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-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 .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} .issue-center .column-empty {padding:28px 12px;text-align:center;color:var(--color-ink-faint);font-size:11px}
@media(min-width:1250px) { @media(min-width:1250px) {
@@ -151,7 +146,7 @@
.issue-center .issue-footer {padding:12px 16px;font-size:10px} .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 .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 .with-details .issue-main {visibility:hidden}
.issue-center .board-header,.issue-center .board-connect {padding:16px} .issue-center .board-header {padding:16px}
.issue-center .board-tools {flex-wrap:wrap;gap:8px} .issue-center .board-tools {flex-wrap:wrap;gap:8px}
.issue-center .board-tools .workspace-search {flex:1 1 100%;height:34px} .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-columns {padding:0 16px 16px;gap:12px}
@@ -171,19 +166,19 @@
.issue-center .issue-status {font-size:11px;gap:6px} .issue-center .issue-status {font-size:11px;gap:6px}
.issue-center .issue-toolbar .workspace-search {flex-basis:100%} .issue-center .issue-toolbar .workspace-search {flex-basis:100%}
.issue-center .board-tools>.workspace-button {font-size:11px;padding:6px 8px} .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}} @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(.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-button,.workspace-search,.issue-label,.source-select button,.state-select button) {border-radius:0}
.issue-center :is(.workspace-symbol,.board-column,.board-card) {border-radius:6px !important} .issue-center .workspace-symbol {border-radius:0}
.issue-center .empty-symbol {border-radius:12px !important} .issue-center .board-column {border-radius:0}
.issue-center .board-card {border-radius:0}
.issue-center .empty-symbol {border-radius:0}
.issue-center .repository-select {width:190px;flex:0 1 190px;min-width:130px} .issue-center .repository-select {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-select button {font-size:12px;min-height:32px;border-radius:0}
/* Both tabs, including their shared navigation and dropdowns, use square corners. */
.issue-center :not(.status-dot):not(.column-dot) {border-radius:0 !important}
.issue-center .repository-group-heading {display:flex;align-items:center;gap:9px;padding:14px 16px 9px;color:var(--color-ink-muted);border-bottom:1px solid var(--issue-line)} .issue-center .repository-group-heading {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 strong {overflow-wrap:anywhere}
.issue-center .repository-group-heading>span {margin-left:auto;color:var(--issue-muted);font-size:11px} .issue-center .repository-group-heading>span {margin-left:auto;color:var(--issue-muted);font-size:11px}
@@ -251,3 +246,13 @@
.issue-center .comment-error {color:#e0737b;font-size:11px;overflow-wrap:anywhere} .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} .issue-center .issue-close-action {width:100%;min-height:39px;margin-bottom:10px;color:#e0737b;border-color:#a74b55;font-size:11px}
@media(max-width:800px) {
.issue-center .board-directory {padding:14px 16px 10px;gap:8px}
.issue-center .board-discovery-notice {padding:8px 16px}
}
.issue-center .issue-state-action {width:100%;margin-top:8px;justify-content:center}
.issue-center .workspace-button.issue-create-button {margin-left:auto;flex-shrink:0;background:var(--color-primary);border-color:var(--color-primary);color:#fff}
.issue-center .workspace-button.issue-create-button:hover:not(:disabled) {background:var(--color-primary);border-color:var(--color-primary);color:#fff;filter:brightness(1.08)}
+12
View File
@@ -0,0 +1,12 @@
// Optional UI preferences must never prevent the workspace from opening.
export function readWorkspacePreferences(key: string): Record<string, string> {
try {
const value: unknown = JSON.parse(localStorage.getItem(key) ?? "{}");
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
} catch { return {}; }
}
export function writeWorkspacePreferences(key: string, value: Record<string, string>) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* Storage may be unavailable or full. */ }
}
+42
View File
@@ -0,0 +1,42 @@
export interface Workspace {
id: string;
name: string;
repositories: string[];
openPaths: string[];
activePath: string;
}
export interface WorkspaceState {
selectedId: string;
workspaces: Workspace[];
defaultSession: { openPaths: string[]; activePath: string };
}
export const WORKSPACES_KEY = "gitlite.workspaces.v1";
const paths = (value: unknown): string[] => Array.isArray(value)
? [...new Set(value.filter((path): path is string => typeof path === "string" && !!path.trim()))] : [];
export function readWorkspaces(storage: Pick<Storage, "getItem">, openPaths: string[]): WorkspaceState {
const fallback: WorkspaceState = { selectedId: "", workspaces: [], defaultSession: { openPaths, activePath: "" } };
try {
const raw = storage.getItem(WORKSPACES_KEY);
if (raw) {
const saved = JSON.parse(raw);
const workspaces: Workspace[] = Array.isArray(saved.workspaces) ? saved.workspaces.flatMap((item: any) => {
if (!item || typeof item.id !== "string" || !item.id.startsWith("workspace-") || typeof item.name !== "string" || !item.name.trim()) return [];
const repositories = paths(item.repositories);
const openPaths = paths(item.openPaths).filter(path => repositories.includes(path));
return [{ id: item.id, name: item.name, repositories, openPaths,
activePath: openPaths.includes(item.activePath) ? item.activePath : openPaths[0] ?? "" }];
}) : [];
const defaultPaths = paths(saved.defaultSession?.openPaths);
return { workspaces, selectedId: workspaces.some(item => item.id === saved.selectedId) ? saved.selectedId : "",
defaultSession: { openPaths: defaultPaths, activePath: defaultPaths.includes(saved.defaultSession?.activePath) ? saved.defaultSession.activePath : defaultPaths[0] ?? "" } };
}
const legacy = JSON.parse(storage.getItem("gitty.dashboard.v1") ?? "{}");
if (Array.isArray(legacy.workspaces)) fallback.workspaces = legacy.workspaces.flatMap((item: any) => {
if (!item || typeof item.id !== "string" || !item.id.startsWith("workspace-") || typeof item.name !== "string" || !item.name.trim()) return [];
const repositories = Object.entries(legacy.assignments ?? {}).filter(([, id]) => id === item.id).map(([path]) => path);
const tabs = openPaths.filter(path => repositories.includes(path));
return [{ ...item, repositories, openPaths: tabs, activePath: tabs[0] ?? "" }];
});
} catch { /* Invalid optional preferences must not block startup. */ }
return fallback;
}