Replace Slint UI with UIKit and add milestones

Fixes #2.\nFixes #9.
This commit is contained in:
Georg Bauer
2026-07-31 09:52:24 +02:00
parent 8f9a4dfc00
commit f201814d54
33 changed files with 7455 additions and 7878 deletions

View File

@@ -7,8 +7,12 @@ use gotcha_gitea::{Client, apis, models};
use tokio::task::JoinSet;
use crate::{
domain::{HistoryCommit, HomeData, RepositoryData, Server},
view::compact_date,
diff,
domain::{
HistoryCommit, HomeData, IssueDetails, MilestoneDetails, PullDetails, RepositoryData,
Server,
},
presentation::compact_date,
};
pub async fn load_repositories(server: &Server) -> Result<Vec<RepositoryData>, String> {
@@ -81,6 +85,71 @@ pub async fn load_issues(
.map_err(|error| error.to_string())
}
pub async fn load_milestones(
server: &Server,
owner: &str,
repository: &str,
) -> Result<Vec<models::Milestone>, String> {
let client =
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
let configuration = client.configuration();
let mut milestones = Vec::new();
for page in 1.. {
let batch = apis::issue_api::issue_get_milestones_list(
&configuration,
owner,
repository,
Some("all"),
None,
Some(page),
Some(100),
)
.await
.map_err(|error| error.to_string())?;
let done = batch.len() < 100;
milestones.extend(batch);
if done {
break;
}
}
Ok(milestones)
}
pub async fn load_milestone(
server: &Server,
owner: &str,
repository: &str,
id: i64,
) -> Result<MilestoneDetails, String> {
let client =
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
let configuration = client.configuration();
let milestone =
apis::issue_api::issue_get_milestone(&configuration, owner, repository, &id.to_string())
.await
.map_err(|error| error.to_string())?;
let issues = apis::issue_api::issue_list_issues(
&configuration,
owner,
repository,
Some("all"),
None,
None,
Some("issues"),
milestone.title.as_deref(),
None,
None,
None,
None,
None,
Some(1),
Some(100),
)
.await
.map_err(|error| error.to_string())?;
Ok(MilestoneDetails { milestone, issues })
}
pub async fn load_pulls(server: &Server, status: &str) -> Result<Vec<models::Issue>, String> {
let client =
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
@@ -233,6 +302,111 @@ pub async fn load_pull_files(
Ok(files)
}
pub async fn load_issue(
server: &Server,
owner: &str,
repository: &str,
number: i64,
) -> Result<IssueDetails, String> {
let client =
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
let configuration = client.configuration();
let (issue, comments) = tokio::join!(
apis::issue_api::issue_get_issue(&configuration, owner, repository, number),
apis::issue_api::issue_get_comments(&configuration, owner, repository, number, None, None,),
);
Ok(IssueDetails {
issue: issue.map_err(|error| error.to_string())?,
comments: comments.map_err(|error| error.to_string())?,
})
}
pub async fn load_pull(
server: &Server,
owner: &str,
repository: &str,
number: i64,
) -> Result<PullDetails, String> {
let client =
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
let configuration = client.configuration();
let (pull, comments, files) = tokio::join!(
apis::repository_api::repo_get_pull_request(&configuration, owner, repository, number,),
apis::issue_api::issue_get_comments(&configuration, owner, repository, number, None, None,),
load_pull_files(&configuration, owner, repository, number),
);
Ok(PullDetails {
pull: pull.map_err(|error| error.to_string())?,
comments: comments.map_err(|error| error.to_string())?,
files: files.map_err(|error| error.to_string())?,
})
}
pub async fn load_commit_files(
server: &Server,
owner: &str,
repository: &str,
sha: &str,
) -> Result<Vec<models::CommitAffectedFiles>, String> {
let client =
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
apis::repository_api::repo_get_single_commit(
&client.configuration(),
owner,
repository,
sha,
Some(true),
None,
Some(true),
)
.await
.map(|commit| commit.files.unwrap_or_default())
.map_err(|error| error.to_string())
}
pub async fn load_commit_diff(
server: &Server,
owner: &str,
repository: &str,
sha: &str,
path: &str,
) -> Result<diff::Parsed, String> {
let client =
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
apis::repository_api::repo_download_commit_diff_or_patch(
&client.configuration(),
owner,
repository,
sha,
"diff",
)
.await
.map(|text| diff::parse_file(&text, path))
.map_err(|error| error.to_string())
}
pub async fn load_pull_diff(
server: &Server,
owner: &str,
repository: &str,
number: i64,
path: &str,
) -> Result<diff::Parsed, String> {
let client =
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
apis::repository_api::repo_download_pull_diff_or_patch(
&client.configuration(),
owner,
repository,
number,
"diff",
Some(false),
)
.await
.map(|text| diff::parse_file(&text, path))
.map_err(|error| error.to_string())
}
async fn load_pull_refs(
server: &Server,
owner: &str,

View File

@@ -3,35 +3,6 @@ use std::collections::BTreeSet;
use gotcha_gitea::models;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum IconStyle {
Duotone,
Bold,
#[default]
#[serde(other)]
Outline,
}
impl IconStyle {
pub fn index(self) -> i32 {
match self {
Self::Outline => 0,
Self::Duotone => 1,
Self::Bold => 2,
}
}
pub fn from_index(index: i32) -> Option<Self> {
match index {
0 => Some(Self::Outline),
1 => Some(Self::Duotone),
2 => Some(Self::Bold),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AppearanceMode {
@@ -82,8 +53,6 @@ pub struct Preferences {
#[serde(default = "open_status")]
pub pull_status: String,
#[serde(default)]
pub icon_style: IconStyle,
#[serde(default)]
pub appearance: AppearanceMode,
}
@@ -95,7 +64,6 @@ impl Default for Preferences {
last_server: None,
issue_status: open_status(),
pull_status: open_status(),
icon_style: IconStyle::default(),
appearance: AppearanceMode::default(),
}
}
@@ -126,17 +94,7 @@ pub struct HistoryCommit {
pub struct State {
pub preferences: Preferences,
pub active_server: Option<usize>,
pub active_repository: Option<(String, String)>,
pub active_issue: Option<i64>,
pub active_pull: Option<(String, String, i64)>,
pub active_commit: Option<String>,
pub opened_from_home: bool,
pub repositories: Vec<RepositoryData>,
pub issues: Vec<models::Issue>,
pub pulls: Vec<models::Issue>,
pub branches: Vec<String>,
pub active_branch: Option<String>,
pub commits: Vec<HistoryCommit>,
}
pub struct HomeData {
@@ -149,6 +107,11 @@ pub struct IssueDetails {
pub comments: Vec<models::Comment>,
}
pub struct MilestoneDetails {
pub milestone: models::Milestone,
pub issues: Vec<models::Issue>,
}
pub struct PullDetails {
pub pull: models::PullRequest,
pub comments: Vec<models::Comment>,

355
crates/app/src/lib.rs Normal file
View File

@@ -0,0 +1,355 @@
use std::sync::{Arc, Mutex};
use gotcha_gitea::Client;
use thiserror::Error;
mod activity;
mod api;
mod diff;
mod domain;
mod presentation;
mod storage;
use api::*;
use domain::*;
pub use presentation::*;
use storage::*;
uniffi::setup_scaffolding!();
#[derive(Debug, Error, uniffi::Error)]
pub enum GotchaError {
#[error("{message}")]
Message { message: String },
}
impl From<String> for GotchaError {
fn from(message: String) -> Self {
Self::Message { message }
}
}
impl From<&str> for GotchaError {
fn from(message: &str) -> Self {
message.to_string().into()
}
}
#[derive(Clone, uniffi::Record)]
pub struct Settings {
pub issue_status: String,
pub pull_status: String,
pub appearance: u32,
}
#[derive(uniffi::Object)]
pub struct GotchaCore {
state: Mutex<State>,
startup_error: Option<String>,
}
#[uniffi::export(async_runtime = "tokio")]
impl GotchaCore {
#[uniffi::constructor]
pub fn new() -> Arc<Self> {
let (preferences, startup_error) = match load_preferences() {
Ok(preferences) => (preferences, None),
Err(error) => (Preferences::default(), Some(error)),
};
let active_server = preferences
.last_server
.filter(|index| *index < preferences.servers.len())
.or_else(|| (preferences.servers.len() == 1).then_some(0));
Arc::new(Self {
state: Mutex::new(State {
preferences,
active_server,
..Default::default()
}),
startup_error,
})
}
pub fn startup_error(&self) -> Option<String> {
self.startup_error.clone()
}
pub fn servers(&self) -> Vec<ServerRow> {
self.state
.lock()
.unwrap()
.preferences
.servers
.iter()
.map(|server| ServerRow {
name: server.name.clone(),
url: server.url.clone(),
})
.collect()
}
pub fn active_server_index(&self) -> Option<u32> {
self.state
.lock()
.unwrap()
.active_server
.map(|index| index as u32)
}
pub fn active_server_name(&self) -> Option<String> {
let state = self.state.lock().unwrap();
state
.active_server
.and_then(|index| state.preferences.servers.get(index))
.map(|server| server.name.clone())
}
pub fn select_server(&self, index: u32) -> Result<(), GotchaError> {
let mut state = self.state.lock().unwrap();
let index = index as usize;
if index >= state.preferences.servers.len() {
return Err("That server no longer exists.".into());
}
state.active_server = Some(index);
state.preferences.last_server = Some(index);
state.repositories.clear();
save_preferences(&state.preferences).map_err(Into::into)
}
pub async fn add_server(
&self,
name: String,
url: String,
token: String,
) -> Result<u32, GotchaError> {
let server = validate_server(&name, &url, &token)?;
Client::new(&server.url, Some(&server.token))
.map_err(|error| error.to_string())?
.current_user()
.await
.map_err(|error| error.to_string())?;
save_server_token(&server)?;
let mut state = self.state.lock().unwrap();
state.preferences.servers.push(server);
let index = state.preferences.servers.len() - 1;
state.preferences.last_server = Some(index);
state.active_server = Some(index);
save_preferences(&state.preferences)?;
Ok(index as u32)
}
pub fn settings(&self) -> Settings {
let state = self.state.lock().unwrap();
Settings {
issue_status: state.preferences.issue_status.clone(),
pull_status: state.preferences.pull_status.clone(),
appearance: state.preferences.appearance.index() as u32,
}
}
pub fn set_issue_status(&self, status: String) -> Result<(), GotchaError> {
if !matches!(status.as_str(), "open" | "closed") {
return Err("Unsupported issue status.".into());
}
let mut state = self.state.lock().unwrap();
state.preferences.issue_status = status;
save_preferences(&state.preferences).map_err(Into::into)
}
pub fn set_pull_status(&self, status: String) -> Result<(), GotchaError> {
if !matches!(status.as_str(), "open" | "closed") {
return Err("Unsupported pull-request status.".into());
}
let mut state = self.state.lock().unwrap();
state.preferences.pull_status = status;
save_preferences(&state.preferences).map_err(Into::into)
}
pub fn set_appearance(&self, index: u32) -> Result<(), GotchaError> {
let appearance =
AppearanceMode::from_index(index as i32).ok_or("Unsupported appearance.")?;
let mut state = self.state.lock().unwrap();
state.preferences.appearance = appearance;
save_preferences(&state.preferences).map_err(Into::into)
}
pub async fn home(&self) -> Result<HomePage, GotchaError> {
let server = self.server()?;
let name = server.name.clone();
Ok(home_page(name, load_home(&server).await?))
}
pub async fn repositories(&self) -> Result<Vec<RepositoryRow>, GotchaError> {
let server = self.server()?;
let repositories = load_repositories(&server).await?;
let mut state = self.state.lock().unwrap();
state.repositories = repositories;
Ok(self.repository_rows(&state))
}
pub fn toggle_favorite(
&self,
owner: String,
repository: String,
) -> Result<Vec<RepositoryRow>, GotchaError> {
let mut state = self.state.lock().unwrap();
let server = state
.active_server
.and_then(|index| state.preferences.servers.get(index))
.ok_or("Select a server first.")?;
let key = favorite_key(&server.url, &owner, &repository);
if !state.preferences.favorites.remove(&key) {
state.preferences.favorites.insert(key);
}
save_preferences(&state.preferences)?;
Ok(self.repository_rows(&state))
}
pub async fn issues(
&self,
owner: String,
repository: String,
) -> Result<Vec<IssueRow>, GotchaError> {
let server = self.server()?;
let status = self.state.lock().unwrap().preferences.issue_status.clone();
Ok(issue_rows(
&load_issues(&server, &owner, &repository, &status).await?,
))
}
pub async fn issue(
&self,
owner: String,
repository: String,
number: i64,
) -> Result<IssuePage, GotchaError> {
Ok(issue_page(
load_issue(&self.server()?, &owner, &repository, number).await?,
))
}
pub async fn milestones(
&self,
owner: String,
repository: String,
) -> Result<Vec<MilestoneRow>, GotchaError> {
Ok(milestone_rows(
&load_milestones(&self.server()?, &owner, &repository).await?,
))
}
pub async fn milestone(
&self,
owner: String,
repository: String,
id: i64,
) -> Result<MilestonePage, GotchaError> {
Ok(milestone_page(
load_milestone(&self.server()?, &owner, &repository, id).await?,
))
}
pub async fn pulls(&self) -> Result<Vec<PullRow>, GotchaError> {
let server = self.server()?;
let status = self.state.lock().unwrap().preferences.pull_status.clone();
Ok(pull_rows(&load_pulls(&server, &status).await?))
}
pub async fn pull(
&self,
owner: String,
repository: String,
number: i64,
) -> Result<PullPage, GotchaError> {
Ok(pull_page(
load_pull(&self.server()?, &owner, &repository, number).await?,
))
}
pub async fn commits(
&self,
owner: String,
repository: String,
branch: Option<String>,
) -> Result<CommitPage, GotchaError> {
let server = self.server()?;
let default_branch = self
.state
.lock()
.unwrap()
.repositories
.iter()
.find(|candidate| candidate.owner == owner && candidate.name == repository)
.map(|candidate| candidate.default_branch.clone())
.unwrap_or_else(|| "main".into());
let branches = load_branches(&server, &owner, &repository, &default_branch).await?;
let commits = match branch.as_deref() {
Some(branch) => load_branch_commits(&server, &owner, &repository, branch).await?,
None => load_all_commits(&server, &owner, &repository, &branches).await?,
};
Ok(commit_page(branches, &commits, branch.is_none()))
}
pub async fn commit_files(
&self,
owner: String,
repository: String,
sha: String,
) -> Result<Vec<FileRow>, GotchaError> {
Ok(commit_file_rows(
load_commit_files(&self.server()?, &owner, &repository, &sha).await?,
))
}
pub async fn commit_diff(
&self,
owner: String,
repository: String,
sha: String,
path: String,
) -> Result<DiffPage, GotchaError> {
Ok(diff_page(
&path,
load_commit_diff(&self.server()?, &owner, &repository, &sha, &path).await?,
))
}
pub async fn pull_diff(
&self,
owner: String,
repository: String,
number: i64,
path: String,
) -> Result<DiffPage, GotchaError> {
Ok(diff_page(
&path,
load_pull_diff(&self.server()?, &owner, &repository, number, &path).await?,
))
}
}
impl GotchaCore {
fn server(&self) -> Result<Server, GotchaError> {
let state = self.state.lock().unwrap();
state
.active_server
.and_then(|index| state.preferences.servers.get(index))
.cloned()
.ok_or_else(|| "Select a server first.".to_string().into())
}
fn repository_rows(&self, state: &State) -> Vec<RepositoryRow> {
let server_url = state
.active_server
.and_then(|index| state.preferences.servers.get(index))
.map(|server| server.url.as_str())
.unwrap_or_default();
repository_rows(&state.repositories, |repository| {
state.preferences.favorites.contains(&favorite_key(
server_url,
&repository.owner,
&repository.name,
))
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,312 +0,0 @@
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use slint::StyledText;
use crate::ui::MarkdownBlock;
struct Draft {
kind: &'static str,
source: String,
plain: bool,
}
#[derive(Default)]
struct Renderer {
blocks: Vec<MarkdownBlock>,
current: Option<Draft>,
lists: Vec<Option<u64>>,
links: Vec<(String, bool, bool)>,
quote_depth: usize,
}
pub fn render(source: &str) -> Vec<MarkdownBlock> {
let mut renderer = Renderer::default();
for event in Parser::new_ext(source, Options::all()) {
renderer.event(event);
}
renderer.finish();
renderer.blocks
}
impl Renderer {
fn event(&mut self, event: Event<'_>) {
match event {
Event::Start(tag) => self.start(tag),
Event::End(tag) => self.end(tag),
Event::Text(text) => self.text(&text),
Event::Code(text) | Event::InlineMath(text) | Event::DisplayMath(text) => {
if self.is_plain() {
self.raw(&text);
} else {
self.raw(&code_span(&text));
}
}
Event::Html(html) | Event::InlineHtml(html) => self.text(&html),
Event::FootnoteReference(label) => self.text(&format!("[{label}]")),
Event::SoftBreak | Event::HardBreak => self.raw("\n"),
Event::Rule => {
self.begin("rule", true);
self.finish();
}
Event::TaskListMarker(checked) => self.raw(if checked { "" } else { "" }),
}
}
fn start(&mut self, tag: Tag<'_>) {
match tag {
Tag::Paragraph => {
if self.current.is_none() {
self.begin(
if self.quote_depth > 0 {
"quote"
} else {
"paragraph"
},
false,
);
} else if self
.current
.as_ref()
.is_some_and(|draft| !draft.source.is_empty() && !draft.source.ends_with(' '))
{
self.raw("\n");
}
}
Tag::Heading { level, .. } => {
self.begin(heading_kind(level), false);
self.raw("**");
}
Tag::BlockQuote(_) => {
self.quote_depth += 1;
if self.current.is_none() {
self.begin("quote", false);
} else {
self.raw(" ");
}
}
Tag::CodeBlock(_) => self.begin("code", true),
Tag::HtmlBlock | Tag::MetadataBlock(_) => self.begin("code", true),
Tag::List(start) => self.lists.push(start),
Tag::Item => {
self.begin("list", false);
let indent = "\u{00a0}\u{00a0}".repeat(self.lists.len().saturating_sub(1));
self.raw(&indent);
let prefix = match self.lists.last_mut() {
Some(Some(number)) => {
let prefix = format!("{number}\\. ");
*number += 1;
prefix
}
_ => "".into(),
};
self.raw(&prefix);
}
Tag::FootnoteDefinition(label) => {
self.begin("footnote", false);
self.text(&format!("[{label}] "));
}
Tag::DefinitionList => {}
Tag::DefinitionListTitle => {
self.begin("paragraph", false);
self.raw("**");
}
Tag::DefinitionListDefinition => {
self.begin("paragraph", false);
self.raw("");
}
Tag::Table(_) => self.begin("table", true),
Tag::TableHead | Tag::TableRow | Tag::TableCell => {}
Tag::Emphasis => self.marker("*"),
Tag::Strong => self.marker("**"),
Tag::Strikethrough => self.marker("~~"),
Tag::Superscript => self.raw("\\^"),
Tag::Subscript => self.raw("\\~"),
Tag::Link { dest_url, .. } => self.start_link(dest_url.into_string(), false),
Tag::Image { dest_url, .. } => self.start_link(dest_url.into_string(), true),
}
}
fn end(&mut self, tag: TagEnd) {
match tag {
TagEnd::Paragraph => {
if self.quote_depth == 0
&& !self
.current
.as_ref()
.is_some_and(|draft| draft.kind == "list")
{
self.finish();
}
}
TagEnd::Heading(_) => {
self.raw("**");
self.finish();
}
TagEnd::BlockQuote(_) => {
self.quote_depth = self.quote_depth.saturating_sub(1);
if self.quote_depth == 0 {
self.finish();
}
}
TagEnd::CodeBlock | TagEnd::HtmlBlock | TagEnd::MetadataBlock(_) => self.finish(),
TagEnd::List(_) => {
self.lists.pop();
}
TagEnd::Item | TagEnd::FootnoteDefinition => self.finish(),
TagEnd::DefinitionList => {}
TagEnd::DefinitionListTitle => {
self.raw("**");
self.finish();
}
TagEnd::DefinitionListDefinition => self.finish(),
TagEnd::Table => self.finish(),
TagEnd::TableHead => self.raw("────────\n"),
TagEnd::TableRow => {
if let Some(draft) = self.current.as_mut()
&& draft.source.ends_with("")
{
draft.source.truncate(draft.source.len() - "".len());
}
self.raw("\n");
}
TagEnd::TableCell => self.raw(""),
TagEnd::Emphasis => self.marker("*"),
TagEnd::Strong => self.marker("**"),
TagEnd::Strikethrough => self.marker("~~"),
TagEnd::Superscript => self.raw("\\^"),
TagEnd::Subscript => self.raw("\\~"),
TagEnd::Link | TagEnd::Image => self.end_link(),
}
}
fn begin(&mut self, kind: &'static str, plain: bool) {
self.finish();
self.current = Some(Draft {
kind,
source: String::new(),
plain,
});
}
fn finish(&mut self) {
let Some(draft) = self.current.take() else {
return;
};
let source = draft.source.trim_matches('\n');
if source.is_empty() && draft.kind != "rule" {
return;
}
let text = if draft.plain {
StyledText::from_plain_text(source)
} else {
StyledText::from_markdown(source)
.unwrap_or_else(|_| StyledText::from_plain_text(source))
};
self.blocks.push(MarkdownBlock {
kind: draft.kind.into(),
text,
});
}
fn text(&mut self, text: &str) {
if self.current.is_none() {
self.begin(
if self.quote_depth > 0 {
"quote"
} else {
"paragraph"
},
false,
);
}
if self.is_plain() {
self.raw(text);
return;
}
let mut escaped = String::with_capacity(text.len());
for character in text.chars() {
if character.is_ascii_punctuation() {
escaped.push('\\');
}
escaped.push(character);
}
self.raw(&escaped);
}
fn raw(&mut self, text: &str) {
if self.current.is_none() {
self.begin("paragraph", false);
}
self.current.as_mut().unwrap().source.push_str(text);
}
fn marker(&mut self, marker: &str) {
if !self.is_plain() {
self.raw(marker);
}
}
fn is_plain(&self) -> bool {
self.current.as_ref().is_some_and(|draft| draft.plain)
}
fn start_link(&mut self, url: String, image: bool) {
let styled = !self.is_plain();
if styled {
self.raw(if image { "[🖼 " } else { "[" });
} else if image {
self.raw("🖼 ");
}
self.links.push((url, image, styled));
}
fn end_link(&mut self) {
let Some((url, _, styled)) = self.links.pop() else {
return;
};
if styled {
self.raw(&format!("](<{}>)", url.replace('>', "%3E")));
} else {
self.raw(&format!(" ({url})"));
}
}
}
fn heading_kind(level: HeadingLevel) -> &'static str {
match level {
HeadingLevel::H1 => "heading1",
HeadingLevel::H2 => "heading2",
HeadingLevel::H3 => "heading3",
HeadingLevel::H4 => "heading4",
HeadingLevel::H5 => "heading5",
HeadingLevel::H6 => "heading6",
}
}
fn code_span(text: &str) -> String {
let longest = text
.split(|character| character != '`')
.map(str::len)
.max()
.unwrap_or(0);
let fence = "`".repeat(longest + 1);
format!("{fence} {text} {fence}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_extended_markdown_as_safe_styled_blocks() {
let blocks = render(
"# Title\n\n- [x] **done** with [link](https://example.com)\n\n> quoted\n\n```rust\nlet x = 1;\n```\n\n| A | B |\n| - | - |\n| 1 | 2 |",
);
let kinds: Vec<_> = blocks.iter().map(|block| block.kind.as_str()).collect();
assert_eq!(kinds, ["heading1", "list", "quote", "code", "table"]);
assert_eq!(
blocks[0].text,
StyledText::from_markdown("**Title**").unwrap()
);
assert_eq!(blocks[3].text, StyledText::from_plain_text("let x = 1;"));
}
}

View File

@@ -0,0 +1,800 @@
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 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,
}
#[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 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 = 52 * 7;
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
},
})
.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 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), (364, 5));
assert_eq!((cells[362].level, cells[363].level), (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");
}
}

View File

@@ -96,31 +96,10 @@ mod tests {
"https://gitea.example.com|octo/demo"
);
assert_eq!(Preferences::default().pull_status, "open");
assert_eq!(
Preferences::default().icon_style,
crate::domain::IconStyle::Outline
);
assert_eq!(
Preferences::default().appearance,
crate::domain::AppearanceMode::Auto
);
assert_eq!(
serde_json::from_str::<Preferences>(r#"{"icon_style":"duotone"}"#)
.unwrap()
.icon_style,
crate::domain::IconStyle::Duotone
);
assert_eq!(
serde_json::from_str::<Preferences>(r#"{"icon_style":"future-style"}"#)
.unwrap()
.icon_style,
crate::domain::IconStyle::Outline
);
assert_eq!(
crate::domain::IconStyle::from_index(2),
Some(crate::domain::IconStyle::Bold)
);
assert_eq!(crate::domain::IconStyle::from_index(3), None);
assert_eq!(
serde_json::from_str::<Preferences>(r#"{"appearance":"dark"}"#)
.unwrap()

View File

@@ -1 +0,0 @@
slint::include_modules!();

View File

@@ -1,627 +0,0 @@
use gotcha_gitea::models;
use slint::Color;
use crate::{
activity,
domain::{HistoryCommit, HomeData, IssueDetails, Preferences, PullDetails, State},
markdown, model,
storage::favorite_key,
ui::*,
};
pub fn refresh_servers(ui: &AppWindow, preferences: &Preferences) {
ui.set_servers(model(
preferences
.servers
.iter()
.map(|server| ServerRow {
name: server.name.clone().into(),
url: server.url.clone().into(),
})
.collect(),
));
}
pub fn refresh_repositories(ui: &AppWindow, state: &State) {
let server_url = state
.active_server
.and_then(|i| state.preferences.servers.get(i))
.map(|server| server.url.as_str())
.unwrap_or_default();
let mut repositories = state.repositories.clone();
repositories.sort_by_key(|repository| {
(
!state.preferences.favorites.contains(&favorite_key(
server_url,
&repository.owner,
&repository.name,
)),
repository.name.to_lowercase(),
)
});
ui.set_repositories(model(
repositories
.into_iter()
.map(|repository| {
let favorite = state.preferences.favorites.contains(&favorite_key(
server_url,
&repository.owner,
&repository.name,
));
RepositoryRow {
name: repository.name.into(),
owner: repository.owner.into(),
description: repository.description.into(),
meta: format!(
"{} · {} open · {}",
repository.language, repository.open_issues, repository.updated
)
.into(),
favorite,
}
})
.collect(),
));
ui.set_repositories_scroll_request(ui.get_repositories_scroll_request().wrapping_add(1));
}
pub fn refresh_issues(ui: &AppWindow, issues: &[models::Issue]) {
ui.set_issues(model(
issues
.iter()
.map(|issue| IssueRow {
number: issue.number.unwrap_or_default() as i32,
title: issue
.title
.clone()
.unwrap_or_else(|| "Untitled issue".into())
.into(),
summary: issue
.body
.as_deref()
.map(summary)
.unwrap_or_else(|| "No description".into())
.into(),
meta: issue_meta(issue).into(),
labels: model(
issue
.labels
.as_deref()
.unwrap_or_default()
.iter()
.filter_map(label_row)
.collect(),
),
})
.collect(),
));
ui.set_issues_scroll_request(ui.get_issues_scroll_request().wrapping_add(1));
}
pub fn refresh_pulls(ui: &AppWindow, pulls: &[models::Issue]) {
ui.set_pulls(model(
pulls
.iter()
.filter_map(|pull| {
let repository = pull.repository.as_ref()?;
let owner = repository.owner.clone()?;
let name = repository.name.clone()?;
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() as i32,
owner: owner.into(),
repository: name.into(),
title: pull
.title
.clone()
.unwrap_or_else(|| "Untitled pull request".into())
.into(),
summary: pull
.body
.as_deref()
.map(summary)
.filter(|body| !body.is_empty())
.unwrap_or_else(|| "No description".into())
.into(),
meta: format!(
"{kind} · {author} · {} · {} comments",
compact_date(pull.updated_at.as_deref()),
pull.comments.unwrap_or_default()
)
.into(),
})
})
.collect(),
));
}
pub fn refresh_commits(ui: &AppWindow, commits: &[HistoryCommit], lane_count: usize) {
ui.set_commits(model(
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)).into(),
refs: row.refs.join(" · ").into(),
lanes: model(
(0..lane_count)
.map(|lane| GraphLane {
color: Color::from_hsva(
(lane as f32 * 137.508) % 360.0,
0.78,
0.78,
1.0,
),
top: row.top_lanes.contains(&lane),
bottom: row.bottom_lanes.contains(&lane),
node: row.node_lane == Some(lane),
connect_left: row.connections.contains(&lane),
})
.collect(),
),
})
})
.collect(),
));
}
fn label_row(label: &models::Label) -> Option<LabelRow> {
let name = label.name.as_deref()?.trim();
let hex = label.color.as_deref()?.trim().trim_start_matches('#');
if name.is_empty() || hex.len() != 6 {
return None;
}
let value = u32::from_str_radix(hex, 16).ok()?;
let (red, green, blue) = (
((value >> 16) & 0xff) as u8,
((value >> 8) & 0xff) as u8,
(value & 0xff) as u8,
);
let light = red as u32 * 299 + green as u32 * 587 + blue as u32 * 114 > 150_000;
Some(LabelRow {
name: name.into(),
background: Color::from_rgb_u8(red, green, blue),
foreground: if light {
Color::from_rgb_u8(28, 28, 30)
} else {
Color::from_rgb_u8(255, 255, 255)
},
})
}
pub fn refresh_home(ui: &AppWindow, home: HomeData) {
let (cells, contributions) = heat_cells(&home.heatmap);
ui.set_heat_cells(model(cells));
ui.set_contribution_count(contributions.min(i32::MAX as i64) as i32);
ui.set_activities(model(home.activities.iter().map(activity_row).collect()));
ui.set_home_scroll_request(ui.get_home_scroll_request().wrapping_add(1));
}
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: title.into(),
detail: activity_detail(activity).into(),
meta: compact_date(activity.created.as_deref()).into(),
target: target.into(),
owner: owner.into(),
repository: repository.into(),
number: number.min(i32::MAX as i64) as i32,
sha: sha.into(),
}
}
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 = 52 * 7;
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 contributions = counts.iter().sum();
let cells = counts
.into_iter()
.enumerate()
.map(|(index, count)| HeatCell {
week: (index / 7) as i32,
day: (index % 7) as i32,
level: if count == 0 || maximum == 0 {
0
} else {
((count * 4 + maximum - 1) / maximum).clamp(1, 4) as i32
},
})
.collect();
(cells, contributions)
}
pub fn show_issue(ui: &AppWindow, details: &IssueDetails) {
let issue = &details.issue;
ui.set_issue_title(
issue
.title
.clone()
.unwrap_or_else(|| "Untitled issue".into())
.into(),
);
ui.set_issue_meta(issue_meta(issue).into());
ui.set_issue_body(model(markdown::render(
issue
.body
.as_deref()
.filter(|body| !body.is_empty())
.unwrap_or("No description provided."),
)));
ui.set_comments(model(details.comments.iter().map(comment_row).collect()));
ui.set_issue_scroll_request(ui.get_issue_scroll_request().wrapping_add(1));
ui.set_page("issue".into());
}
pub fn show_pull(ui: &AppWindow, details: &PullDetails) {
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")
};
ui.set_detail_title(
pull.title
.clone()
.unwrap_or_else(|| "Untitled pull request".into())
.into(),
);
ui.set_detail_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(),
)
.into(),
);
ui.set_detail_body(model(markdown::render(
pull.body
.as_deref()
.filter(|body| !body.is_empty())
.unwrap_or("No description provided."),
)));
ui.set_comments(model(details.comments.iter().map(comment_row).collect()));
ui.set_files(model(
details
.files
.iter()
.filter_map(|file| {
Some(FileRow {
path: file.filename.clone()?.into(),
status: file
.status
.clone()
.unwrap_or_else(|| "modified".into())
.into(),
})
})
.collect(),
));
ui.set_detail_files_ref(pull_files_ref(pull).into());
ui.set_page("pull".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 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: model(markdown::render(
comment
.body
.as_deref()
.filter(|body| !body.is_empty())
.unwrap_or("No comment text."),
)),
meta: compact_date(
comment
.updated_at
.as_deref()
.or(comment.created_at.as_deref()),
)
.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 summary(body: &str) -> String {
body.split_whitespace()
.take(24)
.collect::<Vec<_>>()
.join(" ")
}
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 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), (364, 5));
assert_eq!((cells[362].level, cells[363].level), (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());
}
#[test]
fn pull_files_use_the_live_branch_or_merge_commit() {
let mut pull = models::PullRequest {
state: Some("open".into()),
head: Some(Box::new(models::PrBranchInfo {
r#ref: Some("feature/files".into()),
..Default::default()
})),
merge_commit_sha: Some("0123456789abcdef".into()),
..Default::default()
};
assert_eq!(pull_files_ref(&pull), "Files on feature/files");
pull.state = Some("closed".into());
assert_eq!(pull_files_ref(&pull), "Files at merge commit 01234567");
}
#[test]
fn activity_rows_use_semantic_icons() {
let activity = models::Activity {
op_type: Some(models::activity::OpType::CreatePullRequest),
content: Some("42|feature".into()),
repo: Some(Box::new(models::Repository {
full_name: Some("octo/demo".into()),
..Default::default()
})),
..Default::default()
};
assert_eq!(activity_row(&activity).icon, "pull-open");
}
}