Files
Gotcha/crates/app/src/presentation.rs
2026-08-01 15:48:14 +02:00

1688 lines
52 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::{
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<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 state: String,
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 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<LabelRow>,
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 closed: bool,
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>,
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<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 MilestoneEditorPage {
pub title: String,
pub description: String,
pub due_date: Option<i64>,
pub closed: bool,
}
#[derive(Clone, uniffi::Record)]
pub struct PullFilterOptions {
pub milestones: Vec<String>,
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<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 branch_label: Option<String>,
pub top_lanes: Vec<u32>,
pub bottom_lanes: Vec<u32>,
pub node_lane: Option<u32>,
pub top_connections: Vec<u32>,
pub bottom_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 CommitMetadataRow {
pub label: String,
pub value: String,
}
#[derive(Clone, uniffi::Record)]
pub struct CommitDetailsPage {
pub title: String,
pub description: String,
pub metadata: Vec<CommitMetadataRow>,
pub files: Vec<FileRow>,
}
#[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, Copy, Debug, uniffi::Enum)]
pub enum HomeActivityFilter {
All,
Issues,
PullRequests,
}
impl From<HomeActivityFilter> 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<ActivityRow>,
pub heat_cells: Vec<HeatCell>,
pub contribution_count: i64,
pub next_page: Option<u32>,
}
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(),
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<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 milestone_editor_page(milestone: Option<models::Milestone>) -> 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<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()?,
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<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.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<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 commit_details_page(commit: models::Commit, branch: Option<String>) -> 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::<Vec<_>>().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<String> {
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<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: 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::<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, viewer_id: Option<i64>) -> 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<FileRow> {
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::<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 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::<Vec<_>>(),
[
("Author", "Ada <ada@example.com>"),
("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");
}
}
}