diff --git a/AGENTS.md b/AGENTS.md index 31a828c..2b2ddc2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,21 @@ # Repository instructions +## Required pre-commit gates + +Run every gate below from the repository root before committing. Every command +must succeed; do not commit code that is unformatted, fails Clippy, or compiles +with warnings. + +```sh +cargo fmt --all -- --check +RUSTFLAGS="-D warnings" cargo check --workspace --all-targets +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +``` + +Do not weaken or skip these gates to make a commit pass. Fix the underlying +warning, lint, formatting issue, or test failure. + ## iOS simulator build and deployment This Apple Silicon project builds the simulator app for `arm64`. Do not disable diff --git a/Cargo.lock b/Cargo.lock index 567ff5c..583a83d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1476,6 +1476,7 @@ name = "gotcha-app" version = "0.1.0" dependencies = [ "gotcha_gitea", + "pulldown-cmark", "security-framework", "serde", "serde_json", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index a576f7e..18b391e 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -12,6 +12,7 @@ path = "src/main.rs" [dependencies] gotcha_gitea = { path = "../gitea" } +pulldown-cmark = "0.13" serde.workspace = true serde_json.workspace = true security-framework = "3" diff --git a/crates/app/src/activity.rs b/crates/app/src/activity.rs new file mode 100644 index 0000000..6c77ea6 --- /dev/null +++ b/crates/app/src/activity.rs @@ -0,0 +1,156 @@ +use gotcha_gitea::models; + +#[derive(Debug, Eq, PartialEq)] +pub enum Target { + Commit { + owner: String, + repository: String, + sha: String, + }, + Issue { + owner: String, + repository: String, + number: i64, + }, + Pull { + owner: String, + repository: String, + number: i64, + }, +} + +pub fn target(activity: &models::Activity) -> Option { + use models::activity::OpType; + + let (owner, repository) = activity + .repo + .as_ref()? + .full_name + .as_deref()? + .split_once('/')?; + let pair = || (owner.to_string(), repository.to_string()); + match activity.op_type? { + OpType::CommitRepo | OpType::MirrorSyncPush => { + let (owner, repository) = pair(); + Some(Target::Commit { + owner, + repository, + sha: commit_sha(activity.content.as_deref()?)?, + }) + } + OpType::CreateIssue | OpType::CloseIssue | OpType::ReopenIssue | OpType::CommentIssue => { + let (owner, repository) = pair(); + Some(Target::Issue { + owner, + repository, + number: issue_number(activity)?, + }) + } + OpType::CreatePullRequest + | OpType::MergePullRequest + | OpType::AutoMergePullRequest + | OpType::ClosePullRequest + | OpType::ReopenPullRequest + | OpType::CommentPull + | OpType::ApprovePullRequest + | OpType::RejectPullRequest + | OpType::PullReviewDismissed + | OpType::PullRequestReadyForReview => { + let (owner, repository) = pair(); + Some(Target::Pull { + owner, + repository, + number: issue_number(activity)?, + }) + } + _ => None, + } +} + +fn issue_number(activity: &models::Activity) -> Option { + activity + .comment + .as_ref() + .and_then(|comment| { + comment + .pull_request_url + .as_deref() + .or(comment.issue_url.as_deref()) + }) + .and_then(url_number) + .or_else(|| { + activity + .content + .as_deref()? + .split(|character: char| !character.is_ascii_digit()) + .find(|part| !part.is_empty())? + .parse() + .ok() + }) +} + +fn url_number(url: &str) -> Option { + url.trim_end_matches('/').rsplit('/').next()?.parse().ok() +} + +fn commit_sha(content: &str) -> Option { + let payload: serde_json::Value = serde_json::from_str(content).ok()?; + payload.get("HeadCommit").and_then(find_sha).or_else(|| { + payload + .get("Commits")? + .as_array()? + .last() + .and_then(find_sha) + }) +} + +fn find_sha(value: &serde_json::Value) -> Option { + value.as_object()?.iter().find_map(|(key, value)| { + (key.to_ascii_lowercase().contains("sha")) + .then(|| value.as_str()) + .flatten() + .filter(|sha| sha.len() >= 7) + .map(str::to_string) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use models::activity::OpType; + + fn activity(op_type: OpType, content: &str) -> models::Activity { + models::Activity { + op_type: Some(op_type), + content: Some(content.into()), + repo: Some(Box::new(models::Repository { + full_name: Some("octo/demo".into()), + ..Default::default() + })), + ..Default::default() + } + } + + #[test] + fn decodes_linkable_activity_targets() { + assert_eq!( + target(&activity(OpType::CreatePullRequest, "42|feature")), + Some(Target::Pull { + owner: "octo".into(), + repository: "demo".into(), + number: 42, + }) + ); + assert_eq!( + target(&activity( + OpType::CommitRepo, + r#"{"HeadCommit":{"Sha1":"0123456789abcdef"}}"#, + )), + Some(Target::Commit { + owner: "octo".into(), + repository: "demo".into(), + sha: "0123456789abcdef".into(), + }) + ); + } +} diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs new file mode 100644 index 0000000..a6c0ab7 --- /dev/null +++ b/crates/app/src/api.rs @@ -0,0 +1,170 @@ +use gotcha_gitea::{Client, apis, models}; + +use crate::{ + domain::{HomeData, RepositoryData, Server}, + view::compact_date, +}; + +pub async fn load_repositories(server: &Server) -> Result, String> { + let client = + Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; + let configuration = client.configuration(); + let user = client + .current_user() + .await + .map_err(|error| error.to_string())?; + let login = user.login.unwrap_or_default(); + let repositories = apis::user_api::user_current_list_repos(&configuration, Some(1), Some(100)) + .await + .map_err(|error| error.to_string())?; + Ok(repositories + .into_iter() + .filter(|repository| { + repository + .owner + .as_ref() + .and_then(|owner| owner.login.as_deref()) + == Some(login.as_str()) + }) + .filter_map(|repository| { + Some(RepositoryData { + owner: repository.owner?.login?, + name: repository.name?, + description: repository + .description + .filter(|text| !text.is_empty()) + .unwrap_or_else(|| "No description".into()), + language: repository + .language + .filter(|text| !text.is_empty()) + .unwrap_or_else(|| "Unknown language".into()), + open_issues: repository.open_issues_count.unwrap_or_default(), + updated: compact_date(repository.updated_at.as_deref()), + }) + }) + .collect()) +} + +pub async fn load_issues( + server: &Server, + owner: &str, + repository: &str, + status: &str, +) -> Result, String> { + let client = + Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; + apis::issue_api::issue_list_issues( + &client.configuration(), + owner, + repository, + Some(status), + None, + None, + Some("issues"), + None, + None, + None, + None, + None, + None, + Some(1), + Some(100), + ) + .await + .map_err(|error| error.to_string()) +} + +pub async fn load_pulls(server: &Server, status: &str) -> Result, String> { + let client = + Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; + let configuration = client.configuration(); + let owner = client + .current_user() + .await + .map_err(|error| error.to_string())? + .login + .ok_or("The server account has no username.")?; + let mut pulls = Vec::new(); + for page in 1.. { + let batch = apis::issue_api::issue_search_issues( + &configuration, + Some(status), + None, + None, + None, + None, + Some("pulls"), + None, + None, + None, + None, + None, + None, + None, + Some(&owner), + None, + Some(page), + Some(100), + ) + .await + .map_err(|error| error.to_string())?; + if batch.is_empty() { + break; + } + pulls.extend(batch); + } + Ok(pulls) +} + +pub async fn load_commits( + server: &Server, + owner: &str, + repository: &str, +) -> Result, String> { + let client = + Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; + apis::repository_api::repo_get_all_commits( + &client.configuration(), + owner, + repository, + None, + None, + None, + None, + None, + None, + Some(false), + Some(1), + Some(100), + None, + ) + .await + .map_err(|error| error.to_string()) +} + +pub async fn load_home(server: &Server) -> Result { + let client = + Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; + let configuration = client.configuration(); + let login = client + .current_user() + .await + .map_err(|error| error.to_string())? + .login + .ok_or("The server account has no username.")?; + let (activities, heatmap) = tokio::join!( + apis::user_api::user_list_activity_feeds( + &configuration, + &login, + Some(true), + None, + Some(1), + Some(40), + ), + apis::user_api::user_get_heatmap_data(&configuration, &login), + ); + Ok(HomeData { + activities: activities.map_err(|error| error.to_string())?, + heatmap: heatmap.map_err(|error| error.to_string())?, + }) +} diff --git a/crates/app/src/diff.rs b/crates/app/src/diff.rs new file mode 100644 index 0000000..ab7948e --- /dev/null +++ b/crates/app/src/diff.rs @@ -0,0 +1,190 @@ +#[derive(Debug, Eq, PartialEq)] +pub struct Line { + pub old_number: String, + pub new_number: String, + pub text: String, + pub kind: &'static str, +} + +pub struct Parsed { + pub lines: Vec, + pub columns: usize, +} + +pub fn parse_file(diff: &str, path: &str) -> Parsed { + let section = sections(diff) + .into_iter() + .find(|section| section_path(section).as_deref() == Some(path)) + .unwrap_or(""); + if section.is_empty() { + let text = format!("No diff data is available for {path}."); + return Parsed { + columns: text.chars().count(), + lines: vec![Line { + old_number: String::new(), + new_number: String::new(), + text, + kind: "header", + }], + }; + } + let mut old = 0; + let mut new = 0; + let mut columns = 0; + let lines = section + .lines() + .map(|text| { + columns = columns.max(display_columns(text)); + let (old_number, new_number, kind) = if text.starts_with("@@") { + if let Some((old_start, new_start)) = hunk_starts(text) { + old = old_start; + new = new_start; + } + (String::new(), String::new(), "hunk") + } else if text.starts_with('+') && !text.starts_with("+++") { + let number = new.to_string(); + new += 1; + (String::new(), number, "addition") + } else if text.starts_with('-') && !text.starts_with("---") { + let number = old.to_string(); + old += 1; + (number, String::new(), "removal") + } else if text.starts_with(' ') { + let numbers = (old.to_string(), new.to_string()); + old += 1; + new += 1; + (numbers.0, numbers.1, "context") + } else { + (String::new(), String::new(), "header") + }; + Line { + old_number, + new_number, + text: text.into(), + kind, + } + }) + .collect(); + Parsed { lines, columns } +} + +fn sections(diff: &str) -> Vec<&str> { + let mut starts: Vec<_> = diff + .match_indices("diff --git ") + .map(|(index, _)| index) + .collect(); + starts.push(diff.len()); + starts + .windows(2) + .map(move |bounds| &diff[bounds[0]..bounds[1]]) + .collect() +} + +fn section_path(section: &str) -> Option { + section + .lines() + .find_map(|line| line.strip_prefix("+++ ")) + .filter(|path| *path != "/dev/null") + .or_else(|| { + section + .lines() + .find_map(|line| line.strip_prefix("--- ")) + .filter(|path| *path != "/dev/null") + }) + .map(|path| { + decode_path(path) + .trim_start_matches("a/") + .trim_start_matches("b/") + .into() + }) +} + +fn decode_path(path: &str) -> String { + let path = path.trim_matches('"'); + let mut bytes = Vec::with_capacity(path.len()); + let mut chars = path.bytes(); + while let Some(byte) = chars.next() { + if byte != b'\\' { + bytes.push(byte); + continue; + } + let Some(escaped) = chars.next() else { break }; + match escaped { + b'n' => bytes.push(b'\n'), + b'r' => bytes.push(b'\r'), + b't' => bytes.push(b'\t'), + b'\\' | b'"' => bytes.push(escaped), + b'0'..=b'7' => { + let mut value = escaped - b'0'; + for _ in 0..2 { + let Some(next) = chars.next() else { break }; + if !(b'0'..=b'7').contains(&next) { + bytes.push(value); + bytes.push(next); + value = 0; + break; + } + value = value * 8 + next - b'0'; + } + if value != 0 { + bytes.push(value); + } + } + other => bytes.push(other), + } + } + String::from_utf8_lossy(&bytes).into_owned() +} + +fn hunk_starts(line: &str) -> Option<(i32, i32)> { + let mut parts = line.split_whitespace(); + (parts.next()? == "@@").then_some(())?; + Some(( + range_start(parts.next()?, '-'), + range_start(parts.next()?, '+'), + )) +} + +fn range_start(value: &str, prefix: char) -> i32 { + value + .strip_prefix(prefix) + .and_then(|value| value.split(',').next()) + .and_then(|value| value.parse().ok()) + .unwrap_or_default() +} + +fn display_columns(line: &str) -> usize { + line.chars().fold(0, |column, character| { + if character == '\t' { + (column / 8 + 1) * 8 + } else { + column + 1 + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selects_and_numbers_a_file_diff() { + let diff = "diff --git a/one b/one\n--- a/one\n+++ b/one\n@@ -1 +1 @@\n-old\n+new\n\ +diff --git a/two b/two\n--- a/two\n+++ b/two\n@@ -4,2 +8,3 @@\n context\n-removed\n+added\n"; + let parsed = parse_file(diff, "two"); + assert_eq!(parsed.lines[4].old_number, "4"); + assert_eq!(parsed.lines[4].new_number, "8"); + assert_eq!(parsed.lines[5].kind, "removal"); + assert_eq!(parsed.lines[6].kind, "addition"); + assert!(parsed.lines.iter().all(|line| !line.text.contains("old"))); + } + + #[test] + fn decodes_git_quoted_paths() { + let diff = "diff --git \"a/caf\\303\\251\" \"b/caf\\303\\251\"\n--- \"a/caf\\303\\251\"\n+++ \"b/caf\\303\\251\"\n@@ -0,0 +1 @@\n+hello\n"; + assert_eq!( + parse_file(diff, "café").lines.last().unwrap().kind, + "addition" + ); + } +} diff --git a/crates/app/src/domain.rs b/crates/app/src/domain.rs new file mode 100644 index 0000000..cdf795a --- /dev/null +++ b/crates/app/src/domain.rs @@ -0,0 +1,82 @@ +use std::collections::BTreeSet; + +use gotcha_gitea::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Deserialize, Serialize)] +pub struct Server { + pub name: String, + pub url: String, + #[serde(skip)] + pub token: String, +} + +#[derive(Deserialize, Serialize)] +pub struct Preferences { + #[serde(default)] + pub servers: Vec, + #[serde(default)] + pub favorites: BTreeSet, + #[serde(default)] + pub last_server: Option, + #[serde(default = "open_status")] + pub issue_status: String, + #[serde(default = "open_status")] + pub pull_status: String, +} + +impl Default for Preferences { + fn default() -> Self { + Self { + servers: Vec::new(), + favorites: BTreeSet::new(), + last_server: None, + issue_status: open_status(), + pull_status: open_status(), + } + } +} + +#[derive(Clone)] +pub struct RepositoryData { + pub owner: String, + pub name: String, + pub description: String, + pub language: String, + pub open_issues: i64, + pub updated: String, +} + +#[derive(Default)] +pub struct State { + pub preferences: Preferences, + pub active_server: Option, + pub active_repository: Option<(String, String)>, + pub active_issue: Option, + pub active_pull: Option<(String, String, i64)>, + pub active_commit: Option, + pub opened_from_home: bool, + pub repositories: Vec, + pub issues: Vec, + pub pulls: Vec, + pub commits: Vec, +} + +pub struct HomeData { + pub activities: Vec, + pub heatmap: Vec, +} + +pub struct IssueDetails { + pub issue: models::Issue, + pub comments: Vec, +} + +pub struct PullDetails { + pub pull: models::PullRequest, + pub comments: Vec, +} + +pub fn open_status() -> String { + "open".into() +} diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 19a5ebe..bd14492 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -1,485 +1,25 @@ use std::{ - collections::BTreeSet, - env, fs, future::Future, - path::PathBuf, sync::{Arc, Mutex, OnceLock}, }; -use gotcha_gitea::{Client, apis, models}; -use security_framework::passwords::{get_generic_password, set_generic_password}; -use serde::{Deserialize, Serialize}; -use slint::{Color, ModelRc, VecModel}; +use gotcha_gitea::{Client, apis}; +use slint::{ModelRc, VecModel}; -slint::slint! { - import { Button, LineEdit, ListView, ScrollView, Spinner } from "std-widgets.slint"; +mod activity; +mod api; +mod diff; +mod domain; +mod markdown; +mod storage; +mod ui; +mod view; - export struct ServerRow { name: string, url: string } - export struct RepositoryRow { - name: string, owner: string, description: string, meta: string, favorite: bool, - } - export struct LabelRow { name: string, background: color, foreground: color } - export struct IssueRow { - number: int, title: string, summary: string, meta: string, labels: [LabelRow], - } - export struct ActivityRow { icon: string, title: string, detail: string, meta: string } - export struct HeatCell { week: int, day: int, level: int } - - component Header inherits Rectangle { - in property title; - in property can_back; - callback back(); - height: 58px; - width: 100%; - background: #f8f8fa; - border-color: #dedee3; - border-width: 0px; - - if can_back: TouchArea { - x: 8px; width: 54px; - clicked => { root.back(); } - Text { text: "‹"; color: #0879e1; font-size: 38px; vertical-alignment: center; } - } - Text { - text: root.title; - horizontal-alignment: center; - vertical-alignment: center; - font-size: 18px; - font-weight: 600; - color: #151518; - } - Rectangle { y: parent.height - 1px; height: 1px; background: #dedee3; } - } - - component EdgeBack inherits SwipeGestureHandler { - callback back(); - width: 24px; - height: 100%; - handle-swipe-right: true; - swiped => { root.back(); } - } - - component EmptyState inherits VerticalLayout { - in property title; - in property detail; - alignment: center; - spacing: 8px; - Text { text: root.title; font-size: 21px; font-weight: 600; horizontal-alignment: center; color: #1c1c1e; } - Text { text: root.detail; font-size: 15px; horizontal-alignment: center; wrap: word-wrap; color: #69696f; } - } - - component TabButton inherits Rectangle { - in property icon; - in property label; - in property active; - callback clicked(); - background: transparent; - TouchArea { clicked => { root.clicked(); } } - Text { - y: 4px; height: 29px; width: 100%; text: root.icon; - color: root.active ? #0879e1 : #77777d; font-size: 24px; - horizontal-alignment: center; vertical-alignment: center; - } - Text { - y: 34px; height: 18px; width: 100%; text: root.label; - color: root.active ? #0879e1 : #77777d; font-size: 10px; - font-weight: root.active ? 600 : 400; - horizontal-alignment: center; vertical-alignment: center; - } - } - - component PullToRefreshGesture inherits SwipeGestureHandler { - in property at_top; - in property refreshing; - callback refresh(); - enabled: root.at_top && !root.refreshing; - handle-swipe-down: true; - swiped => { root.refresh(); } - @children - if self.swiping: Rectangle { - x: (parent.width - 142px) / 2; y: 8px; width: 142px; height: 32px; - background: #e9e9ee; border-radius: 16px; - Text { text: "Release to refresh"; font-size: 12px; color: #55555b; horizontal-alignment: center; vertical-alignment: center; } - } - } - - export component AppWindow inherits Window { - title: "Gotcha"; - preferred-width: 390px; - preferred-height: 844px; - background: #f2f2f7; - - in-out property tab: "home"; - in-out property page: "servers"; - in-out property <[ServerRow]> servers; - in-out property <[RepositoryRow]> repositories; - in-out property <[IssueRow]> issues; - in-out property <[ActivityRow]> activities; - in-out property <[HeatCell]> heat_cells; - in-out property contribution_count; - in-out property has_active_server; - in-out property home_server; - in-out property server_title; - in-out property repository_title; - in-out property issue_title; - in-out property issue_meta; - in-out property issue_body; - in-out property loading; - in-out property home_loading; - in-out property refreshing; - in-out property error; - in-out property home_scroll_request; - in-out property servers_scroll_request; - in-out property repositories_scroll_request; - in-out property issues_scroll_request; - in-out property issue_scroll_request; - private property home_at_top: true; - private property repositories_at_top: true; - private property issues_at_top: true; - private property issue_at_top: true; - - callback select_tab(string); - callback refresh_home(); - callback refresh_repositories(); - callback refresh_issues(); - callback refresh_issue(); - callback show_add_server(); - callback save_server(string, string, string); - callback select_server(int); - callback select_repository(string, string); - callback toggle_favorite(string, string); - callback select_issue(int); - callback back(); - - if tab == "home": Rectangle { - x: root.safe-area-insets.left; y: root.safe-area-insets.top; - width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; - height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px; - background: #f2f2f7; - Header { x: 0; y: 0; title: root.has_active_server ? root.home_server : "Home"; can_back: false; } - - if !root.has_active_server: EmptyState { - x: 32px; width: parent.width - 64px; y: 210px; height: 190px; - title: "No server selected"; - detail: "Open Issues to select a server or add your first one."; - } - - if root.has_active_server: Rectangle { - x: 16px; y: 72px; width: parent.width - 32px; height: 118px; - background: #ffffff; border-radius: 12px; - Text { x: 13px; y: 10px; text: "Activity · last 12 months"; font-size: 13px; font-weight: 600; color: #38383d; } - Text { x: 13px; y: 86px; text: root.contribution_count + " contributions"; font-size: 12px; color: #74747a; } - for cell in root.heat_cells: Rectangle { - x: 13px + cell.week * 6.7px; y: 39px + cell.day * 6.2px; - width: 5.2px; height: 5.2px; border-radius: 1px; - background: cell.level == 0 ? #e5e5e9 : cell.level == 1 ? #b8d9f4 : cell.level == 2 ? #72b5e8 : cell.level == 3 ? #278bd4 : #0969b7; - } - } - - if root.has_active_server && !root.home_loading && root.activities.length == 0: EmptyState { - x: 32px; width: parent.width - 64px; y: 250px; height: 150px; - title: "No recent activity"; - detail: "Your recent server actions will appear here."; - } - - if root.has_active_server: PullToRefreshGesture { - x: 16px; y: 202px; width: parent.width - 32px; height: parent.height - 214px; - at_top: root.home_at_top; refreshing: root.home_loading; - refresh => { root.refresh_home(); } - home_list := ListView { - property scroll_request: root.home_scroll_request; - width: 100%; height: 100%; mouse-drag-pan-enabled: true; - changed scroll_request => { self.viewport-y = 0px; root.home_at_top = true; } - scrolled => { root.home_at_top = self.viewport-y >= 0px; } - for activity in root.activities: Rectangle { - height: 94px; - background: #ffffff; border-radius: 12px; - Rectangle { - x: 12px; y: 14px; width: 34px; height: 34px; border-radius: 17px; - background: #e5f2fd; - Text { text: activity.icon; color: #0879e1; font-size: 20px; horizontal-alignment: center; vertical-alignment: center; } - } - Text { x: 57px; y: 11px; width: parent.width - 69px; height: 24px; text: activity.title; font-size: 15px; font-weight: 600; color: #19191d; overflow: elide; } - Text { x: 57px; y: 36px; width: parent.width - 69px; height: 35px; text: activity.detail; font-size: 13px; color: #55555c; wrap: word-wrap; overflow: elide; } - Text { x: 57px; y: 72px; width: parent.width - 69px; text: activity.meta; font-size: 11px; color: #898990; overflow: elide; } - } - } - } - - if root.home_loading: Spinner { width: 34px; height: 34px; x: (parent.width - self.width) / 2; y: 222px; } - } - - if tab == "repositories" || tab == "notifications" || tab == "settings": Rectangle { - x: root.safe-area-insets.left; y: root.safe-area-insets.top; - width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; - height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px; - background: #f2f2f7; - Header { - x: 0; y: 0; can_back: false; - title: root.tab == "repositories" ? "Repositories" : root.tab == "notifications" ? "Notifications" : "Settings"; - } - EmptyState { - x: 32px; width: parent.width - 64px; y: 220px; height: 160px; - title: "Coming soon"; - detail: "This section is ready for the next feature pass."; - } - } - - if tab == "issues" && page == "servers": Rectangle { - x: root.safe-area-insets.left; y: root.safe-area-insets.top; - width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; - height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px; - background: #f2f2f7; - Header { x: 0; y: 0; title: "Servers"; can_back: false; } - if root.servers.length > 0: servers_list := ListView { - property scroll_request: root.servers_scroll_request; - x: 16px; y: 76px; width: parent.width - 32px; height: parent.height - 154px; - mouse-drag-pan-enabled: true; - changed scroll_request => { self.viewport-y = 0px; } - for server[index] in root.servers: Rectangle { - height: 76px; - background: #ffffff; - border-color: #dedee3; - border-width: 1px; - border-radius: 12px; - TouchArea { clicked => { root.select_server(index); } } - Text { x: 16px; y: 13px; width: parent.width - 54px; text: server.name; font-size: 17px; font-weight: 600; color: #19191c; overflow: elide; } - Text { x: 16px; y: 42px; width: parent.width - 54px; text: server.url; font-size: 13px; color: #6c6c72; overflow: elide; } - Text { x: parent.width - 28px; text: "›"; font-size: 27px; color: #a0a0a6; vertical-alignment: center; } - } - } - Button { x: 16px; y: parent.height - 66px; width: parent.width - 32px; height: 48px; text: "Add Server"; clicked => { root.show_add_server(); } } - } - - if tab == "issues" && page == "add": Rectangle { - x: root.safe-area-insets.left; y: root.safe-area-insets.top; - width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; - height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px; - background: #f2f2f7; - Header { x: 0; y: 0; title: "Add Server"; can_back: true; back => { root.back(); } } - VerticalLayout { - x: 20px; y: 92px; width: parent.width - 40px; height: 260px; spacing: 10px; - Text { text: "Name"; font-size: 13px; color: #66666c; } - server_name := LineEdit { placeholder-text: "Work"; } - Text { text: "Server URL"; font-size: 13px; color: #66666c; } - server_url := LineEdit { placeholder-text: "https://gitea.example.com"; } - Text { text: "Access token"; font-size: 13px; color: #66666c; } - server_token := LineEdit { placeholder-text: "Token"; input-type: InputType.password; } - } - Button { - x: 20px; y: 380px; width: parent.width - 40px; height: 48px; - text: root.loading ? "Connecting…" : "Add Server"; - enabled: !root.loading; - clicked => { root.save_server(server_name.text, server_url.text, server_token.text); } - } - EdgeBack { x: 0; y: 0; back => { root.back(); } } - } - - if tab == "issues" && page == "repositories": Rectangle { - x: root.safe-area-insets.left; y: root.safe-area-insets.top; - width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; - height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px; - background: #f2f2f7; - Header { x: 0; y: 0; title: root.server_title; can_back: true; back => { root.back(); } } - if root.refreshing: Spinner { x: parent.width - 42px; y: 17px; width: 24px; height: 24px; } - if !root.loading && root.repositories.length == 0: EmptyState { - x: 34px; width: parent.width - 68px; y: 220px; height: 160px; - title: "No repositories"; - detail: "This account does not own any repositories on this server."; - } - PullToRefreshGesture { - x: 20px; y: 70px; width: parent.width - 40px; height: parent.height - 82px; - at_top: root.repositories_at_top; refreshing: root.refreshing; - refresh => { root.refresh_repositories(); } - repositories_list := ListView { - property scroll_request: root.repositories_scroll_request; - width: 100%; height: 100%; mouse-drag-pan-enabled: true; - changed scroll_request => { self.viewport-y = 0px; root.repositories_at_top = true; } - scrolled => { root.repositories_at_top = self.viewport-y >= 0px; } - for repo in root.repositories: Rectangle { - height: 104px; - background: #ffffff; - border-color: #dedee3; - border-width: 1px; - border-radius: 12px; - TouchArea { clicked => { root.select_repository(repo.owner, repo.name); } } - Text { x: 15px; y: 11px; width: parent.width - 62px; text: repo.name; font-size: 17px; font-weight: 600; color: #17171a; overflow: elide; } - Text { x: 15px; y: 39px; width: parent.width - 30px; text: repo.description; font-size: 14px; color: #56565c; overflow: elide; } - Text { x: 15px; y: 71px; width: parent.width - 30px; text: repo.meta; font-size: 12px; color: #7b7b82; overflow: elide; } - TouchArea { - x: parent.width - 52px; y: 2px; width: 48px; height: 48px; - clicked => { root.toggle_favorite(repo.owner, repo.name); } - Text { text: repo.favorite ? "★" : "☆"; color: repo.favorite ? #f2a900 : #888890; font-size: 27px; horizontal-alignment: center; vertical-alignment: center; } - } - } - } - } - EdgeBack { x: 0; y: 0; back => { root.back(); } } - } - - if tab == "issues" && page == "issues": Rectangle { - x: root.safe-area-insets.left; y: root.safe-area-insets.top; - width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; - height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px; - background: #f2f2f7; - Header { x: 0; y: 0; title: root.repository_title; can_back: true; back => { root.back(); } } - if root.refreshing: Spinner { x: parent.width - 42px; y: 17px; width: 24px; height: 24px; } - if !root.loading && root.issues.length == 0: EmptyState { - x: 34px; width: parent.width - 68px; y: 220px; height: 160px; - title: "No open issues"; - detail: "Everything is clear for this repository."; - } - PullToRefreshGesture { - x: 20px; y: 70px; width: parent.width - 40px; height: parent.height - 82px; - at_top: root.issues_at_top; refreshing: root.refreshing; - refresh => { root.refresh_issues(); } - issues_list := ListView { - property scroll_request: root.issues_scroll_request; - width: 100%; height: 100%; mouse-drag-pan-enabled: true; - changed scroll_request => { self.viewport-y = 0px; root.issues_at_top = true; } - scrolled => { root.issues_at_top = self.viewport-y >= 0px; } - for issue in root.issues: Rectangle { - height: issue.labels.length > 0 ? 138px : 112px; - background: #ffffff; - border-color: #dedee3; - border-width: 1px; - border-radius: 12px; - TouchArea { clicked => { root.select_issue(issue.number); } } - Text { x: 15px; y: 11px; width: parent.width - 44px; text: issue.title; font-size: 16px; font-weight: 600; color: #17171a; overflow: elide; } - Text { x: 15px; y: 39px; width: parent.width - 30px; height: 38px; text: issue.summary; font-size: 13px; color: #56565c; wrap: word-wrap; overflow: elide; } - if issue.labels.length > 0: HorizontalLayout { - x: 15px; y: 82px; width: parent.width - 30px; height: 22px; spacing: 5px; - for label in issue.labels: Rectangle { - width: min(104px, max(48px, (parent.width - (issue.labels.length - 1) * 5px) / issue.labels.length)); - height: 22px; background: label.background; border-radius: 11px; - Text { x: 7px; width: parent.width - 14px; text: label.name; color: label.foreground; font-size: 11px; font-weight: 600; horizontal-alignment: center; vertical-alignment: center; overflow: elide; } - } - } - Text { x: 15px; y: issue.labels.length > 0 ? 112px : 86px; width: parent.width - 30px; text: issue.meta; font-size: 12px; color: #7b7b82; overflow: elide; } - } - } - } - EdgeBack { x: 0; y: 0; back => { root.back(); } } - } - - if tab == "issues" && page == "issue": Rectangle { - x: root.safe-area-insets.left; y: root.safe-area-insets.top; - width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; - height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px; - background: #f2f2f7; - Header { x: 0; y: 0; title: "Issue"; can_back: true; back => { root.back(); } } - if root.refreshing: Spinner { x: parent.width - 42px; y: 17px; width: 24px; height: 24px; } - PullToRefreshGesture { - x: 18px; y: 78px; width: parent.width - 36px; height: parent.height - 96px; - at_top: root.issue_at_top; refreshing: root.refreshing; - refresh => { root.refresh_issue(); } - issue_scroll := ScrollView { - property scroll_request: root.issue_scroll_request; - width: 100%; height: 100%; mouse-drag-pan-enabled: true; - changed scroll_request => { self.viewport-y = 0px; root.issue_at_top = true; } - scrolled => { root.issue_at_top = self.viewport-y >= 0px; } - VerticalLayout { - width: parent.width; - spacing: 12px; - alignment: start; - Text { text: root.issue_title; font-size: 23px; font-weight: 650; color: #161619; wrap: word-wrap; } - Text { text: root.issue_meta; font-size: 13px; color: #707077; wrap: word-wrap; } - Rectangle { height: 1px; background: #d8d8dd; } - Text { text: root.issue_body; font-size: 16px; color: #252529; wrap: word-wrap; } - } - } - } - EdgeBack { x: 0; y: 0; back => { root.back(); } } - } - - Rectangle { - x: root.safe-area-insets.left; - y: root.height - root.safe-area-insets.bottom - 58px; - width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; - height: 58px; - background: #fbfbfc; - Rectangle { width: 100%; height: 1px; background: #d4d4d8; } - TabButton { - x: 0; width: parent.width / 5; height: parent.height; icon: "⌂"; label: "Home"; active: root.tab == "home"; - clicked => { - if root.tab == "home" { root.home_scroll_request += 1; } - root.select_tab("home"); - } - } - TabButton { - x: parent.width / 5; width: parent.width / 5; height: parent.height; icon: "!"; label: "Issues"; active: root.tab == "issues"; - clicked => { - if root.tab == "issues" { - if root.page == "servers" { root.servers_scroll_request += 1; } - if root.page == "repositories" { root.repositories_scroll_request += 1; } - if root.page == "issues" { root.issues_scroll_request += 1; } - if root.page == "issue" { root.issue_scroll_request += 1; } - } - root.select_tab("issues"); - } - } - TabButton { x: parent.width * 2 / 5; width: parent.width / 5; height: parent.height; icon: "▱"; label: "Repos"; active: root.tab == "repositories"; clicked => { root.select_tab("repositories"); } } - TabButton { x: parent.width * 3 / 5; width: parent.width / 5; height: parent.height; icon: "●"; label: "Alerts"; active: root.tab == "notifications"; clicked => { root.select_tab("notifications"); } } - TabButton { x: parent.width * 4 / 5; width: parent.width / 5; height: parent.height; icon: "≡"; label: "Settings"; active: root.tab == "settings"; clicked => { root.select_tab("settings"); } } - } - - if root.loading && root.tab == "issues": Rectangle { - width: 100%; height: 100%; - background: #55ffffff; - TouchArea { } - Spinner { width: 38px; height: 38px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; } - } - if root.error != "": Rectangle { - x: 14px; y: parent.height - root.safe-area-insets.bottom - 132px; width: parent.width - 28px; height: 66px; - background: #d93d35; border-radius: 12px; - Text { x: 14px; width: parent.width - 28px; text: root.error; color: white; font-size: 13px; wrap: word-wrap; vertical-alignment: center; } - TouchArea { clicked => { root.error = ""; } } - } - } -} - -#[derive(Clone, Deserialize, Serialize)] -struct Server { - name: String, - url: String, - #[serde(skip)] - token: String, -} - -#[derive(Default, Deserialize, Serialize)] -struct Preferences { - #[serde(default)] - servers: Vec, - #[serde(default)] - favorites: BTreeSet, - #[serde(default)] - last_server: Option, -} - -#[derive(Clone)] -struct RepositoryData { - owner: String, - name: String, - description: String, - language: String, - open_issues: i64, - updated: String, -} - -#[derive(Default)] -struct State { - preferences: Preferences, - active_server: Option, - active_repository: Option<(String, String)>, - active_issue: Option, - repositories: Vec, - issues: Vec, -} - -struct HomeData { - activities: Vec, - heatmap: Vec, -} +use api::*; +use domain::*; +use storage::*; +use ui::*; +use view::*; static RUNTIME: OnceLock = OnceLock::new(); @@ -495,6 +35,11 @@ fn main() -> Result<(), Box> { })); refresh_servers(&ui, &state.lock().unwrap().preferences); + { + let state = state.lock().unwrap(); + ui.set_issue_filter(state.preferences.issue_status.clone().into()); + ui.set_pull_filter(state.preferences.pull_status.clone().into()); + } if let Some(error) = startup_error { ui.set_error(error.into()); } @@ -524,10 +69,24 @@ fn wire_callbacks(ui: &AppWindow, state: Arc>) { move |tab| { let Some(ui) = weak.upgrade() else { return }; let changed = ui.get_tab() != tab; + if changed { + state.lock().unwrap().opened_from_home = false; + } ui.set_error("".into()); ui.set_tab(tab.clone()); - if changed && tab.as_str() == "home" { - open_home(&ui, state.clone()); + if changed { + match tab.as_str() { + "home" => open_home(&ui, state.clone()), + "pulls" => open_pulls(&ui, state.clone()), + "issues" | "repositories" => { + ui.set_page(if state.lock().unwrap().active_server.is_some() { + "repositories".into() + } else { + "servers".into() + }); + } + _ => {} + } } } }); @@ -593,7 +152,55 @@ fn wire_callbacks(ui: &AppWindow, state: Arc>) { if let (Some(ui), Some(((server, (owner, repository)), number))) = (weak.upgrade(), target) { - fetch_issue(&ui, server, owner, repository, number); + fetch_issue(&ui, server, owner, repository, number, true); + } + } + }); + + ui.on_refresh_pulls({ + let weak = ui.as_weak(); + let state = state.clone(); + move || { + if let Some(ui) = weak.upgrade() { + open_pulls_with_refresh(&ui, state.clone(), true); + } + } + }); + + ui.on_refresh_pull({ + let weak = ui.as_weak(); + let state = state.clone(); + move || { + let target = { + let state = state.lock().unwrap(); + state + .active_server + .and_then(|index| state.preferences.servers.get(index)) + .cloned() + .zip(state.active_pull.clone()) + }; + if let (Some(ui), Some((server, (owner, repository, number)))) = + (weak.upgrade(), target) + { + fetch_pull(&ui, server, owner, repository, number, true); + } + } + }); + + ui.on_refresh_commits({ + let weak = ui.as_weak(); + let state = state.clone(); + move || { + let target = { + let state = state.lock().unwrap(); + state + .active_server + .and_then(|index| state.preferences.servers.get(index)) + .cloned() + .zip(state.active_repository.clone()) + }; + if let (Some(ui), Some((server, (owner, repository)))) = (weak.upgrade(), target) { + fetch_commits(&ui, state.clone(), server, owner, repository, true); } } }); @@ -680,7 +287,11 @@ fn wire_callbacks(ui: &AppWindow, state: Arc>) { let state = state.clone(); move |owner, name| { if let Some(ui) = weak.upgrade() { - open_repository(&ui, state.clone(), owner.to_string(), name.to_string()); + if ui.get_tab().as_str() == "repositories" { + open_commit_history(&ui, state.clone(), owner.to_string(), name.to_string()); + } else { + open_repository(&ui, state.clone(), owner.to_string(), name.to_string()); + } } } }); @@ -716,30 +327,210 @@ fn wire_callbacks(ui: &AppWindow, state: Arc>) { let weak = ui.as_weak(); let state = state.clone(); move |number| { - let issue = { + let target = { let mut state = state.lock().unwrap(); + state.opened_from_home = false; state.active_issue = Some(number as i64); state - .issues - .iter() - .find(|issue| issue.number == Some(number as i64)) + .active_server + .and_then(|index| state.preferences.servers.get(index)) + .cloned() + .zip(state.active_repository.clone()) + }; + if let (Some(ui), Some((server, (owner, repository)))) = (weak.upgrade(), target) { + fetch_issue(&ui, server, owner, repository, number as i64, false); + } + } + }); + + ui.on_select_pull({ + let weak = ui.as_weak(); + let state = state.clone(); + move |owner, repository, number| { + let server = { + let mut state = state.lock().unwrap(); + state.opened_from_home = false; + state.active_pull = + Some((owner.to_string(), repository.to_string(), number as i64)); + state + .active_server + .and_then(|index| state.preferences.servers.get(index)) .cloned() }; - if let (Some(ui), Some(issue)) = (weak.upgrade(), issue) { - show_issue(&ui, &issue); + if let (Some(ui), Some(server)) = (weak.upgrade(), server) { + fetch_pull( + &ui, + server, + owner.to_string(), + repository.to_string(), + number as i64, + false, + ); + } + } + }); + + ui.on_select_commit({ + let weak = ui.as_weak(); + let state = state.clone(); + move |sha| { + let target = { + let mut state = state.lock().unwrap(); + state.opened_from_home = false; + state.active_commit = Some(sha.to_string()); + state + .active_server + .and_then(|index| state.preferences.servers.get(index)) + .cloned() + .zip(state.active_repository.clone()) + }; + if let (Some(ui), Some((server, (owner, repository)))) = (weak.upgrade(), target) { + fetch_commit(&ui, server, owner, repository, sha.to_string()); + } + } + }); + + ui.on_select_file({ + let weak = ui.as_weak(); + let state = state.clone(); + move |path| { + let target = { + let state = state.lock().unwrap(); + state + .active_server + .and_then(|index| state.preferences.servers.get(index)) + .cloned() + .zip(state.active_repository.clone()) + .zip(state.active_commit.clone()) + }; + if let (Some(ui), Some(((server, (owner, repository)), sha))) = (weak.upgrade(), target) + { + fetch_file_diff(&ui, server, owner, repository, sha, path.to_string()); + } + } + }); + + ui.on_select_activity({ + let weak = ui.as_weak(); + let state = state.clone(); + move |target, owner, repository, number, sha| { + let server = { + let mut state = state.lock().unwrap(); + state.opened_from_home = true; + state.active_repository = Some((owner.to_string(), repository.to_string())); + state + .active_server + .and_then(|index| state.preferences.servers.get(index)) + .cloned() + }; + let (Some(ui), Some(server)) = (weak.upgrade(), server) else { + return; + }; + match target.as_str() { + "issue" => { + state.lock().unwrap().active_issue = Some(number as i64); + ui.set_tab("issues".into()); + fetch_issue( + &ui, + server, + owner.to_string(), + repository.to_string(), + number as i64, + false, + ); + } + "pull" => { + state.lock().unwrap().active_pull = + Some((owner.to_string(), repository.to_string(), number as i64)); + ui.set_tab("pulls".into()); + fetch_pull( + &ui, + server, + owner.to_string(), + repository.to_string(), + number as i64, + false, + ); + } + "commit" => { + state.lock().unwrap().active_commit = Some(sha.to_string()); + ui.set_tab("repositories".into()); + ui.set_repository_title(repository.clone()); + fetch_commit( + &ui, + server, + owner.to_string(), + repository.to_string(), + sha.to_string(), + ); + } + _ => {} + } + } + }); + + ui.on_choose_filter({ + let weak = ui.as_weak(); + let state = state.clone(); + move |status| { + if !matches!(status.as_str(), "open" | "closed") { + return; + } + let Some(ui) = weak.upgrade() else { return }; + { + let mut state = state.lock().unwrap(); + if ui.get_tab().as_str() == "pulls" { + state.preferences.pull_status = status.to_string(); + ui.set_pull_filter(status.clone()); + } else { + state.preferences.issue_status = status.to_string(); + ui.set_issue_filter(status.clone()); + } + if let Err(error) = save_preferences(&state.preferences) { + ui.set_error(error.into()); + return; + } + } + if ui.get_tab().as_str() == "pulls" { + open_pulls(&ui, state.clone()); + } else { + let target = { + let state = state.lock().unwrap(); + state + .active_server + .and_then(|index| state.preferences.servers.get(index)) + .cloned() + .zip(state.active_repository.clone()) + }; + if let Some((server, (owner, repository))) = target { + fetch_issues(&ui, state.clone(), server, owner, repository, false); + } } } }); ui.on_back({ let weak = ui.as_weak(); + let state = state.clone(); move || { let Some(ui) = weak.upgrade() else { return }; ui.set_error("".into()); - ui.set_page(match ui.get_page().as_str() { - "issue" => "issues".into(), - "issues" => "repositories".into(), - "repositories" | "add" => "servers".into(), + if state.lock().unwrap().opened_from_home + && matches!(ui.get_page().as_str(), "issue" | "pull" | "files") + { + state.lock().unwrap().opened_from_home = false; + ui.set_tab("home".into()); + open_home(&ui, state.clone()); + return; + } + ui.set_page(match (ui.get_tab().as_str(), ui.get_page().as_str()) { + ("pulls", "pull") => "pulls".into(), + ("repositories", "diff") => "files".into(), + ("repositories", "files") => "commits".into(), + ("repositories", "commits") => "repositories".into(), + (_, "issue") => "issues".into(), + (_, "issues") => "repositories".into(), + (_, "repositories" | "add") => "servers".into(), _ => "servers".into(), }); } @@ -760,6 +551,8 @@ fn open_server(ui: &AppWindow, state: Arc>, index: usize) { } server }; + ui.set_has_active_server(true); + ui.set_home_server(server.name.clone().into()); ui.set_server_title(server.name.clone().into()); ui.set_repositories(empty_model()); ui.set_page("repositories".into()); @@ -871,8 +664,9 @@ fn fetch_issues( ui.set_loading(true); } let weak = ui.as_weak(); + let status = state.lock().unwrap().preferences.issue_status.clone(); spawn(async move { - let result = load_issues(&server, &owner, &repository).await; + let result = load_issues(&server, &owner, &repository, &status).await; post(move || { let Some(ui) = weak.upgrade() else { return }; if refreshing { @@ -892,483 +686,312 @@ fn fetch_issues( }); } -fn fetch_issue(ui: &AppWindow, server: Server, owner: String, repository: String, number: i64) { - ui.set_refreshing(true); +fn fetch_issue( + ui: &AppWindow, + server: Server, + owner: String, + repository: String, + number: i64, + refreshing: bool, +) { + if refreshing { + ui.set_refreshing(true); + } else { + ui.set_loading(true); + } let weak = ui.as_weak(); spawn(async move { let result = async { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - apis::issue_api::issue_get_issue(&client.configuration(), &owner, &repository, number) - .await - .map_err(|error| error.to_string()) + let configuration = client.configuration(); + let (issue, comments) = tokio::join!( + apis::issue_api::issue_get_issue(&configuration, &owner, &repository, number), + apis::issue_api::issue_get_comments( + &configuration, + &owner, + &repository, + number, + None, + None, + ), + ); + Ok::<_, String>(IssueDetails { + issue: issue.map_err(|error| error.to_string())?, + comments: comments.map_err(|error| error.to_string())?, + }) } .await; post(move || { let Some(ui) = weak.upgrade() else { return }; - ui.set_refreshing(false); + if refreshing { + ui.set_refreshing(false); + } else { + ui.set_loading(false); + } match result { - Ok(issue) => show_issue(&ui, &issue), + Ok(details) => show_issue(&ui, &details), Err(error) => ui.set_error(error.into()), } }); }); } -async fn load_repositories(server: &Server) -> Result, String> { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - let configuration = client.configuration(); - let user = client - .current_user() - .await - .map_err(|error| error.to_string())?; - let login = user.login.unwrap_or_default(); - let repositories = apis::user_api::user_current_list_repos(&configuration, Some(1), Some(100)) - .await - .map_err(|error| error.to_string())?; - Ok(repositories - .into_iter() - .filter(|repository| { - repository - .owner - .as_ref() - .and_then(|owner| owner.login.as_deref()) - == Some(login.as_str()) - }) - .filter_map(|repository| { - Some(RepositoryData { - owner: repository.owner?.login?, - name: repository.name?, - description: repository - .description - .filter(|text| !text.is_empty()) - .unwrap_or_else(|| "No description".into()), - language: repository - .language - .filter(|text| !text.is_empty()) - .unwrap_or_else(|| "Unknown language".into()), - open_issues: repository.open_issues_count.unwrap_or_default(), - updated: compact_date(repository.updated_at.as_deref()), - }) - }) - .collect()) +fn open_pulls(ui: &AppWindow, state: Arc>) { + open_pulls_with_refresh(ui, state, false); } -async fn load_issues( - server: &Server, - owner: &str, - repository: &str, -) -> Result, String> { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - apis::issue_api::issue_list_issues( - &client.configuration(), - owner, - repository, - Some("open"), - None, - None, - Some("issues"), - None, - None, - None, - None, - None, - None, - Some(1), - Some(100), - ) - .await - .map_err(|error| error.to_string()) -} - -async fn load_home(server: &Server) -> Result { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - let configuration = client.configuration(); - let login = client - .current_user() - .await - .map_err(|error| error.to_string())? - .login - .ok_or("The server account has no username.")?; - let (activities, heatmap) = tokio::join!( - apis::user_api::user_list_activity_feeds( - &configuration, - &login, - Some(true), - None, - Some(1), - Some(40), - ), - apis::user_api::user_get_heatmap_data(&configuration, &login), - ); - Ok(HomeData { - activities: activities.map_err(|error| error.to_string())?, - heatmap: heatmap.map_err(|error| error.to_string())?, - }) -} - -fn refresh_servers(ui: &AppWindow, preferences: &Preferences) { - ui.set_servers(model( - preferences - .servers - .iter() - .map(|server| ServerRow { - name: server.name.clone().into(), - url: server.url.clone().into(), - }) - .collect(), - )); -} - -fn refresh_repositories(ui: &AppWindow, state: &State) { - let server_url = state - .active_server - .and_then(|i| state.preferences.servers.get(i)) - .map(|server| server.url.as_str()) - .unwrap_or_default(); - let mut repositories = state.repositories.clone(); - repositories.sort_by_key(|repository| { - ( - !state.preferences.favorites.contains(&favorite_key( - server_url, - &repository.owner, - &repository.name, - )), - repository.name.to_lowercase(), - ) - }); - ui.set_repositories(model( - repositories - .into_iter() - .map(|repository| { - let favorite = state.preferences.favorites.contains(&favorite_key( - server_url, - &repository.owner, - &repository.name, - )); - RepositoryRow { - name: repository.name.into(), - owner: repository.owner.into(), - description: repository.description.into(), - meta: format!( - "{} · {} open · {}", - repository.language, repository.open_issues, repository.updated - ) - .into(), - favorite, - } - }) - .collect(), - )); - ui.set_repositories_scroll_request(ui.get_repositories_scroll_request().wrapping_add(1)); -} - -fn refresh_issues(ui: &AppWindow, issues: &[models::Issue]) { - ui.set_issues(model( - issues - .iter() - .map(|issue| IssueRow { - number: issue.number.unwrap_or_default() as i32, - title: issue - .title - .clone() - .unwrap_or_else(|| "Untitled issue".into()) - .into(), - summary: issue - .body - .as_deref() - .map(summary) - .unwrap_or_else(|| "No description".into()) - .into(), - meta: issue_meta(issue).into(), - labels: model( - issue - .labels - .as_deref() - .unwrap_or_default() - .iter() - .filter_map(label_row) - .collect(), - ), - }) - .collect(), - )); - ui.set_issues_scroll_request(ui.get_issues_scroll_request().wrapping_add(1)); -} - -fn label_row(label: &models::Label) -> Option { - let name = label.name.as_deref()?.trim(); - let hex = label.color.as_deref()?.trim().trim_start_matches('#'); - if name.is_empty() || hex.len() != 6 { - return None; - } - let value = u32::from_str_radix(hex, 16).ok()?; - let (red, green, blue) = ( - ((value >> 16) & 0xff) as u8, - ((value >> 8) & 0xff) as u8, - (value & 0xff) as u8, - ); - let light = red as u32 * 299 + green as u32 * 587 + blue as u32 * 114 > 150_000; - Some(LabelRow { - name: name.into(), - background: Color::from_rgb_u8(red, green, blue), - foreground: if light { - Color::from_rgb_u8(28, 28, 30) - } else { - Color::from_rgb_u8(255, 255, 255) - }, - }) -} - -fn refresh_home(ui: &AppWindow, home: HomeData) { - let (cells, contributions) = heat_cells(&home.heatmap); - ui.set_heat_cells(model(cells)); - ui.set_contribution_count(contributions.min(i32::MAX as i64) as i32); - ui.set_activities(model(home.activities.iter().map(activity_row).collect())); - ui.set_home_scroll_request(ui.get_home_scroll_request().wrapping_add(1)); -} - -fn activity_row(activity: &models::Activity) -> ActivityRow { - use models::activity::OpType; - - let repository = activity - .repo - .as_ref() - .and_then(|repo| repo.full_name.as_deref()) - .unwrap_or("repository"); - let branch = activity - .ref_name - .as_deref() - .and_then(|name| name.strip_prefix("refs/heads/").or(Some(name))) - .unwrap_or("default branch"); - let (icon, title) = match activity.op_type { - Some(OpType::CommitRepo) if activity.content.as_deref().unwrap_or("").is_empty() => { - ("↗", format!("Created branch {branch} in {repository}")) - } - Some(OpType::CommitRepo | OpType::MirrorSyncPush) => { - ("↑", format!("Pushed to {branch} in {repository}")) - } - Some(OpType::CreateRepo) => ("▣", format!("Created {repository}")), - Some(OpType::RenameRepo) => ("✎", format!("Renamed {repository}")), - Some(OpType::StarRepo) => ("★", format!("Starred {repository}")), - Some(OpType::WatchRepo) => ("◉", format!("Started watching {repository}")), - Some(OpType::CreateIssue) => ("!", format!("Opened an issue in {repository}")), - Some(OpType::CloseIssue) => ("✓", format!("Closed an issue in {repository}")), - Some(OpType::ReopenIssue) => ("↻", format!("Reopened an issue in {repository}")), - Some(OpType::CommentIssue) => ("●", format!("Commented on an issue in {repository}")), - Some(OpType::CreatePullRequest) => ("↗", format!("Opened a pull request in {repository}")), - Some(OpType::MergePullRequest | OpType::AutoMergePullRequest) => { - ("↝", format!("Merged a pull request in {repository}")) - } - Some(OpType::ClosePullRequest) => ("×", format!("Closed a pull request in {repository}")), - Some(OpType::ReopenPullRequest) => { - ("↻", format!("Reopened a pull request in {repository}")) - } - Some(OpType::CommentPull) => ("●", format!("Commented on a pull request in {repository}")), - Some(OpType::ApprovePullRequest) => { - ("✓", format!("Approved a pull request in {repository}")) - } - Some(OpType::RejectPullRequest) => ("×", format!("Requested changes in {repository}")), - Some(OpType::PushTag) => ("◆", format!("Pushed tag {branch} in {repository}")), - Some(OpType::DeleteTag) => ("−", format!("Deleted tag {branch} in {repository}")), - Some(OpType::DeleteBranch) => ("−", format!("Deleted branch {branch} in {repository}")), - Some(OpType::PublishRelease) => ("◆", format!("Published a release in {repository}")), - Some(_) | None => ("•", format!("Updated {repository}")), +fn open_pulls_with_refresh(ui: &AppWindow, state: Arc>, refreshing: bool) { + let target = { + let state = state.lock().unwrap(); + state + .active_server + .and_then(|index| state.preferences.servers.get(index)) + .cloned() + .map(|server| (server, state.preferences.pull_status.clone())) }; - ActivityRow { - icon: icon.into(), - title: title.into(), - detail: activity_detail(activity).into(), - meta: compact_date(activity.created.as_deref()).into(), - } -} - -fn activity_detail(activity: &models::Activity) -> String { - let text = activity - .comment - .as_ref() - .and_then(|comment| comment.body.as_deref()) - .or(activity.content.as_deref()) - .unwrap_or(""); - if let Ok(payload) = serde_json::from_str::(text) { - let commits = payload.get("Len").and_then(|value| value.as_i64()); - let message = payload - .get("HeadCommit") - .and_then(|commit| commit.get("Message")) - .and_then(|message| message.as_str()) - .map(summary) - .unwrap_or_default(); - if !message.is_empty() { - return match commits { - Some(1) => format!("1 commit · {message}"), - Some(count) => format!("{count} commits · {message}"), - None => message, - }; - } - } - let text = summary(text); - if text.is_empty() { - "Server activity".into() + let Some((server, status)) = target else { + ui.set_has_active_server(false); + ui.set_pulls(empty_model()); + return; + }; + ui.set_has_active_server(true); + ui.set_page("pulls".into()); + if refreshing { + ui.set_refreshing(true); } else { - text + ui.set_loading(true); } -} - -fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec, i64) { - const DAYS: i64 = 52 * 7; - let latest = data - .iter() - .filter_map(|entry| entry.timestamp) - .max() - .unwrap_or(0) - / 86_400; - let start = latest - DAYS + 1; - let mut counts = vec![0_i64; DAYS as usize]; - for entry in data { - let day = entry.timestamp.unwrap_or_default() / 86_400; - if (start..=latest).contains(&day) { - counts[(day - start) as usize] += entry.contributions.unwrap_or_default(); - } - } - let maximum = counts.iter().copied().max().unwrap_or_default(); - let contributions = counts.iter().sum(); - let cells = counts - .into_iter() - .enumerate() - .map(|(index, count)| HeatCell { - week: (index / 7) as i32, - day: (index % 7) as i32, - level: if count == 0 || maximum == 0 { - 0 + let weak = ui.as_weak(); + spawn(async move { + let result = load_pulls(&server, &status).await; + post(move || { + let Some(ui) = weak.upgrade() else { return }; + if refreshing { + ui.set_refreshing(false); } else { - ((count * 4 + maximum - 1) / maximum).clamp(1, 4) as i32 - }, - }) - .collect(); - (cells, contributions) + ui.set_loading(false); + } + match result { + Ok(pulls) => { + state.lock().unwrap().pulls = pulls; + refresh_pulls(&ui, &state.lock().unwrap().pulls); + } + Err(error) => ui.set_error(error.into()), + } + }); + }); } -fn show_issue(ui: &AppWindow, issue: &models::Issue) { - ui.set_issue_title( - issue - .title - .clone() - .unwrap_or_else(|| "Untitled issue".into()) - .into(), - ); - ui.set_issue_meta(issue_meta(issue).into()); - ui.set_issue_body( - issue - .body - .clone() - .filter(|body| !body.is_empty()) - .unwrap_or_else(|| "No description provided.".into()) - .into(), - ); - ui.set_issue_scroll_request(ui.get_issue_scroll_request().wrapping_add(1)); - ui.set_page("issue".into()); -} - -fn issue_meta(issue: &models::Issue) -> String { - let author = issue - .user - .as_ref() - .and_then(|user| user.login.as_deref()) - .unwrap_or("unknown"); - format!( - "#{} · {} · updated {} · {} comments", - issue.number.unwrap_or_default(), - author, - compact_date(issue.updated_at.as_deref()), - issue.comments.unwrap_or_default() - ) -} - -fn summary(body: &str) -> String { - body.split_whitespace() - .take(24) - .collect::>() - .join(" ") -} - -fn compact_date(date: Option<&str>) -> String { - date.and_then(|date| date.get(..10)) - .unwrap_or("unknown") - .to_string() -} - -fn favorite_key(server: &str, owner: &str, repository: &str) -> String { - format!("{server}|{owner}/{repository}") -} - -fn validate_server(name: &str, url: &str, token: &str) -> Result { - let name = name.trim(); - let url = url.trim().trim_end_matches('/'); - let token = token.trim(); - if name.is_empty() { - return Err("Give this server a name.".into()); +fn fetch_pull( + ui: &AppWindow, + server: Server, + owner: String, + repository: String, + number: i64, + refreshing: bool, +) { + if refreshing { + ui.set_refreshing(true); + } else { + ui.set_loading(true); } - if token.is_empty() { - return Err("Enter an access token.".into()); + let weak = ui.as_weak(); + spawn(async move { + let result = async { + let client = + Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; + let configuration = client.configuration(); + let (pull, comments) = tokio::join!( + apis::repository_api::repo_get_pull_request( + &configuration, + &owner, + &repository, + number, + ), + apis::issue_api::issue_get_comments( + &configuration, + &owner, + &repository, + number, + None, + None, + ), + ); + Ok::<_, String>(PullDetails { + pull: pull.map_err(|error| error.to_string())?, + comments: comments.map_err(|error| error.to_string())?, + }) + } + .await; + post(move || { + let Some(ui) = weak.upgrade() else { return }; + if refreshing { + ui.set_refreshing(false); + } else { + ui.set_loading(false); + } + match result { + Ok(details) => show_pull(&ui, &details), + Err(error) => ui.set_error(error.into()), + } + }); + }); +} + +fn open_commit_history( + ui: &AppWindow, + state: Arc>, + owner: String, + repository: String, +) { + let server = { + let mut state = state.lock().unwrap(); + state.active_repository = Some((owner.clone(), repository.clone())); + state.commits.clear(); + state + .active_server + .and_then(|index| state.preferences.servers.get(index)) + .cloned() + }; + let Some(server) = server else { return }; + ui.set_repository_title(repository.clone().into()); + ui.set_commits(empty_model()); + ui.set_page("commits".into()); + ui.set_error("".into()); + fetch_commits(ui, state, server, owner, repository, false); +} + +fn fetch_commits( + ui: &AppWindow, + state: Arc>, + server: Server, + owner: String, + repository: String, + refreshing: bool, +) { + if refreshing { + ui.set_refreshing(true); + } else { + ui.set_loading(true); } - Client::new(url, Some(token)).map_err(|error| error.to_string())?; - Ok(Server { - name: name.into(), - url: url.into(), - token: token.into(), - }) + let weak = ui.as_weak(); + spawn(async move { + let result = load_commits(&server, &owner, &repository).await; + post(move || { + let Some(ui) = weak.upgrade() else { return }; + if refreshing { + ui.set_refreshing(false); + } else { + ui.set_loading(false); + } + match result { + Ok(commits) => { + state.lock().unwrap().commits = commits; + refresh_commits(&ui, &state.lock().unwrap().commits); + } + Err(error) => ui.set_error(error.into()), + } + }); + }); } -fn preferences_path() -> Result { - let home = env::var_os("HOME").ok_or("Cannot find the app data directory.")?; - Ok(PathBuf::from(home).join("Library/Application Support/Gotcha/preferences.json")) +fn fetch_commit(ui: &AppWindow, server: Server, owner: String, repository: String, sha: String) { + ui.set_loading(true); + let weak = ui.as_weak(); + spawn(async move { + let result = async { + let client = + Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; + apis::repository_api::repo_get_single_commit( + &client.configuration(), + &owner, + &repository, + &sha, + Some(true), + None, + Some(true), + ) + .await + .map_err(|error| error.to_string()) + } + .await; + post(move || { + let Some(ui) = weak.upgrade() else { return }; + ui.set_loading(false); + match result { + Ok(commit) => { + ui.set_files(model( + commit + .files + .unwrap_or_default() + .into_iter() + .filter_map(|file| { + Some(FileRow { + path: file.filename?.into(), + status: file.status.unwrap_or_else(|| "modified".into()).into(), + }) + }) + .collect(), + )); + ui.set_page("files".into()); + } + Err(error) => ui.set_error(error.into()), + } + }); + }); } -fn load_preferences() -> Result { - let path = preferences_path()?; - if !path.exists() { - return Ok(Preferences::default()); - } - let mut preferences: Preferences = - serde_json::from_slice(&fs::read(&path).map_err(|error| error.to_string())?) - .map_err(|error| format!("Cannot read {}: {error}", path.display()))?; - for server in &mut preferences.servers { - server.token = String::from_utf8( - get_generic_password("de.rfc1437.gotcha", &keychain_account(server)) - .map_err(|error| format!("Cannot read the token for {}: {error}", server.name))?, - ) - .map_err(|_| format!("The token for {} is not valid text.", server.name))?; - } - Ok(preferences) -} - -fn save_preferences(preferences: &Preferences) -> Result<(), String> { - let path = preferences_path()?; - let parent = path.parent().ok_or("Invalid app data directory.")?; - fs::create_dir_all(parent).map_err(|error| error.to_string())?; - let temporary = path.with_extension("tmp"); - fs::write( - &temporary, - serde_json::to_vec(preferences).map_err(|error| error.to_string())?, - ) - .map_err(|error| error.to_string())?; - fs::rename(&temporary, &path).map_err(|error| error.to_string()) -} - -fn save_server_token(server: &Server) -> Result<(), String> { - set_generic_password( - "de.rfc1437.gotcha", - &keychain_account(server), - server.token.as_bytes(), - ) - .map_err(|error| format!("Cannot save the token for {}: {error}", server.name)) -} - -fn keychain_account(server: &Server) -> String { - format!("{}|{}", server.name, server.url) +fn fetch_file_diff( + ui: &AppWindow, + server: Server, + owner: String, + repository: String, + sha: String, + path: String, +) { + ui.set_loading(true); + let weak = ui.as_weak(); + spawn(async move { + let result = async { + let client = + Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; + apis::repository_api::repo_download_commit_diff_or_patch( + &client.configuration(), + &owner, + &repository, + &sha, + "diff", + ) + .await + .map(|text| diff::parse_file(&text, &path)) + .map_err(|error| error.to_string()) + } + .await; + post(move || { + let Some(ui) = weak.upgrade() else { return }; + ui.set_loading(false); + match result { + Ok(diff) => { + ui.set_detail_title(path.rsplit('/').next().unwrap_or(&path).into()); + ui.set_diff_columns(diff.columns.min(i32::MAX as usize) as i32); + ui.set_diff_lines(model( + diff.lines + .into_iter() + .map(|line| DiffLine { + old_number: line.old_number.into(), + new_number: line.new_number.into(), + text: line.text.into(), + kind: line.kind.into(), + }) + .collect(), + )); + ui.set_page("diff".into()); + } + Err(error) => ui.set_error(error.into()), + } + }); + }); } fn runtime() -> &'static tokio::runtime::Runtime { @@ -1390,41 +1013,3 @@ fn model(rows: Vec) -> ModelRc { fn empty_model() -> ModelRc { model(Vec::new()) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn validates_servers_and_builds_stable_favorite_keys() { - assert!(validate_server("Work", "https://gitea.example.com/", "secret").is_ok()); - assert!(validate_server("", "https://gitea.example.com", "secret").is_err()); - assert!(validate_server("Work", "file:///tmp/gitea", "secret").is_err()); - assert_eq!( - favorite_key("https://gitea.example.com", "octo", "demo"), - "https://gitea.example.com|octo/demo" - ); - - let heatmap = vec![ - models::UserHeatmapData { - timestamp: Some(100 * 86_400), - contributions: Some(1), - }, - models::UserHeatmapData { - timestamp: Some(101 * 86_400), - contributions: Some(4), - }, - ]; - let (cells, total) = heat_cells(&heatmap); - assert_eq!((cells.len(), total), (364, 5)); - assert_eq!((cells[362].level, cells[363].level), (1, 4)); - - let label = models::Label { - name: Some("bug".into()), - color: Some("d73a4a".into()), - ..Default::default() - }; - assert!(label_row(&label).is_some()); - assert!(label_row(&models::Label::default()).is_none()); - } -} diff --git a/crates/app/src/markdown.rs b/crates/app/src/markdown.rs new file mode 100644 index 0000000..381d0a0 --- /dev/null +++ b/crates/app/src/markdown.rs @@ -0,0 +1,312 @@ +use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; +use slint::StyledText; + +use crate::ui::MarkdownBlock; + +struct Draft { + kind: &'static str, + source: String, + plain: bool, +} + +#[derive(Default)] +struct Renderer { + blocks: Vec, + current: Option, + lists: Vec>, + links: Vec<(String, bool, bool)>, + quote_depth: usize, +} + +pub fn render(source: &str) -> Vec { + let mut renderer = Renderer::default(); + for event in Parser::new_ext(source, Options::all()) { + renderer.event(event); + } + renderer.finish(); + renderer.blocks +} + +impl Renderer { + fn event(&mut self, event: Event<'_>) { + match event { + Event::Start(tag) => self.start(tag), + Event::End(tag) => self.end(tag), + Event::Text(text) => self.text(&text), + Event::Code(text) | Event::InlineMath(text) | Event::DisplayMath(text) => { + if self.is_plain() { + self.raw(&text); + } else { + self.raw(&code_span(&text)); + } + } + Event::Html(html) | Event::InlineHtml(html) => self.text(&html), + Event::FootnoteReference(label) => self.text(&format!("[{label}]")), + Event::SoftBreak | Event::HardBreak => self.raw("\n"), + Event::Rule => { + self.begin("rule", true); + self.finish(); + } + Event::TaskListMarker(checked) => self.raw(if checked { "☑ " } else { "☐ " }), + } + } + + fn start(&mut self, tag: Tag<'_>) { + match tag { + Tag::Paragraph => { + if self.current.is_none() { + self.begin( + if self.quote_depth > 0 { + "quote" + } else { + "paragraph" + }, + false, + ); + } else if self + .current + .as_ref() + .is_some_and(|draft| !draft.source.is_empty() && !draft.source.ends_with(' ')) + { + self.raw("\n"); + } + } + Tag::Heading { level, .. } => { + self.begin(heading_kind(level), false); + self.raw("**"); + } + Tag::BlockQuote(_) => { + self.quote_depth += 1; + if self.current.is_none() { + self.begin("quote", false); + } else { + self.raw("› "); + } + } + Tag::CodeBlock(_) => self.begin("code", true), + Tag::HtmlBlock | Tag::MetadataBlock(_) => self.begin("code", true), + Tag::List(start) => self.lists.push(start), + Tag::Item => { + self.begin("list", false); + let indent = "\u{00a0}\u{00a0}".repeat(self.lists.len().saturating_sub(1)); + self.raw(&indent); + let prefix = match self.lists.last_mut() { + Some(Some(number)) => { + let prefix = format!("{number}\\. "); + *number += 1; + prefix + } + _ => "• ".into(), + }; + self.raw(&prefix); + } + Tag::FootnoteDefinition(label) => { + self.begin("footnote", false); + self.text(&format!("[{label}] ")); + } + Tag::DefinitionList => {} + Tag::DefinitionListTitle => { + self.begin("paragraph", false); + self.raw("**"); + } + Tag::DefinitionListDefinition => { + self.begin("paragraph", false); + self.raw("↳ "); + } + Tag::Table(_) => self.begin("table", true), + Tag::TableHead | Tag::TableRow | Tag::TableCell => {} + Tag::Emphasis => self.marker("*"), + Tag::Strong => self.marker("**"), + Tag::Strikethrough => self.marker("~~"), + Tag::Superscript => self.raw("\\^"), + Tag::Subscript => self.raw("\\~"), + Tag::Link { dest_url, .. } => self.start_link(dest_url.into_string(), false), + Tag::Image { dest_url, .. } => self.start_link(dest_url.into_string(), true), + } + } + + fn end(&mut self, tag: TagEnd) { + match tag { + TagEnd::Paragraph => { + if self.quote_depth == 0 + && !self + .current + .as_ref() + .is_some_and(|draft| draft.kind == "list") + { + self.finish(); + } + } + TagEnd::Heading(_) => { + self.raw("**"); + self.finish(); + } + TagEnd::BlockQuote(_) => { + self.quote_depth = self.quote_depth.saturating_sub(1); + if self.quote_depth == 0 { + self.finish(); + } + } + TagEnd::CodeBlock | TagEnd::HtmlBlock | TagEnd::MetadataBlock(_) => self.finish(), + TagEnd::List(_) => { + self.lists.pop(); + } + TagEnd::Item | TagEnd::FootnoteDefinition => self.finish(), + TagEnd::DefinitionList => {} + TagEnd::DefinitionListTitle => { + self.raw("**"); + self.finish(); + } + TagEnd::DefinitionListDefinition => self.finish(), + TagEnd::Table => self.finish(), + TagEnd::TableHead => self.raw("────────\n"), + TagEnd::TableRow => { + if let Some(draft) = self.current.as_mut() + && draft.source.ends_with(" │ ") + { + draft.source.truncate(draft.source.len() - " │ ".len()); + } + self.raw("\n"); + } + TagEnd::TableCell => self.raw(" │ "), + TagEnd::Emphasis => self.marker("*"), + TagEnd::Strong => self.marker("**"), + TagEnd::Strikethrough => self.marker("~~"), + TagEnd::Superscript => self.raw("\\^"), + TagEnd::Subscript => self.raw("\\~"), + TagEnd::Link | TagEnd::Image => self.end_link(), + } + } + + fn begin(&mut self, kind: &'static str, plain: bool) { + self.finish(); + self.current = Some(Draft { + kind, + source: String::new(), + plain, + }); + } + + fn finish(&mut self) { + let Some(draft) = self.current.take() else { + return; + }; + let source = draft.source.trim_matches('\n'); + if source.is_empty() && draft.kind != "rule" { + return; + } + let text = if draft.plain { + StyledText::from_plain_text(source) + } else { + StyledText::from_markdown(source) + .unwrap_or_else(|_| StyledText::from_plain_text(source)) + }; + self.blocks.push(MarkdownBlock { + kind: draft.kind.into(), + text, + }); + } + + fn text(&mut self, text: &str) { + if self.current.is_none() { + self.begin( + if self.quote_depth > 0 { + "quote" + } else { + "paragraph" + }, + false, + ); + } + if self.is_plain() { + self.raw(text); + return; + } + let mut escaped = String::with_capacity(text.len()); + for character in text.chars() { + if character.is_ascii_punctuation() { + escaped.push('\\'); + } + escaped.push(character); + } + self.raw(&escaped); + } + + fn raw(&mut self, text: &str) { + if self.current.is_none() { + self.begin("paragraph", false); + } + self.current.as_mut().unwrap().source.push_str(text); + } + + fn marker(&mut self, marker: &str) { + if !self.is_plain() { + self.raw(marker); + } + } + + fn is_plain(&self) -> bool { + self.current.as_ref().is_some_and(|draft| draft.plain) + } + + fn start_link(&mut self, url: String, image: bool) { + let styled = !self.is_plain(); + if styled { + self.raw(if image { "[🖼 " } else { "[" }); + } else if image { + self.raw("🖼 "); + } + self.links.push((url, image, styled)); + } + + fn end_link(&mut self) { + let Some((url, _, styled)) = self.links.pop() else { + return; + }; + if styled { + self.raw(&format!("](<{}>)", url.replace('>', "%3E"))); + } else { + self.raw(&format!(" ({url})")); + } + } +} + +fn heading_kind(level: HeadingLevel) -> &'static str { + match level { + HeadingLevel::H1 => "heading1", + HeadingLevel::H2 => "heading2", + HeadingLevel::H3 => "heading3", + HeadingLevel::H4 => "heading4", + HeadingLevel::H5 => "heading5", + HeadingLevel::H6 => "heading6", + } +} + +fn code_span(text: &str) -> String { + let longest = text + .split(|character| character != '`') + .map(str::len) + .max() + .unwrap_or(0); + let fence = "`".repeat(longest + 1); + format!("{fence} {text} {fence}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_extended_markdown_as_safe_styled_blocks() { + let blocks = render( + "# Title\n\n- [x] **done** with [link](https://example.com)\n\n> quoted\n\n```rust\nlet x = 1;\n```\n\n| A | B |\n| - | - |\n| 1 | 2 |", + ); + let kinds: Vec<_> = blocks.iter().map(|block| block.kind.as_str()).collect(); + assert_eq!(kinds, ["heading1", "list", "quote", "code", "table"]); + assert_eq!( + blocks[0].text, + StyledText::from_markdown("**Title**").unwrap() + ); + assert_eq!(blocks[3].text, StyledText::from_plain_text("let x = 1;")); + } +} diff --git a/crates/app/src/storage.rs b/crates/app/src/storage.rs new file mode 100644 index 0000000..9cb4c58 --- /dev/null +++ b/crates/app/src/storage.rs @@ -0,0 +1,100 @@ +use std::{env, fs, path::PathBuf}; + +use gotcha_gitea::Client; +use security_framework::passwords::{get_generic_password, set_generic_password}; + +use crate::domain::{Preferences, Server, open_status}; + +pub fn favorite_key(server: &str, owner: &str, repository: &str) -> String { + format!("{server}|{owner}/{repository}") +} + +pub fn validate_server(name: &str, url: &str, token: &str) -> Result { + let name = name.trim(); + let url = url.trim().trim_end_matches('/'); + let token = token.trim(); + if name.is_empty() { + return Err("Give this server a name.".into()); + } + if token.is_empty() { + return Err("Enter an access token.".into()); + } + Client::new(url, Some(token)).map_err(|error| error.to_string())?; + Ok(Server { + name: name.into(), + url: url.into(), + token: token.into(), + }) +} + +pub fn load_preferences() -> Result { + let path = preferences_path()?; + if !path.exists() { + return Ok(Preferences::default()); + } + let mut preferences: Preferences = + serde_json::from_slice(&fs::read(&path).map_err(|error| error.to_string())?) + .map_err(|error| format!("Cannot read {}: {error}", path.display()))?; + if !matches!(preferences.issue_status.as_str(), "open" | "closed") { + preferences.issue_status = open_status(); + } + if !matches!(preferences.pull_status.as_str(), "open" | "closed") { + preferences.pull_status = open_status(); + } + for server in &mut preferences.servers { + server.token = String::from_utf8( + get_generic_password("de.rfc1437.gotcha", &keychain_account(server)) + .map_err(|error| format!("Cannot read the token for {}: {error}", server.name))?, + ) + .map_err(|_| format!("The token for {} is not valid text.", server.name))?; + } + Ok(preferences) +} + +pub fn save_preferences(preferences: &Preferences) -> Result<(), String> { + let path = preferences_path()?; + let parent = path.parent().ok_or("Invalid app data directory.")?; + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + let temporary = path.with_extension("tmp"); + fs::write( + &temporary, + serde_json::to_vec(preferences).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + fs::rename(&temporary, &path).map_err(|error| error.to_string()) +} + +pub fn save_server_token(server: &Server) -> Result<(), String> { + set_generic_password( + "de.rfc1437.gotcha", + &keychain_account(server), + server.token.as_bytes(), + ) + .map_err(|error| format!("Cannot save the token for {}: {error}", server.name)) +} + +fn preferences_path() -> Result { + let home = env::var_os("HOME").ok_or("Cannot find the app data directory.")?; + Ok(PathBuf::from(home).join("Library/Application Support/Gotcha/preferences.json")) +} + +fn keychain_account(server: &Server) -> String { + format!("{}|{}", server.name, server.url) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_servers_and_builds_stable_favorite_keys() { + assert!(validate_server("Work", "https://gitea.example.com/", "secret").is_ok()); + assert!(validate_server("", "https://gitea.example.com", "secret").is_err()); + assert!(validate_server("Work", "file:///tmp/gitea", "secret").is_err()); + assert_eq!( + favorite_key("https://gitea.example.com", "octo", "demo"), + "https://gitea.example.com|octo/demo" + ); + assert_eq!(Preferences::default().pull_status, "open"); + } +} diff --git a/crates/app/src/ui.rs b/crates/app/src/ui.rs new file mode 100644 index 0000000..b3f3c68 --- /dev/null +++ b/crates/app/src/ui.rs @@ -0,0 +1,687 @@ +slint::slint! { + import { Button, LineEdit, ListView, ScrollView, Spinner } from "std-widgets.slint"; + + export struct ServerRow { name: string, url: string } + export struct RepositoryRow { + name: string, owner: string, description: string, meta: string, favorite: bool, + } + export struct LabelRow { name: string, background: color, foreground: color } + export struct IssueRow { + number: int, title: string, summary: string, meta: string, labels: [LabelRow], + } + export struct PullRow { + number: int, owner: string, repository: string, title: string, summary: string, meta: string, + } + export struct MarkdownBlock { text: styled-text, kind: string } + export struct CommentRow { author: string, body: [MarkdownBlock], meta: string } + export struct CommitRow { sha: string, title: string, meta: string } + export struct FileRow { path: string, status: string } + export struct ActivityRow { + icon: string, title: string, detail: string, meta: string, + target: string, owner: string, repository: string, number: int, sha: string, + } + export struct DiffLine { old_number: string, new_number: string, text: string, kind: string } + export struct HeatCell { week: int, day: int, level: int } + + component Header inherits Rectangle { + in property title; + in property can_back; + in property can_filter: false; + callback back(); + callback filter(); + height: 58px; + width: 100%; + background: #f8f8fa; + border-color: #dedee3; + border-width: 0px; + + if can_back: TouchArea { + x: 8px; width: 54px; + clicked => { root.back(); } + Text { text: "‹"; color: #0879e1; font-size: 38px; vertical-alignment: center; } + } + Text { + x: root.can_back || root.can_filter ? 62px : 12px; + width: parent.width - 2 * self.x; + text: root.title; + horizontal-alignment: center; + vertical-alignment: center; + font-size: 18px; + font-weight: 600; + color: #151518; + overflow: elide; + } + if can_filter: TouchArea { + x: parent.width - 58px; width: 58px; + clicked => { root.filter(); } + Text { text: "☷"; color: #0879e1; font-size: 25px; horizontal-alignment: center; vertical-alignment: center; } + } + Rectangle { y: parent.height - 1px; height: 1px; background: #dedee3; } + } + + component EdgeBack inherits SwipeGestureHandler { + callback back(); + width: 24px; + height: 100%; + handle-swipe-right: true; + swiped => { root.back(); } + } + + component EmptyState inherits VerticalLayout { + in property title; + in property detail; + alignment: center; + spacing: 8px; + Text { text: root.title; font-size: 21px; font-weight: 600; horizontal-alignment: center; color: #1c1c1e; } + Text { text: root.detail; font-size: 15px; horizontal-alignment: center; wrap: word-wrap; color: #69696f; } + } + + component MarkdownContent inherits VerticalLayout { + in property <[MarkdownBlock]> blocks; + spacing: 8px; + + for block in root.blocks: Rectangle { + height: block.kind == "rule" ? 9px : rendered.preferred-height + (block.kind == "code" || block.kind == "table" ? 16px : 0px); + background: block.kind == "code" || block.kind == "table" ? #e9e9ed : block.kind == "quote" ? #f6f6f8 : transparent; + border-radius: block.kind == "code" || block.kind == "table" ? 7px : 0px; + if block.kind == "quote": Rectangle { width: 3px; height: 100%; background: #9a9aa1; border-radius: 2px; } + if block.kind == "rule": Rectangle { y: 4px; width: 100%; height: 1px; background: #d2d2d7; } + rendered := StyledText { + x: block.kind == "code" || block.kind == "table" ? 8px : block.kind == "quote" ? 11px : 0px; + y: block.kind == "code" || block.kind == "table" ? 8px : 0px; + width: parent.width - self.x - (block.kind == "code" || block.kind == "table" ? 8px : 0px); + text: block.text; + default-color: #252529; + link-color: #0879e1; + default-font-family: block.kind == "code" || block.kind == "table" ? "monospace" : ""; + default-font-size: block.kind == "heading1" ? 24px : block.kind == "heading2" ? 21px : block.kind == "heading3" ? 19px : block.kind == "heading4" ? 17px : 16px; + } + } + } + + component CommentCard inherits Rectangle { + in property comment; + height: content.preferred-height + 24px; + background: #ffffff; + border-radius: 10px; + content := VerticalLayout { + x: 12px; y: 10px; width: parent.width - 24px; + spacing: 5px; + Text { text: root.comment.author; font-size: 13px; font-weight: 600; color: #252529; } + MarkdownContent { width: parent.width; blocks: root.comment.body; } + Text { text: root.comment.meta; font-size: 11px; color: #85858b; } + } + } + + component TabButton inherits Rectangle { + in property icon; + in property label; + in property active; + callback clicked(); + background: transparent; + TouchArea { clicked => { root.clicked(); } } + Text { + y: 4px; height: 29px; width: 100%; text: root.icon; + color: root.active ? #0879e1 : #77777d; font-size: 24px; + horizontal-alignment: center; vertical-alignment: center; + } + Text { + y: 34px; height: 18px; width: 100%; text: root.label; + color: root.active ? #0879e1 : #77777d; font-size: 10px; + font-weight: root.active ? 600 : 400; + horizontal-alignment: center; vertical-alignment: center; + } + } + + component PullToRefreshGesture inherits SwipeGestureHandler { + in property at_top; + in property refreshing; + callback refresh(); + enabled: root.at_top && !root.refreshing; + handle-swipe-down: true; + swiped => { root.refresh(); } + @children + if self.swiping: Rectangle { + x: (parent.width - 142px) / 2; y: 8px; width: 142px; height: 32px; + background: #e9e9ee; border-radius: 16px; + Text { text: "Release to refresh"; font-size: 12px; color: #55555b; horizontal-alignment: center; vertical-alignment: center; } + } + } + + export component AppWindow inherits Window { + title: "Gotcha"; + preferred-width: 390px; + preferred-height: 844px; + background: #f2f2f7; + + in-out property tab: "home"; + in-out property page: "servers"; + in-out property <[ServerRow]> servers; + in-out property <[RepositoryRow]> repositories; + in-out property <[IssueRow]> issues; + in-out property <[PullRow]> pulls; + in-out property <[CommentRow]> comments; + in-out property <[CommitRow]> commits; + in-out property <[FileRow]> files; + in-out property <[ActivityRow]> activities; + in-out property <[HeatCell]> heat_cells; + in-out property contribution_count; + in-out property has_active_server; + in-out property home_server; + in-out property server_title; + in-out property repository_title; + in-out property issue_title; + in-out property issue_meta; + in-out property <[MarkdownBlock]> issue_body; + in-out property detail_title; + in-out property detail_meta; + in-out property <[MarkdownBlock]> detail_body; + in-out property <[DiffLine]> diff_lines; + in-out property diff_columns; + in-out property issue_filter: "open"; + in-out property pull_filter: "open"; + in-out property filter_open; + in-out property loading; + in-out property home_loading; + in-out property refreshing; + in-out property error; + in-out property home_scroll_request; + in-out property servers_scroll_request; + in-out property repositories_scroll_request; + in-out property issues_scroll_request; + in-out property issue_scroll_request; + private property home_at_top: true; + private property repositories_at_top: true; + private property issues_at_top: true; + private property issue_at_top: true; + + callback select_tab(string); + callback refresh_home(); + callback refresh_repositories(); + callback refresh_issues(); + callback refresh_issue(); + callback refresh_pulls(); + callback refresh_pull(); + callback refresh_commits(); + callback show_add_server(); + callback save_server(string, string, string); + callback select_server(int); + callback select_repository(string, string); + callback toggle_favorite(string, string); + callback select_issue(int); + callback select_pull(string, string, int); + callback select_commit(string); + callback select_file(string); + callback select_activity(string, string, string, int, string); + callback choose_filter(string); + callback back(); + + if tab == "home": Rectangle { + x: root.safe-area-insets.left; y: root.safe-area-insets.top; + width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; + height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px; + background: #f2f2f7; + Header { x: 0; y: 0; title: root.has_active_server ? root.home_server : "Home"; can_back: false; } + + if !root.has_active_server: EmptyState { + x: 32px; width: parent.width - 64px; y: 210px; height: 190px; + title: "No server selected"; + detail: "Open Issues to select a server or add your first one."; + } + + if root.has_active_server: Rectangle { + x: 16px; y: 72px; width: parent.width - 32px; height: 118px; + background: #ffffff; border-radius: 12px; + Text { x: 13px; y: 10px; text: "Activity · last 12 months"; font-size: 13px; font-weight: 600; color: #38383d; } + Text { x: 13px; y: 86px; text: root.contribution_count + " contributions"; font-size: 12px; color: #74747a; } + for cell in root.heat_cells: Rectangle { + x: 13px + cell.week * 6.7px; y: 39px + cell.day * 6.2px; + width: 5.2px; height: 5.2px; border-radius: 1px; + background: cell.level == 0 ? #e5e5e9 : cell.level == 1 ? #b8d9f4 : cell.level == 2 ? #72b5e8 : cell.level == 3 ? #278bd4 : #0969b7; + } + } + + if root.has_active_server && !root.home_loading && root.activities.length == 0: EmptyState { + x: 32px; width: parent.width - 64px; y: 250px; height: 150px; + title: "No recent activity"; + detail: "Your recent server actions will appear here."; + } + + if root.has_active_server: PullToRefreshGesture { + x: 16px; y: 202px; width: parent.width - 32px; height: parent.height - 214px; + at_top: root.home_at_top; refreshing: root.home_loading; + refresh => { root.refresh_home(); } + home_list := ListView { + property scroll_request: root.home_scroll_request; + width: 100%; height: 100%; mouse-drag-pan-enabled: true; + changed scroll_request => { self.viewport-y = 0px; root.home_at_top = true; } + scrolled => { root.home_at_top = self.viewport-y >= 0px; } + for activity in root.activities: Rectangle { + height: 94px; + background: #ffffff; border-radius: 12px; + TouchArea { + enabled: activity.target != ""; + clicked => { root.select_activity(activity.target, activity.owner, activity.repository, activity.number, activity.sha); } + } + Rectangle { + x: 12px; y: 14px; width: 34px; height: 34px; border-radius: 17px; + background: #e5f2fd; + Text { text: activity.icon; color: #0879e1; font-size: 20px; horizontal-alignment: center; vertical-alignment: center; } + } + Text { x: 57px; y: 11px; width: parent.width - 69px; height: 24px; text: activity.title; font-size: 15px; font-weight: 600; color: #19191d; overflow: elide; } + Text { x: 57px; y: 36px; width: parent.width - 69px; height: 35px; text: activity.detail; font-size: 13px; color: #55555c; wrap: word-wrap; overflow: elide; } + Text { x: 57px; y: 72px; width: parent.width - 69px; text: activity.meta; font-size: 11px; color: #898990; overflow: elide; } + } + } + } + + if root.home_loading: Spinner { width: 34px; height: 34px; x: (parent.width - self.width) / 2; y: 222px; } + } + + if tab == "settings": Rectangle { + x: root.safe-area-insets.left; y: root.safe-area-insets.top; + width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; + height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px; + background: #f2f2f7; + Header { + x: 0; y: 0; can_back: false; + title: "Settings"; + } + EmptyState { + x: 32px; width: parent.width - 64px; y: 220px; height: 160px; + title: "Coming soon"; + detail: "This section is ready for the next feature pass."; + } + } + + if (tab == "issues" || tab == "repositories") && page == "servers": Rectangle { + x: root.safe-area-insets.left; y: root.safe-area-insets.top; + width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; + height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px; + background: #f2f2f7; + Header { x: 0; y: 0; title: "Servers"; can_back: false; } + if root.servers.length > 0: servers_list := ListView { + property scroll_request: root.servers_scroll_request; + x: 16px; y: 76px; width: parent.width - 32px; height: parent.height - 154px; + mouse-drag-pan-enabled: true; + changed scroll_request => { self.viewport-y = 0px; } + for server[index] in root.servers: Rectangle { + height: 76px; + background: #ffffff; + border-color: #dedee3; + border-width: 1px; + border-radius: 12px; + TouchArea { clicked => { root.select_server(index); } } + Text { x: 16px; y: 13px; width: parent.width - 54px; text: server.name; font-size: 17px; font-weight: 600; color: #19191c; overflow: elide; } + Text { x: 16px; y: 42px; width: parent.width - 54px; text: server.url; font-size: 13px; color: #6c6c72; overflow: elide; } + Text { x: parent.width - 28px; text: "›"; font-size: 27px; color: #a0a0a6; vertical-alignment: center; } + } + } + Button { x: 16px; y: parent.height - 66px; width: parent.width - 32px; height: 48px; text: "Add Server"; clicked => { root.show_add_server(); } } + } + + if (tab == "issues" || tab == "repositories") && page == "add": Rectangle { + x: root.safe-area-insets.left; y: root.safe-area-insets.top; + width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; + height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px; + background: #f2f2f7; + Header { x: 0; y: 0; title: "Add Server"; can_back: true; back => { root.back(); } } + VerticalLayout { + x: 20px; y: 92px; width: parent.width - 40px; height: 260px; spacing: 10px; + Text { text: "Name"; font-size: 13px; color: #66666c; } + server_name := LineEdit { placeholder-text: "Work"; } + Text { text: "Server URL"; font-size: 13px; color: #66666c; } + server_url := LineEdit { placeholder-text: "https://gitea.example.com"; } + Text { text: "Access token"; font-size: 13px; color: #66666c; } + server_token := LineEdit { placeholder-text: "Token"; input-type: InputType.password; } + } + Button { + x: 20px; y: 380px; width: parent.width - 40px; height: 48px; + text: root.loading ? "Connecting…" : "Add Server"; + enabled: !root.loading; + clicked => { root.save_server(server_name.text, server_url.text, server_token.text); } + } + EdgeBack { x: 0; y: 0; back => { root.back(); } } + } + + if (tab == "issues" || tab == "repositories") && page == "repositories": Rectangle { + x: root.safe-area-insets.left; y: root.safe-area-insets.top; + width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; + height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px; + background: #f2f2f7; + Header { x: 0; y: 0; title: root.server_title; can_back: true; back => { root.back(); } } + if root.refreshing: Spinner { x: parent.width - 42px; y: 17px; width: 24px; height: 24px; } + if !root.loading && root.repositories.length == 0: EmptyState { + x: 34px; width: parent.width - 68px; y: 220px; height: 160px; + title: "No repositories"; + detail: "This account does not own any repositories on this server."; + } + PullToRefreshGesture { + x: 20px; y: 70px; width: parent.width - 40px; height: parent.height - 82px; + at_top: root.repositories_at_top; refreshing: root.refreshing; + refresh => { root.refresh_repositories(); } + repositories_list := ListView { + property scroll_request: root.repositories_scroll_request; + width: 100%; height: 100%; mouse-drag-pan-enabled: true; + changed scroll_request => { self.viewport-y = 0px; root.repositories_at_top = true; } + scrolled => { root.repositories_at_top = self.viewport-y >= 0px; } + for repo in root.repositories: Rectangle { + height: 104px; + background: #ffffff; + border-color: #dedee3; + border-width: 1px; + border-radius: 12px; + TouchArea { clicked => { root.select_repository(repo.owner, repo.name); } } + Text { x: 15px; y: 11px; width: parent.width - 62px; text: repo.name; font-size: 17px; font-weight: 600; color: #17171a; overflow: elide; } + Text { x: 15px; y: 39px; width: parent.width - 30px; text: repo.description; font-size: 14px; color: #56565c; overflow: elide; } + Text { x: 15px; y: 71px; width: parent.width - 30px; text: repo.meta; font-size: 12px; color: #7b7b82; overflow: elide; } + TouchArea { + x: parent.width - 52px; y: 2px; width: 48px; height: 48px; + clicked => { root.toggle_favorite(repo.owner, repo.name); } + Text { text: repo.favorite ? "★" : "☆"; color: repo.favorite ? #f2a900 : #888890; font-size: 27px; horizontal-alignment: center; vertical-alignment: center; } + } + } + } + } + EdgeBack { x: 0; y: 0; back => { root.back(); } } + } + + if tab == "issues" && page == "issues": Rectangle { + x: root.safe-area-insets.left; y: root.safe-area-insets.top; + width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; + height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px; + background: #f2f2f7; + Header { + x: 0; y: 0; title: root.repository_title; can_back: true; can_filter: true; + back => { root.back(); } + filter => { root.filter_open = true; } + } + if root.refreshing: Spinner { x: parent.width - 42px; y: 17px; width: 24px; height: 24px; } + if !root.loading && root.issues.length == 0: EmptyState { + x: 34px; width: parent.width - 68px; y: 220px; height: 160px; + title: "No " + root.issue_filter + " issues"; + detail: "No issues match the selected status."; + } + PullToRefreshGesture { + x: 20px; y: 70px; width: parent.width - 40px; height: parent.height - 82px; + at_top: root.issues_at_top; refreshing: root.refreshing; + refresh => { root.refresh_issues(); } + issues_list := ListView { + property scroll_request: root.issues_scroll_request; + width: 100%; height: 100%; mouse-drag-pan-enabled: true; + changed scroll_request => { self.viewport-y = 0px; root.issues_at_top = true; } + scrolled => { root.issues_at_top = self.viewport-y >= 0px; } + for issue in root.issues: Rectangle { + height: issue.labels.length > 0 ? 138px : 112px; + background: #ffffff; + border-color: #dedee3; + border-width: 1px; + border-radius: 12px; + TouchArea { clicked => { root.select_issue(issue.number); } } + Text { x: 15px; y: 11px; width: parent.width - 44px; text: issue.title; font-size: 16px; font-weight: 600; color: #17171a; overflow: elide; } + Text { x: 15px; y: 39px; width: parent.width - 30px; height: 38px; text: issue.summary; font-size: 13px; color: #56565c; wrap: word-wrap; overflow: elide; } + if issue.labels.length > 0: HorizontalLayout { + x: 15px; y: 82px; width: parent.width - 30px; height: 22px; spacing: 5px; + for label in issue.labels: Rectangle { + width: min(104px, max(48px, (parent.width - (issue.labels.length - 1) * 5px) / issue.labels.length)); + height: 22px; background: label.background; border-radius: 11px; + Text { x: 7px; width: parent.width - 14px; text: label.name; color: label.foreground; font-size: 11px; font-weight: 600; horizontal-alignment: center; vertical-alignment: center; overflow: elide; } + } + } + Text { x: 15px; y: issue.labels.length > 0 ? 112px : 86px; width: parent.width - 30px; text: issue.meta; font-size: 12px; color: #7b7b82; overflow: elide; } + } + } + } + EdgeBack { x: 0; y: 0; back => { root.back(); } } + } + + if tab == "issues" && page == "issue": Rectangle { + x: root.safe-area-insets.left; y: root.safe-area-insets.top; + width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; + height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px; + background: #f2f2f7; + Header { x: 0; y: 0; title: "Issue"; can_back: true; back => { root.back(); } } + if root.refreshing: Spinner { x: parent.width - 42px; y: 17px; width: 24px; height: 24px; } + PullToRefreshGesture { + x: 18px; y: 78px; width: parent.width - 36px; height: parent.height - 96px; + at_top: root.issue_at_top; refreshing: root.refreshing; + refresh => { root.refresh_issue(); } + issue_scroll := ScrollView { + property scroll_request: root.issue_scroll_request; + width: 100%; height: 100%; mouse-drag-pan-enabled: true; + changed scroll_request => { self.viewport-y = 0px; root.issue_at_top = true; } + scrolled => { root.issue_at_top = self.viewport-y >= 0px; } + VerticalLayout { + width: parent.width; + spacing: 12px; + alignment: start; + Text { text: root.issue_title; font-size: 23px; font-weight: 650; color: #161619; wrap: word-wrap; } + Text { text: root.issue_meta; font-size: 13px; color: #707077; wrap: word-wrap; } + Rectangle { height: 1px; background: #d8d8dd; } + MarkdownContent { width: parent.width; blocks: root.issue_body; } + if root.comments.length > 0: Text { text: "Comments"; font-size: 17px; font-weight: 600; color: #252529; } + for comment in root.comments: CommentCard { width: parent.width; comment: comment; } + } + } + } + EdgeBack { x: 0; y: 0; back => { root.back(); } } + } + + if tab == "pulls" && !root.has_active_server: Rectangle { + x: root.safe-area-insets.left; y: root.safe-area-insets.top; + width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; + height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px; + background: #f2f2f7; + Header { x: 0; y: 0; title: "Home"; can_back: false; } + EmptyState { + x: 32px; width: parent.width - 64px; y: 210px; height: 190px; + title: "No server selected"; + detail: "Open Issues or Repositories to select a server or add your first one."; + } + } + + if tab == "pulls" && root.has_active_server && page == "pulls": Rectangle { + x: root.safe-area-insets.left; y: root.safe-area-insets.top; + width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; + height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px; + background: #f2f2f7; + Header { + x: 0; y: 0; title: "Pull Requests"; can_back: false; can_filter: true; + filter => { root.filter_open = true; } + } + if root.refreshing: Spinner { x: parent.width - 92px; y: 17px; width: 24px; height: 24px; } + if !root.loading && root.pulls.length == 0: EmptyState { + x: 34px; width: parent.width - 68px; y: 220px; height: 160px; + title: "No " + root.pull_filter + " pull requests"; + detail: "No pull requests match the selected status."; + } + PullToRefreshGesture { + x: 16px; y: 70px; width: parent.width - 32px; height: parent.height - 82px; + at_top: root.issues_at_top; refreshing: root.refreshing; + refresh => { root.refresh_pulls(); } + ListView { + width: 100%; height: 100%; mouse-drag-pan-enabled: true; + scrolled => { root.issues_at_top = self.viewport-y >= 0px; } + for pull in root.pulls: Rectangle { + height: 112px; + background: #ffffff; border-color: #dedee3; border-width: 1px; border-radius: 12px; + TouchArea { clicked => { root.select_pull(pull.owner, pull.repository, pull.number); } } + Text { x: 14px; y: 10px; width: parent.width - 28px; text: pull.repository + " #" + pull.number; font-size: 12px; color: #727278; overflow: elide; } + Text { x: 14px; y: 31px; width: parent.width - 28px; text: pull.title; font-size: 16px; font-weight: 600; color: #17171a; overflow: elide; } + Text { x: 14px; y: 57px; width: parent.width - 28px; text: pull.summary; font-size: 13px; color: #56565c; overflow: elide; } + Text { x: 14px; y: 85px; width: parent.width - 28px; text: pull.meta; font-size: 12px; color: #7b7b82; overflow: elide; } + } + } + } + } + + if tab == "pulls" && page == "pull": Rectangle { + x: root.safe-area-insets.left; y: root.safe-area-insets.top; + width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; + height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px; + background: #f2f2f7; + Header { x: 0; y: 0; title: "Pull Request"; can_back: true; back => { root.back(); } } + if root.refreshing: Spinner { x: parent.width - 42px; y: 17px; width: 24px; height: 24px; } + PullToRefreshGesture { + x: 18px; y: 78px; width: parent.width - 36px; height: parent.height - 96px; + at_top: root.issue_at_top; refreshing: root.refreshing; + refresh => { root.refresh_pull(); } + ScrollView { + width: 100%; height: 100%; mouse-drag-pan-enabled: true; + scrolled => { root.issue_at_top = self.viewport-y >= 0px; } + VerticalLayout { + width: parent.width; spacing: 12px; alignment: start; + Text { text: root.detail_title; font-size: 23px; font-weight: 650; color: #161619; wrap: word-wrap; } + Text { text: root.detail_meta; font-size: 13px; color: #707077; wrap: word-wrap; } + Rectangle { height: 1px; background: #d8d8dd; } + MarkdownContent { width: parent.width; blocks: root.detail_body; } + if root.comments.length > 0: Text { text: "Comments"; font-size: 17px; font-weight: 600; color: #252529; } + for comment in root.comments: CommentCard { width: parent.width; comment: comment; } + } + } + } + EdgeBack { x: 0; y: 0; back => { root.back(); } } + } + + if tab == "repositories" && page == "commits": Rectangle { + x: root.safe-area-insets.left; y: root.safe-area-insets.top; + width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; + height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px; + background: #f2f2f7; + Header { x: 0; y: 0; title: root.repository_title; can_back: true; back => { root.back(); } } + if root.refreshing: Spinner { x: parent.width - 42px; y: 17px; width: 24px; height: 24px; } + if !root.loading && root.commits.length == 0: EmptyState { + x: 34px; width: parent.width - 68px; y: 220px; height: 160px; + title: "No commits"; + detail: "This repository has no commit history."; + } + PullToRefreshGesture { + x: 16px; y: 70px; width: parent.width - 32px; height: parent.height - 82px; + at_top: root.repositories_at_top; refreshing: root.refreshing; + refresh => { root.refresh_commits(); } + ListView { + width: 100%; height: 100%; mouse-drag-pan-enabled: true; + scrolled => { root.repositories_at_top = self.viewport-y >= 0px; } + for commit in root.commits: Rectangle { + height: 86px; background: #ffffff; border-color: #dedee3; border-width: 1px; border-radius: 12px; + TouchArea { clicked => { root.select_commit(commit.sha); } } + Text { x: 14px; y: 11px; width: parent.width - 28px; text: commit.title; font-size: 15px; font-weight: 600; color: #17171a; overflow: elide; } + Text { x: 14px; y: 40px; width: parent.width - 28px; text: commit.meta; font-size: 12px; color: #707077; overflow: elide; } + Text { x: 14px; y: 61px; width: parent.width - 28px; text: commit.sha; font-size: 11px; color: #919198; overflow: elide; } + } + } + } + EdgeBack { x: 0; y: 0; back => { root.back(); } } + } + + if tab == "repositories" && page == "files": Rectangle { + x: root.safe-area-insets.left; y: root.safe-area-insets.top; + width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; + height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px; + background: #f2f2f7; + Header { x: 0; y: 0; title: "Changed Files"; can_back: true; back => { root.back(); } } + if !root.loading && root.files.length == 0: EmptyState { + x: 34px; width: parent.width - 68px; y: 220px; height: 160px; + title: "No changed files"; + detail: "This commit does not contain file changes."; + } + ListView { + x: 16px; y: 70px; width: parent.width - 32px; height: parent.height - 82px; mouse-drag-pan-enabled: true; + for file in root.files: Rectangle { + height: 66px; background: #ffffff; border-color: #dedee3; border-width: 1px; border-radius: 10px; + TouchArea { clicked => { root.select_file(file.path); } } + Text { x: 13px; y: 10px; width: parent.width - 26px; text: file.path; font-size: 14px; font-weight: 600; color: #232327; overflow: elide; } + Text { x: 13px; y: 38px; width: parent.width - 26px; text: file.status; font-size: 12px; color: #74747a; } + } + } + EdgeBack { x: 0; y: 0; back => { root.back(); } } + } + + if tab == "repositories" && page == "diff": Rectangle { + x: root.safe-area-insets.left; y: root.safe-area-insets.top; + width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; + height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px; + background: #f2f2f7; + Header { x: 0; y: 0; title: root.detail_title; can_back: true; back => { root.back(); } } + ScrollView { + x: 12px; y: 70px; width: parent.width - 24px; height: parent.height - 82px; mouse-drag-pan-enabled: true; + viewport-width: max(self.visible-width, root.diff_columns * 7px + 100px); + viewport-height: diff_rows.preferred-height; + diff_rows := VerticalLayout { + width: parent.viewport-width; + for line in root.diff_lines: Rectangle { + height: 20px; + background: line.kind == "addition" ? #ddf5e3 : line.kind == "removal" ? #fde2e1 : line.kind == "hunk" ? #dcecff : line.kind == "header" ? #ececf0 : #ffffff; + Text { x: 0; width: 38px; text: line.old_number; font-family: "monospace"; font-size: 11px; color: #85858c; horizontal-alignment: right; vertical-alignment: center; } + Text { x: 42px; width: 38px; text: line.new_number; font-family: "monospace"; font-size: 11px; color: #85858c; horizontal-alignment: right; vertical-alignment: center; } + Rectangle { x: 84px; width: 1px; height: 100%; background: #d5d5da; } + Text { + x: 90px; width: parent.width - 94px; text: line.text; font-family: "monospace"; font-size: 11px; + color: line.kind == "addition" ? #176b2c : line.kind == "removal" ? #a12720 : line.kind == "hunk" ? #175b9e : #202024; + wrap: no-wrap; overflow: clip; vertical-alignment: center; + } + } + } + } + EdgeBack { x: 0; y: 0; back => { root.back(); } } + } + + Rectangle { + x: root.safe-area-insets.left; + y: root.height - root.safe-area-insets.bottom - 58px; + width: root.width - root.safe-area-insets.left - root.safe-area-insets.right; + height: 58px; + background: #fbfbfc; + Rectangle { width: 100%; height: 1px; background: #d4d4d8; } + TabButton { + x: 0; width: parent.width / 5; height: parent.height; icon: "⌂"; label: "Home"; active: root.tab == "home"; + clicked => { + if root.tab == "home" { root.home_scroll_request += 1; } + root.select_tab("home"); + } + } + TabButton { + x: parent.width / 5; width: parent.width / 5; height: parent.height; icon: "!"; label: "Issues"; active: root.tab == "issues"; + clicked => { + if root.tab == "issues" { + if root.page == "servers" { root.servers_scroll_request += 1; } + if root.page == "repositories" { root.repositories_scroll_request += 1; } + if root.page == "issues" { root.issues_scroll_request += 1; } + if root.page == "issue" { root.issue_scroll_request += 1; } + } + root.select_tab("issues"); + } + } + TabButton { x: parent.width * 2 / 5; width: parent.width / 5; height: parent.height; icon: "▱"; label: "Repos"; active: root.tab == "repositories"; clicked => { root.select_tab("repositories"); } } + TabButton { x: parent.width * 3 / 5; width: parent.width / 5; height: parent.height; icon: "↗"; label: "Pulls"; active: root.tab == "pulls"; clicked => { root.select_tab("pulls"); } } + TabButton { x: parent.width * 4 / 5; width: parent.width / 5; height: parent.height; icon: "≡"; label: "Settings"; active: root.tab == "settings"; clicked => { root.select_tab("settings"); } } + } + + if root.loading && (root.tab == "issues" || root.tab == "repositories" || root.tab == "pulls"): Rectangle { + width: 100%; height: 100%; + background: #55ffffff; + TouchArea { } + Spinner { width: 38px; height: 38px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; } + } + if root.filter_open: Rectangle { + width: 100%; height: 100%; background: #66000000; + TouchArea { clicked => { root.filter_open = false; } } + Rectangle { + x: 28px; y: (parent.height - 190px) / 2; width: parent.width - 56px; height: 190px; + background: #ffffff; border-radius: 14px; + Text { x: 18px; y: 16px; width: parent.width - 36px; text: "Status"; font-size: 19px; font-weight: 650; color: #202024; } + VerticalLayout { + x: 18px; y: 54px; width: parent.width - 36px; height: 118px; spacing: 8px; + Button { text: "Open"; clicked => { root.choose_filter("open"); root.filter_open = false; } } + Button { text: "Closed"; clicked => { root.choose_filter("closed"); root.filter_open = false; } } + } + } + } + if root.error != "": Rectangle { + x: 14px; y: parent.height - root.safe-area-insets.bottom - 132px; width: parent.width - 28px; height: 66px; + background: #d93d35; border-radius: 12px; + Text { x: 14px; width: parent.width - 28px; text: root.error; color: white; font-size: 13px; wrap: word-wrap; vertical-alignment: center; } + TouchArea { clicked => { root.error = ""; } } + } + } +} diff --git a/crates/app/src/view.rs b/crates/app/src/view.rs new file mode 100644 index 0000000..61c2eca --- /dev/null +++ b/crates/app/src/view.rs @@ -0,0 +1,518 @@ +use gotcha_gitea::models; +use slint::Color; + +use crate::{ + activity, + domain::{HomeData, IssueDetails, Preferences, PullDetails, State}, + markdown, model, + storage::favorite_key, + ui::*, +}; + +pub fn refresh_servers(ui: &AppWindow, preferences: &Preferences) { + ui.set_servers(model( + preferences + .servers + .iter() + .map(|server| ServerRow { + name: server.name.clone().into(), + url: server.url.clone().into(), + }) + .collect(), + )); +} + +pub fn refresh_repositories(ui: &AppWindow, state: &State) { + let server_url = state + .active_server + .and_then(|i| state.preferences.servers.get(i)) + .map(|server| server.url.as_str()) + .unwrap_or_default(); + let mut repositories = state.repositories.clone(); + repositories.sort_by_key(|repository| { + ( + !state.preferences.favorites.contains(&favorite_key( + server_url, + &repository.owner, + &repository.name, + )), + repository.name.to_lowercase(), + ) + }); + ui.set_repositories(model( + repositories + .into_iter() + .map(|repository| { + let favorite = state.preferences.favorites.contains(&favorite_key( + server_url, + &repository.owner, + &repository.name, + )); + RepositoryRow { + name: repository.name.into(), + owner: repository.owner.into(), + description: repository.description.into(), + meta: format!( + "{} · {} open · {}", + repository.language, repository.open_issues, repository.updated + ) + .into(), + favorite, + } + }) + .collect(), + )); + ui.set_repositories_scroll_request(ui.get_repositories_scroll_request().wrapping_add(1)); +} + +pub fn refresh_issues(ui: &AppWindow, issues: &[models::Issue]) { + ui.set_issues(model( + issues + .iter() + .map(|issue| IssueRow { + number: issue.number.unwrap_or_default() as i32, + title: issue + .title + .clone() + .unwrap_or_else(|| "Untitled issue".into()) + .into(), + summary: issue + .body + .as_deref() + .map(summary) + .unwrap_or_else(|| "No description".into()) + .into(), + meta: issue_meta(issue).into(), + labels: model( + issue + .labels + .as_deref() + .unwrap_or_default() + .iter() + .filter_map(label_row) + .collect(), + ), + }) + .collect(), + )); + ui.set_issues_scroll_request(ui.get_issues_scroll_request().wrapping_add(1)); +} + +pub fn refresh_pulls(ui: &AppWindow, pulls: &[models::Issue]) { + ui.set_pulls(model( + pulls + .iter() + .filter_map(|pull| { + let repository = pull.repository.as_ref()?; + let owner = repository.owner.clone()?; + let name = repository.name.clone()?; + let author = pull + .user + .as_ref() + .and_then(|user| user.login.as_deref()) + .unwrap_or("unknown"); + let kind = if pull + .pull_request + .as_ref() + .and_then(|pull| pull.draft) + .unwrap_or(false) + { + "Draft" + } else { + pull.state.as_deref().unwrap_or("unknown") + }; + Some(PullRow { + number: pull.number.unwrap_or_default() as i32, + owner: owner.into(), + repository: name.into(), + title: pull + .title + .clone() + .unwrap_or_else(|| "Untitled pull request".into()) + .into(), + summary: pull + .body + .as_deref() + .map(summary) + .filter(|body| !body.is_empty()) + .unwrap_or_else(|| "No description".into()) + .into(), + meta: format!( + "{kind} · {author} · {} · {} comments", + compact_date(pull.updated_at.as_deref()), + pull.comments.unwrap_or_default() + ) + .into(), + }) + }) + .collect(), + )); +} + +pub fn refresh_commits(ui: &AppWindow, commits: &[models::Commit]) { + ui.set_commits(model( + commits + .iter() + .filter_map(|commit| { + let sha = commit.sha.as_deref()?; + let details = commit.commit.as_ref(); + let author = details + .and_then(|commit| commit.author.as_ref()) + .and_then(|author| author.name.as_deref()) + .or_else(|| { + commit + .author + .as_ref() + .and_then(|author| author.login.as_deref()) + }) + .unwrap_or("unknown"); + let date = details + .and_then(|commit| commit.author.as_ref()) + .and_then(|author| author.date.as_deref()) + .or(commit.created.as_deref()); + Some(CommitRow { + sha: sha.into(), + title: details + .and_then(|commit| commit.message.as_deref()) + .map(|message| message.lines().next().unwrap_or(message)) + .unwrap_or("Commit") + .into(), + meta: format!("{author} · {}", compact_date(date)).into(), + }) + }) + .collect(), + )); +} + +fn label_row(label: &models::Label) -> Option { + let name = label.name.as_deref()?.trim(); + let hex = label.color.as_deref()?.trim().trim_start_matches('#'); + if name.is_empty() || hex.len() != 6 { + return None; + } + let value = u32::from_str_radix(hex, 16).ok()?; + let (red, green, blue) = ( + ((value >> 16) & 0xff) as u8, + ((value >> 8) & 0xff) as u8, + (value & 0xff) as u8, + ); + let light = red as u32 * 299 + green as u32 * 587 + blue as u32 * 114 > 150_000; + Some(LabelRow { + name: name.into(), + background: Color::from_rgb_u8(red, green, blue), + foreground: if light { + Color::from_rgb_u8(28, 28, 30) + } else { + Color::from_rgb_u8(255, 255, 255) + }, + }) +} + +pub fn refresh_home(ui: &AppWindow, home: HomeData) { + let (cells, contributions) = heat_cells(&home.heatmap); + ui.set_heat_cells(model(cells)); + ui.set_contribution_count(contributions.min(i32::MAX as i64) as i32); + ui.set_activities(model(home.activities.iter().map(activity_row).collect())); + ui.set_home_scroll_request(ui.get_home_scroll_request().wrapping_add(1)); +} + +fn activity_row(activity: &models::Activity) -> ActivityRow { + use models::activity::OpType; + + let repository = activity + .repo + .as_ref() + .and_then(|repo| repo.full_name.as_deref()) + .unwrap_or("repository"); + let branch = activity + .ref_name + .as_deref() + .and_then(|name| name.strip_prefix("refs/heads/").or(Some(name))) + .unwrap_or("default branch"); + let (icon, title) = match activity.op_type { + Some(OpType::CommitRepo) if activity.content.as_deref().unwrap_or("").is_empty() => { + ("↗", format!("Created branch {branch} in {repository}")) + } + Some(OpType::CommitRepo | OpType::MirrorSyncPush) => { + ("↑", format!("Pushed to {branch} in {repository}")) + } + Some(OpType::CreateRepo) => ("▣", format!("Created {repository}")), + Some(OpType::RenameRepo) => ("✎", format!("Renamed {repository}")), + Some(OpType::StarRepo) => ("★", format!("Starred {repository}")), + Some(OpType::WatchRepo) => ("◉", format!("Started watching {repository}")), + Some(OpType::CreateIssue) => ("!", format!("Opened an issue in {repository}")), + Some(OpType::CloseIssue) => ("✓", format!("Closed an issue in {repository}")), + Some(OpType::ReopenIssue) => ("↻", format!("Reopened an issue in {repository}")), + Some(OpType::CommentIssue) => ("●", format!("Commented on an issue in {repository}")), + Some(OpType::CreatePullRequest) => ("↗", format!("Opened a pull request in {repository}")), + Some(OpType::MergePullRequest | OpType::AutoMergePullRequest) => { + ("↝", format!("Merged a pull request in {repository}")) + } + Some(OpType::ClosePullRequest) => ("×", format!("Closed a pull request in {repository}")), + Some(OpType::ReopenPullRequest) => { + ("↻", format!("Reopened a pull request in {repository}")) + } + Some(OpType::CommentPull) => ("●", format!("Commented on a pull request in {repository}")), + Some(OpType::ApprovePullRequest) => { + ("✓", format!("Approved a pull request in {repository}")) + } + Some(OpType::RejectPullRequest) => ("×", format!("Requested changes in {repository}")), + Some(OpType::PushTag) => ("◆", format!("Pushed tag {branch} in {repository}")), + Some(OpType::DeleteTag) => ("−", format!("Deleted tag {branch} in {repository}")), + Some(OpType::DeleteBranch) => ("−", format!("Deleted branch {branch} in {repository}")), + Some(OpType::PublishRelease) => ("◆", format!("Published a release in {repository}")), + Some(_) | None => ("•", format!("Updated {repository}")), + }; + let target = activity::target(activity); + let (target, owner, repository, number, sha) = match target { + Some(activity::Target::Issue { + owner, + repository, + number, + }) => ("issue", owner, repository, number, String::new()), + Some(activity::Target::Pull { + owner, + repository, + number, + }) => ("pull", owner, repository, number, String::new()), + Some(activity::Target::Commit { + owner, + repository, + sha, + }) => ("commit", owner, repository, 0, sha), + None => ("", String::new(), String::new(), 0, String::new()), + }; + ActivityRow { + icon: icon.into(), + title: title.into(), + detail: activity_detail(activity).into(), + meta: compact_date(activity.created.as_deref()).into(), + target: target.into(), + owner: owner.into(), + repository: repository.into(), + number: number.min(i32::MAX as i64) as i32, + sha: sha.into(), + } +} + +fn activity_detail(activity: &models::Activity) -> String { + let text = activity + .comment + .as_ref() + .and_then(|comment| comment.body.as_deref()) + .or(activity.content.as_deref()) + .unwrap_or(""); + if let Ok(payload) = serde_json::from_str::(text) { + let commits = payload.get("Len").and_then(|value| value.as_i64()); + let message = payload + .get("HeadCommit") + .and_then(|commit| commit.get("Message")) + .and_then(|message| message.as_str()) + .map(summary) + .unwrap_or_default(); + if !message.is_empty() { + return match commits { + Some(1) => format!("1 commit · {message}"), + Some(count) => format!("{count} commits · {message}"), + None => message, + }; + } + } + let text = summary(text); + if text.is_empty() { + "Server activity".into() + } else { + text + } +} + +fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec, i64) { + const DAYS: i64 = 52 * 7; + let latest = data + .iter() + .filter_map(|entry| entry.timestamp) + .max() + .unwrap_or(0) + / 86_400; + let start = latest - DAYS + 1; + let mut counts = vec![0_i64; DAYS as usize]; + for entry in data { + let day = entry.timestamp.unwrap_or_default() / 86_400; + if (start..=latest).contains(&day) { + counts[(day - start) as usize] += entry.contributions.unwrap_or_default(); + } + } + let maximum = counts.iter().copied().max().unwrap_or_default(); + let contributions = counts.iter().sum(); + let cells = counts + .into_iter() + .enumerate() + .map(|(index, count)| HeatCell { + week: (index / 7) as i32, + day: (index % 7) as i32, + level: if count == 0 || maximum == 0 { + 0 + } else { + ((count * 4 + maximum - 1) / maximum).clamp(1, 4) as i32 + }, + }) + .collect(); + (cells, contributions) +} + +pub fn show_issue(ui: &AppWindow, details: &IssueDetails) { + let issue = &details.issue; + ui.set_issue_title( + issue + .title + .clone() + .unwrap_or_else(|| "Untitled issue".into()) + .into(), + ); + ui.set_issue_meta(issue_meta(issue).into()); + ui.set_issue_body(model(markdown::render( + issue + .body + .as_deref() + .filter(|body| !body.is_empty()) + .unwrap_or("No description provided."), + ))); + ui.set_comments(model(details.comments.iter().map(comment_row).collect())); + ui.set_issue_scroll_request(ui.get_issue_scroll_request().wrapping_add(1)); + ui.set_page("issue".into()); +} + +pub fn show_pull(ui: &AppWindow, details: &PullDetails) { + let pull = &details.pull; + let author = pull + .user + .as_ref() + .and_then(|user| user.login.as_deref()) + .unwrap_or("unknown"); + let head = pull + .head + .as_ref() + .and_then(|branch| branch.label.as_deref().or(branch.r#ref.as_deref())) + .unwrap_or("head"); + let base = pull + .base + .as_ref() + .and_then(|branch| branch.label.as_deref().or(branch.r#ref.as_deref())) + .unwrap_or("base"); + let state = if pull.merged.unwrap_or(false) { + "merged" + } else if pull.draft.unwrap_or(false) { + "draft" + } else { + pull.state.as_deref().unwrap_or("unknown") + }; + ui.set_detail_title( + pull.title + .clone() + .unwrap_or_else(|| "Untitled pull request".into()) + .into(), + ); + ui.set_detail_meta( + format!( + "#{} · {state} · {author} · {head} → {base}\n{} files · +{} −{} · {} comments", + pull.number.unwrap_or_default(), + pull.changed_files.unwrap_or_default(), + pull.additions.unwrap_or_default(), + pull.deletions.unwrap_or_default(), + pull.comments.unwrap_or_default(), + ) + .into(), + ); + ui.set_detail_body(model(markdown::render( + pull.body + .as_deref() + .filter(|body| !body.is_empty()) + .unwrap_or("No description provided."), + ))); + ui.set_comments(model(details.comments.iter().map(comment_row).collect())); + ui.set_page("pull".into()); +} + +fn comment_row(comment: &models::Comment) -> CommentRow { + CommentRow { + author: comment + .user + .as_ref() + .and_then(|user| user.login.as_deref()) + .or(comment.original_author.as_deref()) + .unwrap_or("unknown") + .into(), + body: model(markdown::render( + comment + .body + .as_deref() + .filter(|body| !body.is_empty()) + .unwrap_or("No comment text."), + )), + meta: compact_date( + comment + .updated_at + .as_deref() + .or(comment.created_at.as_deref()), + ) + .into(), + } +} + +fn issue_meta(issue: &models::Issue) -> String { + let author = issue + .user + .as_ref() + .and_then(|user| user.login.as_deref()) + .unwrap_or("unknown"); + format!( + "#{} · {} · updated {} · {} comments", + issue.number.unwrap_or_default(), + author, + compact_date(issue.updated_at.as_deref()), + issue.comments.unwrap_or_default() + ) +} + +fn summary(body: &str) -> String { + body.split_whitespace() + .take(24) + .collect::>() + .join(" ") +} + +pub fn compact_date(date: Option<&str>) -> String { + date.and_then(|date| date.get(..10)) + .unwrap_or("unknown") + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_heat_levels_and_labels() { + let heatmap = vec![ + models::UserHeatmapData { + timestamp: Some(100 * 86_400), + contributions: Some(1), + }, + models::UserHeatmapData { + timestamp: Some(101 * 86_400), + contributions: Some(4), + }, + ]; + let (cells, total) = heat_cells(&heatmap); + assert_eq!((cells.len(), total), (364, 5)); + assert_eq!((cells[362].level, cells[363].level), (1, 4)); + + let label = models::Label { + name: Some("bug".into()), + color: Some("d73a4a".into()), + ..Default::default() + }; + assert!(label_row(&label).is_some()); + assert!(label_row(&models::Label::default()).is_none()); + } +}