use gotcha_gitea::{ PullFileSource, activity, comment_can_edit, diff, models, pull_file_source, pull_state, }; use crate::domain::{ HistoryCommit, HomeData, IssueDetails, IssueEditorData, IssueFilter, MilestoneDetails, PullDetails, PullFilter, RepositoryData, civil_from_days, days_from_civil, parse_api_date, }; #[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 RepositoryListPage { pub rows: Vec, pub has_more: 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 state: String, pub title: String, pub summary: String, pub meta: String, pub milestone: String, pub labels: Vec, } #[derive(Clone, uniffi::Record)] pub struct IssueListPage { pub rows: Vec, pub has_more: bool, } #[derive(Clone, uniffi::Record)] pub struct PullRow { pub number: i64, pub owner: String, pub repository: String, pub state: String, pub title: String, pub summary: String, pub meta: String, } #[derive(Clone, uniffi::Record)] pub struct CommentRow { pub id: i64, pub author: String, pub body: String, pub meta: String, pub can_edit: bool, } #[derive(Clone, uniffi::Record)] pub struct IssuePage { pub title: String, pub state: String, pub meta: String, pub milestone: String, pub labels: Vec, pub body: String, pub comments: Vec, pub has_more: bool, } #[derive(Clone, uniffi::Record)] pub struct IssueEditorLabel { pub id: i64, pub name: String, pub selected: bool, } #[derive(Clone, uniffi::Record)] pub struct IssueEditorMilestone { pub id: i64, pub title: String, pub selected: bool, } #[derive(Clone, uniffi::Record)] pub struct IssueEditorPage { pub title: String, pub body: String, pub due_date: Option, pub closed: bool, pub labels: Vec, pub milestones: Vec, } #[derive(Clone, uniffi::Record)] pub struct IssueFilterOptions { pub milestones: Vec, pub labels: Vec, pub unavailable_labels: Vec, pub selected_milestone: String, pub selected_labels: Vec, pub search_text: String, } #[derive(Clone, uniffi::Record)] pub struct MilestoneRow { pub id: i64, pub state: String, pub title: String, pub description: String, pub meta: String, pub has_issues: bool, pub progress: f64, pub progress_accessibility: String, } #[derive(Clone, uniffi::Record)] pub struct MilestonePage { pub milestone: MilestoneRow, pub issues: Vec, pub pulls: Vec, pub has_more: bool, } #[derive(Clone, uniffi::Record)] pub struct MilestoneListPage { pub rows: Vec, pub has_more: bool, } #[derive(Clone, uniffi::Record)] pub struct MilestoneEditorPage { pub title: String, pub description: String, pub due_date: Option, pub closed: bool, } #[derive(Clone, uniffi::Record)] pub struct PullFilterOptions { pub milestones: Vec, pub selected_milestone: String, pub search_text: String, } #[derive(Clone, uniffi::Record)] pub struct PullPage { pub title: String, pub state: String, pub meta: String, pub body: String, pub files_ref: String, pub files: Vec, pub comments: Vec, pub has_more: bool, } #[derive(Clone, uniffi::Record)] pub struct PullListPage { pub rows: Vec, pub has_more: bool, } #[derive(Clone, uniffi::Record)] pub struct CommitRow { pub sha: String, pub title: String, pub detail: String, pub branch_label: Option, pub top_lanes: Vec, pub bottom_lanes: Vec, pub node_lane: Option, pub top_connections: Vec, pub bottom_connections: Vec, } #[derive(Clone, uniffi::Record)] pub struct CommitPage { pub branches: Vec, pub commits: Vec, pub lane_count: u32, pub has_more: bool, } #[derive(Clone, uniffi::Record)] pub struct CommitMetadataRow { pub label: String, pub value: String, } #[derive(Clone, uniffi::Record)] pub struct CommitDetailsPage { pub title: String, pub description: String, pub metadata: Vec, pub files: Vec, } #[derive(Clone, uniffi::Record)] pub struct FileRow { pub path: String, pub status: String, } #[derive(Clone, uniffi::Record)] pub struct FileListPage { pub rows: Vec, pub has_more: bool, } #[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 RepositoryFilePage { pub name: String, pub kind: String, pub language: String, pub text: String, pub data: Vec, } #[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, Copy, Debug, uniffi::Enum)] pub enum HomeActivityFilter { All, Issues, PullRequests, } impl From for gotcha_gitea::ActivityFilter { fn from(filter: HomeActivityFilter) -> Self { match filter { HomeActivityFilter::All => Self::All, HomeActivityFilter::Issues => Self::Issues, HomeActivityFilter::PullRequests => Self::PullRequests, } } } #[derive(Clone, uniffi::Record)] pub struct HeatCell { pub level: u32, pub timestamp: i64, } #[derive(Clone, uniffi::Record)] pub struct HomePage { pub server_name: String, pub activities: Vec, pub heat_cells: Vec, pub contribution_count: i64, pub next_page: Option, } 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(), state: issue.state.clone().unwrap_or_else(|| "unknown".into()), 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 issue_filter_options( labels: &[models::Label], milestones: &[models::Milestone], filter: &IssueFilter, ) -> IssueFilterOptions { let mut labels: Vec<_> = labels .iter() .filter_map(|label| label.name.clone()) .collect(); let unavailable_labels = filter .labels .iter() .filter(|label| !labels.contains(label)) .cloned() .collect(); labels.extend(filter.labels.iter().cloned()); labels.sort_by_key(|label| label.to_lowercase()); labels.dedup(); let mut milestones: Vec<_> = milestones .iter() .filter_map(|milestone| milestone.title.clone()) .collect(); if !filter.milestone.is_empty() { milestones.push(filter.milestone.clone()); } milestones.sort_by_key(|milestone| milestone.to_lowercase()); milestones.dedup(); IssueFilterOptions { milestones, labels, unavailable_labels, selected_milestone: filter.milestone.clone(), selected_labels: filter.labels.iter().cloned().collect(), search_text: filter.search_text.clone(), } } 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), pulls: pull_rows(&details.pulls), has_more: details.has_more, } } pub fn milestone_editor_page(milestone: Option) -> MilestoneEditorPage { MilestoneEditorPage { title: milestone .as_ref() .and_then(|milestone| milestone.title.clone()) .unwrap_or_default(), description: milestone .as_ref() .and_then(|milestone| milestone.description.clone()) .unwrap_or_default(), due_date: milestone .as_ref() .and_then(|milestone| milestone.due_on.as_deref()) .and_then(parse_api_date), closed: milestone .as_ref() .and_then(|milestone| milestone.state.as_deref()) == Some("closed"), } } pub fn pull_filter_options(milestones: &[String], filter: &PullFilter) -> PullFilterOptions { let mut milestones = milestones.to_vec(); if !filter.milestone.is_empty() { milestones.push(filter.milestone.clone()); } milestones.sort_by_key(|milestone| milestone.to_lowercase()); milestones.dedup(); PullFilterOptions { milestones, selected_milestone: filter.milestone.clone(), search_text: filter.search_text.clone(), } } 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()?, state: pull.state.clone().unwrap_or_else(|| "unknown".into()), title: format!( "{} #{}\n{}", repository.name.as_deref()?, pull.number.unwrap_or_default(), pull.title.as_deref().unwrap_or("Untitled pull request") ), 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, has_more: bool, ) -> CommitPage { let lane_count = if show_graph { commits .iter() .flat_map(|commit| { commit .top_lanes .iter() .chain(commit.bottom_lanes.iter()) .chain(commit.top_connections.iter()) .chain(commit.bottom_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()); let mut branch_labels = row.refs.clone(); for label in &row.branch_starts { if !branch_labels.contains(label) { branch_labels.push(label.clone()); } } 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(), detail: if !branch_labels.is_empty() { format!("{author} · {} · {}", compact_date(date), short_sha(sha)) } else { format!("{author} · {}\n{}", compact_date(date), short_sha(sha)) }, branch_label: (!branch_labels.is_empty()).then(|| branch_labels.join(" · ")), top_lanes: indices(&row.top_lanes), bottom_lanes: indices(&row.bottom_lanes), node_lane: row.node_lane.map(|lane| lane as u32), top_connections: indices(&row.top_connections), bottom_connections: indices(&row.bottom_connections), }) }) .collect(); CommitPage { branches, commits, lane_count: lane_count as u32, has_more, } } pub fn issue_page(details: IssueDetails) -> IssuePage { IssuePage { title: details .issue .title .clone() .unwrap_or_else(|| "Untitled issue".into()), state: details .issue .state .clone() .unwrap_or_else(|| "unknown".into()), meta: issue_meta(&details.issue), milestone: issue_milestone(&details.issue), labels: details .issue .labels .as_deref() .unwrap_or_default() .iter() .filter_map(label_row) .collect(), body: details .issue .body .filter(|body| !body.is_empty()) .unwrap_or_else(|| "No description provided.".into()), comments: details .comments .iter() .map(|comment| comment_row(comment, details.viewer_id)) .collect(), has_more: details.has_more, } } pub fn issue_editor_page(data: IssueEditorData) -> IssueEditorPage { let selected_labels: std::collections::BTreeSet<_> = data .issue .as_ref() .and_then(|issue| issue.labels.as_deref()) .unwrap_or_default() .iter() .filter_map(|label| label.id) .collect(); let selected_milestone = data .issue .as_ref() .and_then(|issue| issue.milestone.as_ref()) .and_then(|milestone| milestone.id); let mut labels: Vec<_> = data .labels .into_iter() .filter_map(|label| { let id = label.id?; Some(IssueEditorLabel { id, name: label.name?, selected: selected_labels.contains(&id), }) }) .collect(); labels.sort_by_key(|label| label.name.to_lowercase()); let mut milestones: Vec<_> = data .milestones .into_iter() .filter(|milestone| { milestone.state.as_deref() != Some("closed") || milestone.id == selected_milestone }) .filter_map(|milestone| { let id = milestone.id?; Some(IssueEditorMilestone { id, title: milestone.title?, selected: Some(id) == selected_milestone, }) }) .collect(); milestones.sort_by_key(|milestone| milestone.title.to_lowercase()); IssueEditorPage { title: data .issue .as_ref() .and_then(|issue| issue.title.clone()) .unwrap_or_default(), body: data .issue .as_ref() .and_then(|issue| issue.body.clone()) .unwrap_or_default(), due_date: data .issue .as_ref() .and_then(|issue| issue.due_date.as_deref()) .and_then(parse_api_date), closed: data.issue.as_ref().and_then(|issue| issue.state.as_deref()) == Some("closed"), labels, milestones, } } 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 = pull_state(pull); PullPage { title: pull .title .clone() .unwrap_or_else(|| "Untitled pull request".into()), state: pull.state.clone().unwrap_or_else(|| "unknown".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| comment_row(comment, None)) .collect(), has_more: details.has_more, } } 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 commit_details_page(commit: models::Commit, branch: Option) -> CommitDetailsPage { let details = commit.commit.as_deref(); let message = details .and_then(|details| details.message.as_deref()) .unwrap_or("Commit"); let mut lines = message.lines(); let title = lines.next().unwrap_or("Commit").to_string(); let description = lines.collect::>().join("\n").trim().to_string(); let author = details .and_then(|details| details.author.as_deref()) .and_then(commit_user) .or_else(|| { commit .author .as_deref() .and_then(|author| author.login.clone()) }); let committer = details .and_then(|details| details.committer.as_deref()) .and_then(commit_user) .or_else(|| { commit .committer .as_deref() .and_then(|committer| committer.login.clone()) }); let date = details .and_then(|details| details.committer.as_deref()) .and_then(|committer| committer.date.as_deref()) .or_else(|| { details .and_then(|details| details.author.as_deref()) .and_then(|author| author.date.as_deref()) }) .or(commit.created.as_deref()); let files = commit.files.unwrap_or_default(); let mut metadata = Vec::new(); if let Some(author) = author { metadata.push(metadata_row("Author", author)); } if let Some(committer) = committer { metadata.push(metadata_row("Committer", committer)); } if date.is_some() { metadata.push(metadata_row("Committed", compact_date(date))); } if let Some(branch) = branch.filter(|branch| !branch.is_empty()) { metadata.push(metadata_row("Branch", branch)); } metadata.push(metadata_row( "Commit", commit.sha.clone().unwrap_or_else(|| "unknown".into()), )); let verification = details.and_then(|details| details.verification.as_deref()); metadata.push(metadata_row( "Signature", match verification.filter(|verification| { verification .signature .as_deref() .is_some_and(|signature| !signature.is_empty()) }) { Some(verification) if verification.verified.unwrap_or(false) => "Verified".into(), Some(_) => "Unverified".into(), None => "Unsigned".into(), }, )); CommitDetailsPage { title, description, metadata, files: commit_file_rows(files), } } fn commit_user(user: &models::CommitUser) -> Option { match (user.name.as_deref(), user.email.as_deref()) { (Some(name), Some(email)) => Some(format!("{name} <{email}>")), (Some(name), None) => Some(name.into()), (None, Some(email)) => Some(email.into()), (None, None) => None, } } fn metadata_row(label: &str, value: String) -> CommitMetadataRow { CommitMetadataRow { label: label.into(), value, } } 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 repository_file_page(path: &str, data: Vec) -> RepositoryFilePage { let name = path.rsplit('/').next().unwrap_or("Preview").to_string(); let extension = path.rsplit('.').next().unwrap_or_default().to_lowercase(); let language = source_language(path).unwrap_or_default().to_string(); let markdown = matches!(extension.as_str(), "md" | "markdown" | "mdown" | "mkd"); let media = matches!( extension.as_str(), "apng" | "avi" | "bmp" | "flac" | "gif" | "heic" | "heif" | "jpeg" | "jpg" | "m4a" | "m4v" | "mkv" | "mov" | "mp3" | "mp4" | "mpeg" | "mpg" | "ogg" | "pdf" | "png" | "tif" | "tiff" | "wav" | "webm" | "webp" ); if media { return RepositoryFilePage { name, kind: "preview".into(), language, text: String::new(), data, }; } match String::from_utf8(data) { Ok(text) => RepositoryFilePage { name, kind: if markdown { "markdown" } else { "source" }.into(), language, text, data: Vec::new(), }, Err(error) => RepositoryFilePage { name, kind: "preview".into(), language, text: String::new(), data: error.into_bytes(), }, } } pub fn diff_page(path: &str, diff: 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, next_page: home.next_page.map(|page| page as u32), } } 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) { let latest = data .iter() .filter_map(|entry| entry.timestamp) .max() .unwrap_or(0) / 86_400; let (year, month, _) = civil_from_days(latest); let first_month = year * 12 + month - 1 - 8; let start = days_from_civil( first_month.div_euclid(12), first_month.rem_euclid(12) + 1, 1, ); let mut counts = vec![0_i64; (latest - start + 1) 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 { 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, }) .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, viewer_id: Option) -> CommentRow { CommentRow { id: comment.id.unwrap_or_default(), 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()), ), can_edit: comment_can_edit(comment, viewer_id), } } 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 { match pull_file_source(pull) { PullFileSource::Branch(branch) => format!("Files on {branch}"), PullFileSource::Commit(sha) => format!("Files at merge commit {sha}"), PullFileSource::Request => "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 total = open + closed; 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(), state: milestone.state.clone().unwrap_or_else(|| "unknown".into()), 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 ), has_issues: total > 0, progress: if total == 0 { 0.0 } else { closed as f64 / total as f64 }, progress_accessibility: format!("{closed} closed, {open} open"), } } fn short_sha(sha: &str) -> &str { &sha[..sha.len().min(8)] } fn source_language(path: &str) -> Option<&'static str> { let filename = path.rsplit('/').next()?.to_lowercase(); match filename.as_str() { "cmakelists.txt" => return Some("cmake"), "dockerfile" => return Some("dockerfile"), "gemfile" | "podfile" => return Some("ruby"), "makefile" => return Some("makefile"), _ => {} } match filename.rsplit('.').next()? { "asm" => Some("x86asm"), "c" => Some("c"), "cc" | "cpp" | "h" | "hpp" => Some("cpp"), "clj" => Some("clojure"), "cs" => Some("csharp"), "css" => Some("css"), "dart" => Some("dart"), "ex" | "exs" => Some("elixir"), "fs" => Some("fsharp"), "go" => Some("go"), "groovy" => Some("groovy"), "hs" => Some("haskell"), "html" | "plist" | "xml" => Some("xml"), "java" => Some("java"), "jl" => Some("julia"), "js" | "jsx" => Some("javascript"), "json" => Some("json"), "kt" | "kts" => Some("kotlin"), "lua" => Some("lua"), "m" | "mm" => Some("objectivec"), "md" | "markdown" | "mdown" | "mkd" => Some("markdown"), "php" => Some("php"), "pl" => Some("perl"), "ps1" => Some("powershell"), "py" => Some("python"), "r" => Some("r"), "rb" => Some("ruby"), "rs" => Some("rust"), "scala" => Some("scala"), "scss" => Some("scss"), "sh" => Some("bash"), "sql" => Some("sql"), "swift" => Some("swift"), "toml" => Some("ini"), "ts" | "tsx" => Some("typescript"), "txt" => Some("plaintext"), "yaml" | "yml" => Some("yaml"), _ => None, } } 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 presents_complete_commit_details() { let page = commit_details_page( models::Commit { sha: Some("0123456789abcdef".into()), commit: Some(Box::new(models::RepoCommit { message: Some("Add details\n\nExplain the change.".into()), author: Some(Box::new(models::CommitUser { name: Some("Ada".into()), email: Some("ada@example.com".into()), date: Some("2026-08-01T10:00:00Z".into()), })), committer: Some(Box::new(models::CommitUser { name: Some("Grace".into()), date: Some("2026-08-02T11:00:00Z".into()), ..Default::default() })), verification: Some(Box::new(models::PayloadCommitVerification { verified: Some(true), signature: Some("signed".into()), ..Default::default() })), ..Default::default() })), files: Some(vec![models::CommitAffectedFiles { filename: Some("README.md".into()), status: Some("modified".into()), }]), ..Default::default() }, Some("main".into()), ); assert_eq!(page.title, "Add details"); assert_eq!(page.description, "Explain the change."); assert_eq!(page.files[0].path, "README.md"); assert_eq!( page.metadata .iter() .map(|row| (row.label.as_str(), row.value.as_str())) .collect::>(), [ ("Author", "Ada "), ("Committer", "Grace"), ("Committed", "2026-08-02"), ("Branch", "main"), ("Commit", "0123456789abcdef"), ("Signature", "Verified"), ] ); assert_eq!( commit_details_page(models::Commit::default(), None) .metadata .last() .unwrap() .value, "Unsigned" ); } #[test] fn maps_heat_levels_and_labels() { let heatmap = vec![ models::UserHeatmapData { timestamp: Some(20_393 * 86_400), contributions: Some(1), }, models::UserHeatmapData { timestamp: Some(20_665 * 86_400), contributions: Some(4), }, ]; let (cells, total) = heat_cells(&heatmap); assert_eq!((cells.len(), total), (273, 5)); assert_eq!( (cells[0].level, cells[0].timestamp, cells[272].level,), (1, 20_393 * 86_400, 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.progress, 0.4); assert_eq!(milestone_row.state, "open"); assert_eq!(milestone_row.progress_accessibility, "2 closed, 3 open"); let issue = models::Issue { state: Some("closed".into()), milestone: Some(Box::new(milestone)), ..Default::default() }; let row = &issue_rows(&[issue])[0]; assert_eq!(row.state, "closed"); assert_eq!(row.milestone, "Version 1"); } #[test] fn presents_filtered_home_activity_and_continuation() { use models::activity::OpType; let page = home_page( "Gitea".into(), HomeData { activities: [OpType::CreatePullRequest, OpType::ClosePullRequest] .into_iter() .map(|op_type| models::Activity { op_type: Some(op_type), ..Default::default() }) .collect(), heatmap: Vec::new(), next_page: Some(4), }, ); assert_eq!(page.activities.len(), 2); assert_eq!(page.next_page, Some(4)); } #[test] fn issue_page_exposes_state_and_owned_comment_editing() { let page = issue_page(IssueDetails { issue: models::Issue { state: Some("closed".into()), labels: Some(vec![models::Label { name: Some("bug".into()), color: Some("ff0000".into()), ..Default::default() }]), ..Default::default() }, comments: vec![ models::Comment { id: Some(7), user: Some(Box::new(models::User { id: Some(3), login: Some("viewer".into()), ..Default::default() })), body: Some("Comment".into()), ..Default::default() }, models::Comment { id: Some(8), user: Some(Box::new(models::User { id: Some(4), login: Some("someone-else".into()), ..Default::default() })), ..Default::default() }, ], viewer_id: Some(3), has_more: false, }); assert_eq!(page.state, "closed"); assert_eq!(page.labels[0].name, "bug"); assert_eq!(page.comments[0].id, 7); assert!(page.comments[0].can_edit); assert!(!page.comments[1].can_edit); } #[test] fn issue_editor_exposes_selected_metadata_and_due_date() { let selected_label = models::Label { id: Some(2), name: Some("bug".into()), ..Default::default() }; let page = issue_editor_page(IssueEditorData { issue: Some(models::Issue { title: Some("Title".into()), body: Some("Body".into()), due_date: Some("2024-02-29T18:00:00Z".into()), state: Some("closed".into()), labels: Some(vec![selected_label.clone()]), milestone: Some(Box::new(models::Milestone { id: Some(4), title: Some("Current".into()), state: Some("closed".into()), ..Default::default() })), ..Default::default() }), labels: vec![selected_label], milestones: vec![models::Milestone { id: Some(4), title: Some("Current".into()), state: Some("closed".into()), ..Default::default() }], }); assert_eq!((page.title.as_str(), page.body.as_str()), ("Title", "Body")); assert_eq!(page.due_date, Some(1_709_164_800)); assert!(page.closed); assert!(page.labels[0].selected); assert!(page.milestones[0].selected); let new = issue_editor_page(IssueEditorData { issue: None, labels: Vec::new(), milestones: Vec::new(), }); assert!(!new.closed); } #[test] fn builds_sorted_issue_filter_options() { let filter = IssueFilter { milestone: "v3".into(), labels: ["bug".into(), "retired".into()].into(), search_text: "login".into(), }; let options = issue_filter_options( &[ models::Label { name: Some("critical".into()), ..Default::default() }, models::Label { name: Some("bug".into()), ..Default::default() }, ], &[ models::Milestone { title: Some("v2".into()), ..Default::default() }, models::Milestone { title: Some("v1".into()), ..Default::default() }, ], &filter, ); assert_eq!(options.labels, ["bug", "critical", "retired"]); assert_eq!(options.unavailable_labels, ["retired"]); assert_eq!(options.milestones, ["v1", "v2", "v3"]); assert_eq!(options.selected_labels, ["bug", "retired"]); assert_eq!(options.selected_milestone, "v3"); assert_eq!(options.search_text, "login"); let pull_options = pull_filter_options( &["v2".into(), "v1".into()], &PullFilter { milestone: "v3".into(), search_text: "review".into(), }, ); assert_eq!(pull_options.milestones, ["v1", "v2", "v3"]); assert_eq!(pull_options.selected_milestone, "v3"); assert_eq!(pull_options.search_text, "review"); } #[test] fn milestone_page_includes_linked_pull_requests() { let page = milestone_page(MilestoneDetails { milestone: models::Milestone::default(), issues: Vec::new(), pulls: vec![models::Issue { number: Some(7), state: Some("closed".into()), title: Some("Linked pull".into()), repository: Some(Box::new(models::RepositoryMeta { name: Some("demo".into()), owner: Some("octo".into()), ..Default::default() })), ..Default::default() }], has_more: false, }); assert!(page.issues.is_empty()); assert_eq!(page.pulls.len(), 1); assert_eq!(page.pulls[0].number, 7); assert_eq!(page.pulls[0].state, "closed"); } #[test] fn pull_page_exposes_open_closed_state() { let page = pull_page(PullDetails { pull: models::PullRequest { state: Some("closed".into()), ..Default::default() }, comments: Vec::new(), files: Vec::new(), has_more: false, }); assert_eq!(page.state, "closed"); } #[test] fn milestone_editor_preserves_description_and_optional_due_date() { let page = milestone_editor_page(Some(models::Milestone { title: Some("Version 1".into()), description: Some("Ship it".into()), due_on: Some("2024-02-29T12:34:56Z".into()), state: Some("closed".into()), ..Default::default() })); assert_eq!(page.title, "Version 1"); assert_eq!(page.description, "Ship it"); assert_eq!(page.due_date, Some(1_709_164_800)); assert!(page.closed); let new = milestone_editor_page(None); assert_eq!(new.due_date, None); assert!(!new.closed); } #[test] fn classifies_repository_files_in_rust() { let markdown = repository_file_page("docs/README.md", b"# Hello".to_vec()); assert_eq!( ( markdown.name.as_str(), markdown.kind.as_str(), markdown.language.as_str() ), ("README.md", "markdown", "markdown") ); assert_eq!(markdown.text, "# Hello"); let source = repository_file_page("Sources/App.swift", b"import UIKit".to_vec()); assert_eq!( (source.kind.as_str(), source.language.as_str()), ("source", "swift") ); let preview = repository_file_page("image.png", vec![0, 159]); assert_eq!(preview.kind, "preview"); assert_eq!(preview.data, [0, 159]); for path in ["manual.pdf", "sound.mp3", "movie.mp4"] { assert_eq!(repository_file_page(path, Vec::new()).kind, "preview"); } } }