use gotcha_gitea::models; use crate::{ activity, domain::{ HistoryCommit, HomeData, IssueDetails, MilestoneDetails, PullDetails, RepositoryData, }, }; #[derive(Clone, uniffi::Record)] pub struct ServerRow { pub name: String, pub url: String, } #[derive(Clone, uniffi::Record)] pub struct RepositoryRow { pub name: String, pub owner: String, pub description: String, pub meta: String, pub favorite: bool, } #[derive(Clone, uniffi::Record)] pub struct LabelRow { pub name: String, pub color: String, pub light: bool, } #[derive(Clone, uniffi::Record)] pub struct IssueRow { pub number: i64, pub title: String, pub summary: String, pub meta: String, pub milestone: String, pub labels: Vec, } #[derive(Clone, uniffi::Record)] pub struct PullRow { pub number: i64, pub owner: String, pub repository: String, pub title: String, pub summary: String, pub meta: String, } #[derive(Clone, uniffi::Record)] pub struct CommentRow { pub author: String, pub body: String, pub meta: String, } #[derive(Clone, uniffi::Record)] pub struct IssuePage { pub title: String, pub meta: String, pub milestone: String, pub body: String, pub comments: Vec, } #[derive(Clone, uniffi::Record)] pub struct MilestoneRow { pub id: i64, pub title: String, pub description: String, pub meta: String, pub open_issues: i64, pub closed_issues: i64, } #[derive(Clone, uniffi::Record)] pub struct MilestonePage { pub milestone: MilestoneRow, pub issues: Vec, } #[derive(Clone, uniffi::Record)] pub struct PullPage { pub title: String, pub meta: String, pub body: String, pub files_ref: String, pub files: Vec, pub comments: Vec, } #[derive(Clone, uniffi::Record)] pub struct CommitRow { pub sha: String, pub title: String, pub meta: String, pub refs: String, pub top_lanes: Vec, pub bottom_lanes: Vec, pub node_lane: Option, pub connections: Vec, } #[derive(Clone, uniffi::Record)] pub struct CommitPage { pub branches: Vec, pub commits: Vec, pub lane_count: u32, } #[derive(Clone, uniffi::Record)] pub struct FileRow { pub path: String, pub status: String, } #[derive(Clone, uniffi::Record)] pub struct RepositoryContentRow { pub name: String, pub path: String, pub kind: String, pub size: i64, } #[derive(Clone, uniffi::Record)] pub struct DiffLine { pub old_number: String, pub new_number: String, pub text: String, pub kind: String, } #[derive(Clone, uniffi::Record)] pub struct DiffPage { pub title: String, pub columns: u32, pub lines: Vec, } #[derive(Clone, uniffi::Record)] pub struct ActivityRow { pub icon: String, pub title: String, pub detail: String, pub meta: String, pub target: String, pub owner: String, pub repository: String, pub number: i64, pub sha: String, } #[derive(Clone, uniffi::Record)] pub struct HeatCell { pub week: u32, pub day: u32, pub level: u32, pub timestamp: i64, pub contributions: i64, } #[derive(Clone, uniffi::Record)] pub struct HomePage { pub server_name: String, pub activities: Vec, pub heat_cells: Vec, pub contribution_count: i64, } pub fn repository_rows( repositories: &[RepositoryData], is_favorite: impl Fn(&RepositoryData) -> bool, ) -> Vec { let mut repositories = repositories.to_vec(); repositories .sort_by_key(|repository| (!is_favorite(repository), repository.name.to_lowercase())); repositories .into_iter() .map(|repository| RepositoryRow { favorite: is_favorite(&repository), meta: format!( "{} · {} open · {}", repository.language, repository.open_issues, repository.updated ), name: repository.name, owner: repository.owner, description: repository.description, }) .collect() } pub fn issue_rows(issues: &[models::Issue]) -> Vec { issues .iter() .map(|issue| IssueRow { number: issue.number.unwrap_or_default(), title: issue .title .clone() .unwrap_or_else(|| "Untitled issue".into()), summary: issue .body .as_deref() .map(summary) .unwrap_or_else(|| "No description".into()), meta: issue_meta(issue), milestone: issue_milestone(issue), labels: issue .labels .as_deref() .unwrap_or_default() .iter() .filter_map(label_row) .collect(), }) .collect() } pub fn milestone_rows(milestones: &[models::Milestone]) -> Vec { let mut milestones = milestones.to_vec(); milestones.sort_by_key(|milestone| { ( milestone.state.as_deref() == Some("closed"), milestone .title .as_deref() .unwrap_or_default() .to_lowercase(), ) }); milestones.iter().map(milestone_row).collect() } pub fn milestone_page(details: MilestoneDetails) -> MilestonePage { MilestonePage { milestone: milestone_row(&details.milestone), issues: issue_rows(&details.issues), } } pub fn pull_rows(pulls: &[models::Issue]) -> Vec { pulls .iter() .filter_map(|pull| { let repository = pull.repository.as_ref()?; 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(), owner: repository.owner.clone()?, repository: repository.name.clone()?, title: pull .title .clone() .unwrap_or_else(|| "Untitled pull request".into()), summary: pull .body .as_deref() .map(summary) .filter(|body| !body.is_empty()) .unwrap_or_else(|| "No description".into()), meta: format!( "{kind} · {author} · {} · {} comments", compact_date(pull.updated_at.as_deref()), pull.comments.unwrap_or_default() ), }) }) .collect() } pub fn commit_page( branches: Vec, commits: &[HistoryCommit], show_graph: bool, ) -> CommitPage { let lane_count = if show_graph { commits .iter() .flat_map(|commit| { commit .top_lanes .iter() .chain(commit.bottom_lanes.iter()) .chain(commit.connections.iter()) .chain(commit.node_lane.iter()) }) .max() .map_or(0, |lane| lane + 1) } else { 0 }; let commits = commits .iter() .filter_map(|row| { let commit = &row.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)), refs: row.refs.join(" · "), top_lanes: indices(&row.top_lanes), bottom_lanes: indices(&row.bottom_lanes), node_lane: row.node_lane.map(|lane| lane as u32), connections: indices(&row.connections), }) }) .collect(); CommitPage { branches, commits, lane_count: lane_count as u32, } } pub fn issue_page(details: IssueDetails) -> IssuePage { IssuePage { title: details .issue .title .clone() .unwrap_or_else(|| "Untitled issue".into()), meta: issue_meta(&details.issue), milestone: issue_milestone(&details.issue), body: details .issue .body .filter(|body| !body.is_empty()) .unwrap_or_else(|| "No description provided.".into()), comments: details.comments.iter().map(comment_row).collect(), } } pub fn pull_page(details: PullDetails) -> PullPage { 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") }; PullPage { title: pull .title .clone() .unwrap_or_else(|| "Untitled pull request".into()), 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(), ), body: pull .body .clone() .filter(|body| !body.is_empty()) .unwrap_or_else(|| "No description provided.".into()), files_ref: pull_files_ref(pull), files: details.files.iter().filter_map(file_row).collect(), comments: details.comments.iter().map(comment_row).collect(), } } pub fn file_rows(files: Vec) -> Vec { files.iter().filter_map(file_row).collect() } pub fn commit_file_rows(files: Vec) -> Vec { files .into_iter() .filter_map(|file| { Some(FileRow { path: file.filename?, status: file.status.unwrap_or_else(|| "modified".into()), }) }) .collect() } pub fn repository_content_rows( contents: Vec, ) -> Vec { let mut rows: Vec<_> = contents .into_iter() .filter_map(|content| { Some(RepositoryContentRow { name: content.name?, path: content.path?, kind: content.r#type.unwrap_or_else(|| "file".into()), size: content.size.unwrap_or_default(), }) }) .collect(); rows.sort_by_key(|row| (row.kind != "dir", row.name.to_lowercase())); rows } pub fn diff_page(path: &str, diff: crate::diff::Parsed) -> DiffPage { DiffPage { title: path.rsplit('/').next().unwrap_or(path).into(), columns: diff.columns.min(u32::MAX as usize) as u32, lines: diff .lines .into_iter() .map(|line| DiffLine { old_number: line.old_number, new_number: line.new_number, text: line.text, kind: line.kind.into(), }) .collect(), } } pub fn home_page(server_name: String, home: HomeData) -> HomePage { let (heat_cells, contribution_count) = heat_cells(&home.heatmap); HomePage { server_name, activities: home.activities.iter().map(activity_row).collect(), heat_cells, contribution_count, } } 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() => ( "branch-create", format!("Created branch {branch} in {repository}"), ), Some(OpType::CommitRepo | OpType::MirrorSyncPush) => { ("push", format!("Pushed to {branch} in {repository}")) } Some(OpType::CreateRepo) => ("repo-create", format!("Created {repository}")), Some(OpType::RenameRepo) => ("repo-rename", format!("Renamed {repository}")), Some(OpType::StarRepo) => ("repo-star", format!("Starred {repository}")), Some(OpType::WatchRepo) => ("repo-watch", format!("Started watching {repository}")), Some(OpType::CreateIssue) => ("issue-open", format!("Opened an issue in {repository}")), Some(OpType::CloseIssue) => ("issue-close", format!("Closed an issue in {repository}")), Some(OpType::ReopenIssue) => ("issue-reopen", format!("Reopened an issue in {repository}")), Some(OpType::CommentIssue) => ( "issue-comment", format!("Commented on an issue in {repository}"), ), Some(OpType::CreatePullRequest) => ( "pull-open", format!("Opened a pull request in {repository}"), ), Some(OpType::MergePullRequest | OpType::AutoMergePullRequest) => ( "pull-merge", format!("Merged a pull request in {repository}"), ), Some(OpType::ClosePullRequest) => ( "pull-close", format!("Closed a pull request in {repository}"), ), Some(OpType::ReopenPullRequest) => ( "pull-reopen", format!("Reopened a pull request in {repository}"), ), Some(OpType::CommentPull) => ( "pull-comment", format!("Commented on a pull request in {repository}"), ), Some(OpType::ApprovePullRequest) => ( "pull-approve", format!("Approved a pull request in {repository}"), ), Some(OpType::RejectPullRequest) => { ("pull-reject", format!("Requested changes in {repository}")) } Some(OpType::PushTag) => ("tag-push", format!("Pushed tag {branch} in {repository}")), Some(OpType::DeleteTag) => ( "tag-delete", format!("Deleted tag {branch} in {repository}"), ), Some(OpType::DeleteBranch) => ( "branch-delete", format!("Deleted branch {branch} in {repository}"), ), Some(OpType::PublishRelease) => ("release", format!("Published a release in {repository}")), Some(_) | None => ("update", format!("Updated {repository}")), }; let target = activity::target(activity); let (target, owner, repository, number, sha) = match target { Some(activity::Target::Repository { owner, repository }) => { ("repository", owner, repository, 0, String::new()) } 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, detail: activity_detail(activity), meta: compact_date(activity.created.as_deref()), target: target.into(), owner, repository, number, sha, } } 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 = 9 * 31; 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 contribution_count = counts.iter().sum(); let cells = counts .into_iter() .enumerate() .map(|(index, count)| HeatCell { week: (index / 7) as u32, day: (index % 7) as u32, level: if count == 0 || maximum == 0 { 0 } else { ((count * 4 + maximum - 1) / maximum).clamp(1, 4) as u32 }, timestamp: (start + index as i64) * 86_400, contributions: count, }) .collect(); (cells, contribution_count) } fn label_row(label: &models::Label) -> Option { let name = label.name.as_deref()?.trim(); let color = label.color.as_deref()?.trim().trim_start_matches('#'); if name.is_empty() || color.len() != 6 { return None; } let value = u32::from_str_radix(color, 16).ok()?; let red = (value >> 16) & 0xff; let green = (value >> 8) & 0xff; let blue = value & 0xff; Some(LabelRow { name: name.into(), color: format!("#{color}"), light: red * 299 + green * 587 + blue * 114 > 150_000, }) } 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: comment .body .clone() .filter(|body| !body.is_empty()) .unwrap_or_else(|| "No comment text.".into()), meta: compact_date( comment .updated_at .as_deref() .or(comment.created_at.as_deref()), ), } } fn file_row(file: &models::ChangedFile) -> Option { Some(FileRow { path: file.filename.clone()?, status: file.status.clone().unwrap_or_else(|| "modified".into()), }) } fn pull_files_ref(pull: &models::PullRequest) -> String { if pull.state.as_deref() == Some("open") { let branch = pull .head .as_ref() .and_then(|head| head.r#ref.as_deref().or(head.label.as_deref())) .unwrap_or("head branch"); format!("Files on {branch}") } else if let Some(sha) = pull.merge_commit_sha.as_deref() { format!("Files at merge commit {}", &sha[..sha.len().min(8)]) } else { "Files from pull request".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 issue_milestone(issue: &models::Issue) -> String { issue .milestone .as_ref() .and_then(|milestone| milestone.title.as_deref()) .unwrap_or_default() .into() } fn milestone_row(milestone: &models::Milestone) -> MilestoneRow { let open = milestone.open_issues.unwrap_or_default(); let closed = milestone.closed_issues.unwrap_or_default(); let due = milestone .due_on .as_deref() .map(|date| format!("due {}", compact_date(Some(date)))) .unwrap_or_else(|| "no due date".into()); MilestoneRow { id: milestone.id.unwrap_or_default(), title: milestone .title .clone() .unwrap_or_else(|| "Untitled milestone".into()), description: milestone .description .clone() .filter(|description| !description.is_empty()) .unwrap_or_else(|| "No description".into()), meta: format!( "{} · {closed} of {} closed · {due}", milestone.state.as_deref().unwrap_or("unknown"), open + closed ), open_issues: open, closed_issues: closed, } } fn summary(body: &str) -> String { body.split_whitespace() .take(24) .collect::>() .join(" ") } fn indices(values: &[usize]) -> Vec { values.iter().map(|value| *value as u32).collect() } 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 repository_directories_sort_before_files() { let contents = [ ("z.swift", "file"), ("Sources", "dir"), ("README.md", "file"), ] .into_iter() .map(|(name, kind)| models::ContentsResponse { name: Some(name.into()), path: Some(name.into()), r#type: Some(kind.into()), ..Default::default() }) .collect(); assert_eq!( repository_content_rows(contents) .into_iter() .map(|row| row.name) .collect::>(), ["Sources", "README.md", "z.swift"] ); } #[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), (279, 5)); assert_eq!( ( cells[277].level, cells[277].timestamp, cells[277].contributions, cells[278].level, ), (1, 100 * 86_400, 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()); let milestone = models::Milestone { id: Some(1), title: Some("Version 1".into()), open_issues: Some(3), closed_issues: Some(2), state: Some("open".into()), ..Default::default() }; let milestone_row = milestone_rows(std::slice::from_ref(&milestone)).remove(0); assert_eq!( (milestone_row.open_issues, milestone_row.closed_issues), (3, 2) ); let issue = models::Issue { milestone: Some(Box::new(milestone)), ..Default::default() }; assert_eq!(issue_rows(&[issue])[0].milestone, "Version 1"); } }