Files
Gotcha/crates/app/src/presentation.rs
2026-07-31 15:02:21 +02:00

1413 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use gotcha_gitea::models;
use crate::{
activity,
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<RepositoryRow>,
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 title: String,
pub summary: String,
pub meta: String,
pub milestone: String,
pub labels: Vec<LabelRow>,
}
#[derive(Clone, uniffi::Record)]
pub struct IssueListPage {
pub rows: Vec<IssueRow>,
pub has_more: bool,
}
#[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 state: String,
pub meta: String,
pub milestone: String,
pub body: String,
pub comments: Vec<CommentRow>,
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<i64>,
pub labels: Vec<IssueEditorLabel>,
pub milestones: Vec<IssueEditorMilestone>,
}
#[derive(Clone, uniffi::Record)]
pub struct IssueFilterOptions {
pub milestones: Vec<String>,
pub labels: Vec<String>,
pub unavailable_labels: Vec<String>,
pub selected_milestone: String,
pub selected_labels: Vec<String>,
}
#[derive(Clone, uniffi::Record)]
pub struct MilestoneRow {
pub id: i64,
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<IssueRow>,
pub pulls: Vec<PullRow>,
pub has_more: bool,
}
#[derive(Clone, uniffi::Record)]
pub struct MilestoneListPage {
pub rows: Vec<MilestoneRow>,
pub has_more: bool,
}
#[derive(Clone, uniffi::Record)]
pub struct PullFilterOptions {
pub milestones: Vec<String>,
pub selected_milestone: String,
}
#[derive(Clone, uniffi::Record)]
pub struct PullPage {
pub title: String,
pub meta: String,
pub body: String,
pub files_ref: String,
pub files: Vec<FileRow>,
pub comments: Vec<CommentRow>,
pub has_more: bool,
}
#[derive(Clone, uniffi::Record)]
pub struct PullListPage {
pub rows: Vec<PullRow>,
pub has_more: bool,
}
#[derive(Clone, uniffi::Record)]
pub struct CommitRow {
pub sha: String,
pub title: String,
pub detail: String,
pub top_lanes: Vec<u32>,
pub bottom_lanes: Vec<u32>,
pub node_lane: Option<u32>,
pub connections: Vec<u32>,
}
#[derive(Clone, uniffi::Record)]
pub struct CommitPage {
pub branches: Vec<String>,
pub commits: Vec<CommitRow>,
pub lane_count: u32,
pub has_more: bool,
}
#[derive(Clone, uniffi::Record)]
pub struct FileRow {
pub path: String,
pub status: String,
}
#[derive(Clone, uniffi::Record)]
pub struct FileListPage {
pub rows: Vec<FileRow>,
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<u8>,
}
#[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<DiffLine>,
}
#[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 level: u32,
pub timestamp: i64,
}
#[derive(Clone, uniffi::Record)]
pub struct HomePage {
pub server_name: String,
pub activities: Vec<ActivityRow>,
pub issue_activities: Vec<ActivityRow>,
pub pull_activities: Vec<ActivityRow>,
pub heat_cells: Vec<HeatCell>,
pub contribution_count: i64,
pub has_more: bool,
}
pub fn repository_rows(
repositories: &[RepositoryData],
is_favorite: impl Fn(&RepositoryData) -> bool,
) -> Vec<RepositoryRow> {
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<IssueRow> {
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 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(),
}
}
pub fn milestone_rows(milestones: &[models::Milestone]) -> Vec<MilestoneRow> {
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 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(),
}
}
pub fn pull_rows(pulls: &[models::Issue]) -> Vec<PullRow> {
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: 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<String>,
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.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(),
detail: if row.refs.is_empty() {
format!("{author} · {}\n{}", compact_date(date), short_sha(sha))
} else {
format!(
"{}\n{author} · {} · {}",
row.refs.join(" · "),
compact_date(date),
short_sha(sha)
)
},
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,
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),
body: details
.issue
.body
.filter(|body| !body.is_empty())
.unwrap_or_else(|| "No description provided.".into()),
comments: details.comments.iter().map(comment_row).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),
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 = 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(),
has_more: details.has_more,
}
}
pub fn file_rows(files: Vec<models::ChangedFile>) -> Vec<FileRow> {
files.iter().filter_map(file_row).collect()
}
pub fn commit_file_rows(files: Vec<models::CommitAffectedFiles>) -> Vec<FileRow> {
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<models::ContentsResponse>,
) -> Vec<RepositoryContentRow> {
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<u8>) -> 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: 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,
issue_activities: home
.activities
.iter()
.filter(|activity| is_issue_activity(activity))
.map(activity_row)
.collect(),
pull_activities: home
.activities
.iter()
.filter(|activity| is_pull_activity(activity))
.map(activity_row)
.collect(),
activities: home.activities.iter().map(activity_row).collect(),
heat_cells,
contribution_count,
has_more: home.has_more,
}
}
fn is_issue_activity(activity: &models::Activity) -> bool {
use models::activity::OpType;
matches!(
activity.op_type,
Some(OpType::CreateIssue | OpType::CloseIssue | OpType::ReopenIssue | OpType::CommentIssue)
)
}
fn is_pull_activity(activity: &models::Activity) -> bool {
use models::activity::OpType;
matches!(
activity.op_type,
Some(
OpType::CreatePullRequest
| OpType::MergePullRequest
| OpType::AutoMergePullRequest
| OpType::ClosePullRequest
| OpType::ReopenPullRequest
| OpType::CommentPull
| OpType::ApprovePullRequest
| OpType::RejectPullRequest
)
)
}
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::<serde_json::Value>(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<HeatCell>, 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<LabelRow> {
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<FileRow> {
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 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(),
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::<Vec<_>>()
.join(" ")
}
fn indices(values: &[usize]) -> Vec<u32> {
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::<Vec<_>>(),
["Sources", "README.md", "z.swift"]
);
}
#[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.progress_accessibility, "2 closed, 3 open");
let issue = models::Issue {
milestone: Some(Box::new(milestone)),
..Default::default()
};
assert_eq!(issue_rows(&[issue])[0].milestone, "Version 1");
}
#[test]
fn categorizes_home_activity_before_crossing_the_bridge() {
use models::activity::OpType;
let page = home_page(
"Gitea".into(),
HomeData {
activities: [
OpType::CreateIssue,
OpType::CreatePullRequest,
OpType::CreateRepo,
]
.into_iter()
.map(|op_type| models::Activity {
op_type: Some(op_type),
..Default::default()
})
.collect(),
heatmap: Vec::new(),
has_more: false,
},
);
assert_eq!(page.activities.len(), 3);
assert_eq!(page.issue_activities.len(), 1);
assert_eq!(page.pull_activities.len(), 1);
}
#[test]
fn issue_page_exposes_state() {
let page = issue_page(IssueDetails {
issue: models::Issue {
state: Some("closed".into()),
..Default::default()
},
comments: Vec::new(),
has_more: false,
});
assert_eq!(page.state, "closed");
}
#[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()),
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.labels[0].selected);
assert!(page.milestones[0].selected);
}
#[test]
fn builds_sorted_issue_filter_options() {
let filter = IssueFilter {
milestone: "v3".into(),
labels: ["bug".into(), "retired".into()].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");
let pull_options = pull_filter_options(
&["v2".into(), "v1".into()],
&PullFilter {
milestone: "v3".into(),
},
);
assert_eq!(pull_options.milestones, ["v1", "v2", "v3"]);
assert_eq!(pull_options.selected_milestone, "v3");
}
#[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),
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);
}
#[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");
}
}
}