Files
Gotcha/crates/app/src/presentation.rs
2026-07-31 11:25:46 +02:00

864 lines
26 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, MilestoneDetails, PullDetails, RepositoryData,
},
};
#[derive(Clone, uniffi::Record)]
pub struct ServerRow {
pub name: String,
pub url: String,
}
#[derive(Clone, uniffi::Record)]
pub struct RepositoryRow {
pub name: String,
pub owner: String,
pub description: String,
pub meta: String,
pub favorite: bool,
}
#[derive(Clone, uniffi::Record)]
pub struct LabelRow {
pub name: String,
pub color: String,
pub light: bool,
}
#[derive(Clone, uniffi::Record)]
pub struct IssueRow {
pub number: i64,
pub title: String,
pub summary: String,
pub meta: String,
pub milestone: String,
pub labels: Vec<LabelRow>,
}
#[derive(Clone, uniffi::Record)]
pub struct PullRow {
pub number: i64,
pub owner: String,
pub repository: String,
pub title: String,
pub summary: String,
pub meta: String,
}
#[derive(Clone, uniffi::Record)]
pub struct CommentRow {
pub author: String,
pub body: String,
pub meta: String,
}
#[derive(Clone, uniffi::Record)]
pub struct IssuePage {
pub title: String,
pub meta: String,
pub milestone: String,
pub body: String,
pub comments: Vec<CommentRow>,
}
#[derive(Clone, uniffi::Record)]
pub struct MilestoneRow {
pub id: i64,
pub title: String,
pub description: String,
pub meta: String,
pub open_issues: i64,
pub closed_issues: i64,
}
#[derive(Clone, uniffi::Record)]
pub struct MilestonePage {
pub milestone: MilestoneRow,
pub issues: Vec<IssueRow>,
}
#[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>,
}
#[derive(Clone, uniffi::Record)]
pub struct CommitRow {
pub sha: String,
pub title: String,
pub meta: String,
pub refs: 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,
}
#[derive(Clone, uniffi::Record)]
pub struct FileRow {
pub path: String,
pub status: String,
}
#[derive(Clone, uniffi::Record)]
pub struct RepositoryContentRow {
pub name: String,
pub path: String,
pub kind: String,
pub size: i64,
}
#[derive(Clone, uniffi::Record)]
pub struct DiffLine {
pub old_number: String,
pub new_number: String,
pub text: String,
pub kind: String,
}
#[derive(Clone, uniffi::Record)]
pub struct DiffPage {
pub title: String,
pub columns: u32,
pub lines: Vec<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 week: u32,
pub day: u32,
pub level: u32,
pub timestamp: i64,
pub contributions: i64,
}
#[derive(Clone, uniffi::Record)]
pub struct HomePage {
pub server_name: String,
pub activities: Vec<ActivityRow>,
pub heat_cells: Vec<HeatCell>,
pub contribution_count: i64,
}
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 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),
}
}
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: pull
.title
.clone()
.unwrap_or_else(|| "Untitled pull request".into()),
summary: pull
.body
.as_deref()
.map(summary)
.filter(|body| !body.is_empty())
.unwrap_or_else(|| "No description".into()),
meta: format!(
"{kind} · {author} · {} · {} comments",
compact_date(pull.updated_at.as_deref()),
pull.comments.unwrap_or_default()
),
})
})
.collect()
}
pub fn commit_page(
branches: Vec<String>,
commits: &[HistoryCommit],
show_graph: bool,
) -> CommitPage {
let lane_count = if show_graph {
commits
.iter()
.flat_map(|commit| {
commit
.top_lanes
.iter()
.chain(commit.bottom_lanes.iter())
.chain(commit.connections.iter())
.chain(commit.node_lane.iter())
})
.max()
.map_or(0, |lane| lane + 1)
} else {
0
};
let commits = commits
.iter()
.filter_map(|row| {
let commit = &row.commit;
let sha = commit.sha.as_deref()?;
let details = commit.commit.as_ref();
let author = details
.and_then(|commit| commit.author.as_ref())
.and_then(|author| author.name.as_deref())
.or_else(|| {
commit
.author
.as_ref()
.and_then(|author| author.login.as_deref())
})
.unwrap_or("unknown");
let date = details
.and_then(|commit| commit.author.as_ref())
.and_then(|author| author.date.as_deref())
.or(commit.created.as_deref());
Some(CommitRow {
sha: sha.into(),
title: details
.and_then(|commit| commit.message.as_deref())
.map(|message| message.lines().next().unwrap_or(message))
.unwrap_or("Commit")
.into(),
meta: format!("{author} · {}", compact_date(date)),
refs: row.refs.join(" · "),
top_lanes: indices(&row.top_lanes),
bottom_lanes: indices(&row.bottom_lanes),
node_lane: row.node_lane.map(|lane| lane as u32),
connections: indices(&row.connections),
})
})
.collect();
CommitPage {
branches,
commits,
lane_count: lane_count as u32,
}
}
pub fn issue_page(details: IssueDetails) -> IssuePage {
IssuePage {
title: details
.issue
.title
.clone()
.unwrap_or_else(|| "Untitled issue".into()),
meta: issue_meta(&details.issue),
milestone: issue_milestone(&details.issue),
body: details
.issue
.body
.filter(|body| !body.is_empty())
.unwrap_or_else(|| "No description provided.".into()),
comments: details.comments.iter().map(comment_row).collect(),
}
}
pub fn pull_page(details: PullDetails) -> PullPage {
let pull = &details.pull;
let author = pull
.user
.as_ref()
.and_then(|user| user.login.as_deref())
.unwrap_or("unknown");
let head = pull
.head
.as_ref()
.and_then(|branch| branch.label.as_deref().or(branch.r#ref.as_deref()))
.unwrap_or("head");
let base = pull
.base
.as_ref()
.and_then(|branch| branch.label.as_deref().or(branch.r#ref.as_deref()))
.unwrap_or("base");
let state = if pull.merged.unwrap_or(false) {
"merged"
} else if pull.draft.unwrap_or(false) {
"draft"
} else {
pull.state.as_deref().unwrap_or("unknown")
};
PullPage {
title: pull
.title
.clone()
.unwrap_or_else(|| "Untitled pull request".into()),
meta: format!(
"#{} · {state} · {author} · {head} → {base}\n{} files · +{} {} · {} comments",
pull.number.unwrap_or_default(),
pull.changed_files.unwrap_or_default(),
pull.additions.unwrap_or_default(),
pull.deletions.unwrap_or_default(),
pull.comments.unwrap_or_default(),
),
body: pull
.body
.clone()
.filter(|body| !body.is_empty())
.unwrap_or_else(|| "No description provided.".into()),
files_ref: pull_files_ref(pull),
files: details.files.iter().filter_map(file_row).collect(),
comments: details.comments.iter().map(comment_row).collect(),
}
}
pub fn file_rows(files: Vec<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 diff_page(path: &str, diff: crate::diff::Parsed) -> DiffPage {
DiffPage {
title: path.rsplit('/').next().unwrap_or(path).into(),
columns: diff.columns.min(u32::MAX as usize) as u32,
lines: diff
.lines
.into_iter()
.map(|line| DiffLine {
old_number: line.old_number,
new_number: line.new_number,
text: line.text,
kind: line.kind.into(),
})
.collect(),
}
}
pub fn home_page(server_name: String, home: HomeData) -> HomePage {
let (heat_cells, contribution_count) = heat_cells(&home.heatmap);
HomePage {
server_name,
activities: home.activities.iter().map(activity_row).collect(),
heat_cells,
contribution_count,
}
}
fn activity_row(activity: &models::Activity) -> ActivityRow {
use models::activity::OpType;
let repository = activity
.repo
.as_ref()
.and_then(|repo| repo.full_name.as_deref())
.unwrap_or("repository");
let branch = activity
.ref_name
.as_deref()
.and_then(|name| name.strip_prefix("refs/heads/").or(Some(name)))
.unwrap_or("default branch");
let (icon, title) = match activity.op_type {
Some(OpType::CommitRepo) if activity.content.as_deref().unwrap_or("").is_empty() => (
"branch-create",
format!("Created branch {branch} in {repository}"),
),
Some(OpType::CommitRepo | OpType::MirrorSyncPush) => {
("push", format!("Pushed to {branch} in {repository}"))
}
Some(OpType::CreateRepo) => ("repo-create", format!("Created {repository}")),
Some(OpType::RenameRepo) => ("repo-rename", format!("Renamed {repository}")),
Some(OpType::StarRepo) => ("repo-star", format!("Starred {repository}")),
Some(OpType::WatchRepo) => ("repo-watch", format!("Started watching {repository}")),
Some(OpType::CreateIssue) => ("issue-open", format!("Opened an issue in {repository}")),
Some(OpType::CloseIssue) => ("issue-close", format!("Closed an issue in {repository}")),
Some(OpType::ReopenIssue) => ("issue-reopen", format!("Reopened an issue in {repository}")),
Some(OpType::CommentIssue) => (
"issue-comment",
format!("Commented on an issue in {repository}"),
),
Some(OpType::CreatePullRequest) => (
"pull-open",
format!("Opened a pull request in {repository}"),
),
Some(OpType::MergePullRequest | OpType::AutoMergePullRequest) => (
"pull-merge",
format!("Merged a pull request in {repository}"),
),
Some(OpType::ClosePullRequest) => (
"pull-close",
format!("Closed a pull request in {repository}"),
),
Some(OpType::ReopenPullRequest) => (
"pull-reopen",
format!("Reopened a pull request in {repository}"),
),
Some(OpType::CommentPull) => (
"pull-comment",
format!("Commented on a pull request in {repository}"),
),
Some(OpType::ApprovePullRequest) => (
"pull-approve",
format!("Approved a pull request in {repository}"),
),
Some(OpType::RejectPullRequest) => {
("pull-reject", format!("Requested changes in {repository}"))
}
Some(OpType::PushTag) => ("tag-push", format!("Pushed tag {branch} in {repository}")),
Some(OpType::DeleteTag) => (
"tag-delete",
format!("Deleted tag {branch} in {repository}"),
),
Some(OpType::DeleteBranch) => (
"branch-delete",
format!("Deleted branch {branch} in {repository}"),
),
Some(OpType::PublishRelease) => ("release", format!("Published a release in {repository}")),
Some(_) | None => ("update", format!("Updated {repository}")),
};
let target = activity::target(activity);
let (target, owner, repository, number, sha) = match target {
Some(activity::Target::Repository { owner, repository }) => {
("repository", owner, repository, 0, String::new())
}
Some(activity::Target::Issue {
owner,
repository,
number,
}) => ("issue", owner, repository, number, String::new()),
Some(activity::Target::Pull {
owner,
repository,
number,
}) => ("pull", owner, repository, number, String::new()),
Some(activity::Target::Commit {
owner,
repository,
sha,
}) => ("commit", owner, repository, 0, sha),
None => ("", String::new(), String::new(), 0, String::new()),
};
ActivityRow {
icon: icon.into(),
title,
detail: activity_detail(activity),
meta: compact_date(activity.created.as_deref()),
target: target.into(),
owner,
repository,
number,
sha,
}
}
fn activity_detail(activity: &models::Activity) -> String {
let text = activity
.comment
.as_ref()
.and_then(|comment| comment.body.as_deref())
.or(activity.content.as_deref())
.unwrap_or("");
if let Ok(payload) = serde_json::from_str::<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) {
const DAYS: i64 = 9 * 31;
let latest = data
.iter()
.filter_map(|entry| entry.timestamp)
.max()
.unwrap_or(0)
/ 86_400;
let start = latest - DAYS + 1;
let mut counts = vec![0_i64; DAYS as usize];
for entry in data {
let day = entry.timestamp.unwrap_or_default() / 86_400;
if (start..=latest).contains(&day) {
counts[(day - start) as usize] += entry.contributions.unwrap_or_default();
}
}
let maximum = counts.iter().copied().max().unwrap_or_default();
let contribution_count = counts.iter().sum();
let cells = counts
.into_iter()
.enumerate()
.map(|(index, count)| HeatCell {
week: (index / 7) as u32,
day: (index % 7) as u32,
level: if count == 0 || maximum == 0 {
0
} else {
((count * 4 + maximum - 1) / maximum).clamp(1, 4) as u32
},
timestamp: (start + index as i64) * 86_400,
contributions: count,
})
.collect();
(cells, contribution_count)
}
fn label_row(label: &models::Label) -> Option<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 due = milestone
.due_on
.as_deref()
.map(|date| format!("due {}", compact_date(Some(date))))
.unwrap_or_else(|| "no due date".into());
MilestoneRow {
id: milestone.id.unwrap_or_default(),
title: milestone
.title
.clone()
.unwrap_or_else(|| "Untitled milestone".into()),
description: milestone
.description
.clone()
.filter(|description| !description.is_empty())
.unwrap_or_else(|| "No description".into()),
meta: format!(
"{} · {closed} of {} closed · {due}",
milestone.state.as_deref().unwrap_or("unknown"),
open + closed
),
open_issues: open,
closed_issues: closed,
}
}
fn summary(body: &str) -> String {
body.split_whitespace()
.take(24)
.collect::<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(100 * 86_400),
contributions: Some(1),
},
models::UserHeatmapData {
timestamp: Some(101 * 86_400),
contributions: Some(4),
},
];
let (cells, total) = heat_cells(&heatmap);
assert_eq!((cells.len(), total), (279, 5));
assert_eq!(
(
cells[277].level,
cells[277].timestamp,
cells[277].contributions,
cells[278].level,
),
(1, 100 * 86_400, 1, 4)
);
let label = models::Label {
name: Some("bug".into()),
color: Some("d73a4a".into()),
..Default::default()
};
assert!(label_row(&label).is_some());
assert!(label_row(&models::Label::default()).is_none());
let milestone = models::Milestone {
id: Some(1),
title: Some("Version 1".into()),
open_issues: Some(3),
closed_issues: Some(2),
state: Some("open".into()),
..Default::default()
};
let milestone_row = milestone_rows(std::slice::from_ref(&milestone)).remove(0);
assert_eq!(
(milestone_row.open_issues, milestone_row.closed_issues),
(3, 2)
);
let issue = models::Issue {
milestone: Some(Box::new(milestone)),
..Default::default()
};
assert_eq!(issue_rows(&[issue])[0].milestone, "Version 1");
}
}