Prepare Gotcha 1.0 for release
This commit is contained in:
102
crates/app/src/core/content.rs
Normal file
102
crates/app/src/core/content.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use crate::*;
|
||||
|
||||
#[uniffi::export(async_runtime = "tokio")]
|
||||
impl GotchaCore {
|
||||
pub async fn commits(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
branch: Option<String>,
|
||||
path: String,
|
||||
pages: u32,
|
||||
) -> 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 path = (!path.is_empty()).then_some(path.as_str());
|
||||
let commits = match branch.as_deref() {
|
||||
Some(branch) => {
|
||||
load_branch_commits(&server, &owner, &repository, branch, path, pages.max(1))
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
load_all_commits(&server, &owner, &repository, &branches, path, pages.max(1))
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(commit_page(
|
||||
branches,
|
||||
&commits.items,
|
||||
branch.is_none(),
|
||||
commits.has_more,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn repository_contents(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
path: String,
|
||||
) -> Result<Vec<RepositoryContentRow>, GotchaError> {
|
||||
Ok(repository_content_rows(
|
||||
load_repository_contents(&self.server()?, &owner, &repository, &path).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn repository_file(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
path: String,
|
||||
) -> Result<RepositoryFilePage, GotchaError> {
|
||||
let data = load_repository_file(&self.server()?, &owner, &repository, &path).await?;
|
||||
Ok(repository_file_page(&path, data))
|
||||
}
|
||||
|
||||
pub async fn commit_details(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
sha: String,
|
||||
branch: Option<String>,
|
||||
) -> Result<CommitDetailsPage, GotchaError> {
|
||||
Ok(commit_details_page(
|
||||
load_commit(&self.server()?, &owner, &repository, &sha).await?,
|
||||
branch,
|
||||
))
|
||||
}
|
||||
|
||||
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?,
|
||||
))
|
||||
}
|
||||
}
|
||||
265
crates/app/src/core/issues.rs
Normal file
265
crates/app/src/core/issues.rs
Normal file
@@ -0,0 +1,265 @@
|
||||
use crate::*;
|
||||
|
||||
#[uniffi::export(async_runtime = "tokio")]
|
||||
impl GotchaCore {
|
||||
pub async fn issues(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
page: u32,
|
||||
) -> Result<IssueListPage, GotchaError> {
|
||||
let server = self.server()?;
|
||||
let (status, filter) = {
|
||||
let state = self.state.lock().unwrap();
|
||||
(
|
||||
state.preferences.issue_status.clone(),
|
||||
state
|
||||
.preferences
|
||||
.issue_filters
|
||||
.get(&repository_key(&server.url, &owner, &repository))
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
};
|
||||
let page = load_issues(
|
||||
&server,
|
||||
&owner,
|
||||
&repository,
|
||||
&status,
|
||||
&filter,
|
||||
valid_page(page)?,
|
||||
)
|
||||
.await?;
|
||||
Ok(IssueListPage {
|
||||
rows: issue_rows(&page.items),
|
||||
has_more: page.has_more,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn issue_filters(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
) -> Result<IssueFilterOptions, GotchaError> {
|
||||
let server = self.server()?;
|
||||
let filter = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.preferences
|
||||
.issue_filters
|
||||
.get(&repository_key(&server.url, &owner, &repository))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let (labels, milestones) = tokio::try_join!(
|
||||
load_labels(&server, &owner, &repository),
|
||||
load_milestones(&server, &owner, &repository)
|
||||
)?;
|
||||
Ok(issue_filter_options(&labels, &milestones, &filter))
|
||||
}
|
||||
|
||||
pub fn issue_filters_active(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
) -> Result<bool, GotchaError> {
|
||||
let server = self.server()?;
|
||||
let state = self.state.lock().unwrap();
|
||||
let filter = state
|
||||
.preferences
|
||||
.issue_filters
|
||||
.get(&repository_key(
|
||||
&server.url,
|
||||
owner.trim(),
|
||||
repository.trim(),
|
||||
))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Ok(filter.is_active(&state.preferences.issue_status))
|
||||
}
|
||||
|
||||
pub fn set_issue_filters(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
milestone: String,
|
||||
labels: Vec<String>,
|
||||
search_text: String,
|
||||
) -> Result<(), GotchaError> {
|
||||
let owner = owner.trim();
|
||||
let repository = repository.trim();
|
||||
if owner.is_empty() || repository.is_empty() {
|
||||
return Err("Select a repository first.".into());
|
||||
}
|
||||
let filter = IssueFilter {
|
||||
milestone,
|
||||
labels: labels
|
||||
.into_iter()
|
||||
.filter(|label| !label.is_empty())
|
||||
.collect(),
|
||||
search_text: search_text.trim().into(),
|
||||
};
|
||||
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 = repository_key(&server.url, owner, repository);
|
||||
if filter == IssueFilter::default() {
|
||||
state.preferences.issue_filters.remove(&key);
|
||||
} else {
|
||||
state.preferences.issue_filters.insert(key, filter);
|
||||
}
|
||||
save_preferences(&state.preferences).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn clear_issue_filters(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
) -> Result<(), GotchaError> {
|
||||
let owner = owner.trim();
|
||||
let repository = repository.trim();
|
||||
if owner.is_empty() || repository.is_empty() {
|
||||
return Err("Select a repository first.".into());
|
||||
}
|
||||
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 = repository_key(&server.url, owner, repository);
|
||||
state.preferences.issue_status = "open".into();
|
||||
state.preferences.issue_filters.remove(&key);
|
||||
save_preferences(&state.preferences).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn issue(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: i64,
|
||||
page: u32,
|
||||
) -> Result<IssuePage, GotchaError> {
|
||||
Ok(issue_page(
|
||||
load_issue(
|
||||
&self.server()?,
|
||||
&owner,
|
||||
&repository,
|
||||
number,
|
||||
valid_page(page)?,
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn save_issue_comment(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: i64,
|
||||
comment_id: Option<i64>,
|
||||
body: String,
|
||||
) -> Result<(), GotchaError> {
|
||||
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||
if number <= 0 || comment_id.is_some_and(|id| id <= 0) {
|
||||
return Err("Invalid issue comment selection.".into());
|
||||
}
|
||||
if body.trim().is_empty() {
|
||||
return Err("Enter a comment.".into());
|
||||
}
|
||||
save_issue_comment(&self.server()?, owner, repository, number, comment_id, body).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn issue_editor(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: Option<i64>,
|
||||
) -> Result<IssueEditorPage, GotchaError> {
|
||||
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||
if number.is_some_and(|number| number <= 0) {
|
||||
return Err("Invalid issue number.".into());
|
||||
}
|
||||
Ok(issue_editor_page(
|
||||
load_issue_editor(&self.server()?, owner, repository, number).await?,
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn save_issue(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: Option<i64>,
|
||||
title: String,
|
||||
body: String,
|
||||
label_ids: Vec<i64>,
|
||||
milestone_id: Option<i64>,
|
||||
due_date: Option<i64>,
|
||||
closed: bool,
|
||||
) -> Result<i64, GotchaError> {
|
||||
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||
let title = title.trim();
|
||||
if title.is_empty() {
|
||||
return Err("Enter an issue title.".into());
|
||||
}
|
||||
if number.is_some_and(|number| number <= 0)
|
||||
|| milestone_id.is_some_and(|id| id <= 0)
|
||||
|| label_ids.iter().any(|id| *id <= 0)
|
||||
{
|
||||
return Err("Invalid issue editor selection.".into());
|
||||
}
|
||||
let label_ids: Vec<_> = label_ids
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let draft = IssueDraft {
|
||||
title: title.into(),
|
||||
body,
|
||||
label_ids,
|
||||
milestone_id,
|
||||
due_date,
|
||||
closed,
|
||||
};
|
||||
let server = self.server()?;
|
||||
let issue = match number {
|
||||
Some(number) => edit_issue(&server, owner, repository, number, draft).await?,
|
||||
None => create_issue(&server, owner, repository, draft).await?,
|
||||
};
|
||||
issue
|
||||
.number
|
||||
.ok_or_else(|| "The saved issue has no number.".into())
|
||||
}
|
||||
|
||||
pub async fn set_issue_closed(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: i64,
|
||||
closed: bool,
|
||||
) -> Result<(), GotchaError> {
|
||||
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||
if number <= 0 {
|
||||
return Err("Invalid issue number.".into());
|
||||
}
|
||||
set_issue_closed(&self.server()?, owner, repository, number, closed).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_issue(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: i64,
|
||||
) -> Result<(), GotchaError> {
|
||||
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||
if number <= 0 {
|
||||
return Err("Invalid issue number.".into());
|
||||
}
|
||||
delete_issue(&self.server()?, owner, repository, number).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
109
crates/app/src/core/milestones.rs
Normal file
109
crates/app/src/core/milestones.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use crate::*;
|
||||
|
||||
#[uniffi::export(async_runtime = "tokio")]
|
||||
impl GotchaCore {
|
||||
pub async fn milestones(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
page: u32,
|
||||
) -> Result<MilestoneListPage, GotchaError> {
|
||||
let page =
|
||||
load_milestones_page(&self.server()?, &owner, &repository, valid_page(page)?).await?;
|
||||
Ok(MilestoneListPage {
|
||||
rows: milestone_rows(&page.items),
|
||||
has_more: page.has_more,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn milestone(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
id: i64,
|
||||
page: u32,
|
||||
) -> Result<MilestonePage, GotchaError> {
|
||||
Ok(milestone_page(
|
||||
load_milestone(&self.server()?, &owner, &repository, id, valid_page(page)?).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn milestone_editor(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
id: Option<i64>,
|
||||
) -> Result<MilestoneEditorPage, GotchaError> {
|
||||
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||
if id.is_some_and(|id| id <= 0) {
|
||||
return Err("Invalid milestone selection.".into());
|
||||
}
|
||||
let milestone = match id {
|
||||
Some(id) => Some(load_milestone_editor(&self.server()?, owner, repository, id).await?),
|
||||
None => None,
|
||||
};
|
||||
Ok(milestone_editor_page(milestone))
|
||||
}
|
||||
|
||||
pub async fn save_milestone(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
id: Option<i64>,
|
||||
draft: MilestoneEditorPage,
|
||||
) -> Result<i64, GotchaError> {
|
||||
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||
let title = draft.title.trim();
|
||||
if title.is_empty() {
|
||||
return Err("Enter a milestone title.".into());
|
||||
}
|
||||
if id.is_some_and(|id| id <= 0) {
|
||||
return Err("Invalid milestone selection.".into());
|
||||
}
|
||||
let milestone = save_milestone(
|
||||
&self.server()?,
|
||||
owner,
|
||||
repository,
|
||||
id,
|
||||
MilestoneDraft {
|
||||
title: title.into(),
|
||||
description: draft.description,
|
||||
due_date: draft.due_date,
|
||||
closed: draft.closed,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
milestone
|
||||
.id
|
||||
.ok_or_else(|| "The saved milestone has no ID.".into())
|
||||
}
|
||||
|
||||
pub async fn set_milestone_closed(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
id: i64,
|
||||
closed: bool,
|
||||
) -> Result<(), GotchaError> {
|
||||
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||
if id <= 0 {
|
||||
return Err("Invalid milestone selection.".into());
|
||||
}
|
||||
set_milestone_closed(&self.server()?, owner, repository, id, closed).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_milestone(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
id: i64,
|
||||
) -> Result<(), GotchaError> {
|
||||
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||
if id <= 0 {
|
||||
return Err("Invalid milestone selection.".into());
|
||||
}
|
||||
delete_milestone(&self.server()?, owner, repository, id).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
6
crates/app/src/core/mod.rs
Normal file
6
crates/app/src/core/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod content;
|
||||
mod issues;
|
||||
mod milestones;
|
||||
mod pulls;
|
||||
mod repositories;
|
||||
mod servers;
|
||||
115
crates/app/src/core/pulls.rs
Normal file
115
crates/app/src/core/pulls.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use crate::*;
|
||||
|
||||
#[uniffi::export(async_runtime = "tokio")]
|
||||
impl GotchaCore {
|
||||
pub async fn pull_filters(&self) -> Result<PullFilterOptions, GotchaError> {
|
||||
let server = self.server()?;
|
||||
let filter = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.preferences
|
||||
.pull_filters
|
||||
.get(&server.url)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Ok(pull_filter_options(
|
||||
&load_pull_milestones(&server).await?,
|
||||
&filter,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn pull_filters_active(&self) -> Result<bool, GotchaError> {
|
||||
let server = self.server()?;
|
||||
let state = self.state.lock().unwrap();
|
||||
let filter = state
|
||||
.preferences
|
||||
.pull_filters
|
||||
.get(&server.url)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Ok(filter.is_active(&state.preferences.pull_status))
|
||||
}
|
||||
|
||||
pub fn set_pull_filters(
|
||||
&self,
|
||||
milestone: String,
|
||||
search_text: String,
|
||||
) -> Result<(), GotchaError> {
|
||||
let filter = PullFilter {
|
||||
milestone,
|
||||
search_text: search_text.trim().into(),
|
||||
};
|
||||
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 = server.url.clone();
|
||||
if filter == PullFilter::default() {
|
||||
state.preferences.pull_filters.remove(&key);
|
||||
} else {
|
||||
state.preferences.pull_filters.insert(key, filter);
|
||||
}
|
||||
save_preferences(&state.preferences).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn clear_pull_filters(&self) -> Result<(), 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 = server.url.clone();
|
||||
state.preferences.pull_status = "open".into();
|
||||
state.preferences.pull_filters.remove(&key);
|
||||
save_preferences(&state.preferences).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn pulls(&self, page: u32) -> Result<PullListPage, GotchaError> {
|
||||
let server = self.server()?;
|
||||
let (status, filter) = {
|
||||
let state = self.state.lock().unwrap();
|
||||
(
|
||||
state.preferences.pull_status.clone(),
|
||||
state
|
||||
.preferences
|
||||
.pull_filters
|
||||
.get(&server.url)
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
};
|
||||
let page = load_pulls(
|
||||
&server,
|
||||
&status,
|
||||
&filter.milestone,
|
||||
&filter.search_text,
|
||||
valid_page(page)?,
|
||||
)
|
||||
.await?;
|
||||
Ok(PullListPage {
|
||||
rows: pull_rows(&page.items),
|
||||
has_more: page.has_more,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn pull(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: i64,
|
||||
page: u32,
|
||||
) -> Result<PullPage, GotchaError> {
|
||||
Ok(pull_page(
|
||||
load_pull(
|
||||
&self.server()?,
|
||||
&owner,
|
||||
&repository,
|
||||
number,
|
||||
valid_page(page)?,
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
}
|
||||
50
crates/app/src/core/repositories.rs
Normal file
50
crates/app/src/core/repositories.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use crate::*;
|
||||
|
||||
#[uniffi::export(async_runtime = "tokio")]
|
||||
impl GotchaCore {
|
||||
pub async fn repositories(
|
||||
&self,
|
||||
page: u32,
|
||||
pane: RepositoryPane,
|
||||
) -> Result<RepositoryListPage, GotchaError> {
|
||||
let server = self.server()?;
|
||||
let page_number = valid_page(page)?;
|
||||
let repositories = load_repositories(&server, page_number).await?;
|
||||
let mut state = self.state.lock().unwrap();
|
||||
if page_number == 1 {
|
||||
state.repositories.clear();
|
||||
}
|
||||
for repository in repositories.items {
|
||||
if let Some(existing) = state.repositories.iter_mut().find(|candidate| {
|
||||
candidate.owner == repository.owner && candidate.name == repository.name
|
||||
}) {
|
||||
*existing = repository;
|
||||
} else {
|
||||
state.repositories.push(repository);
|
||||
}
|
||||
}
|
||||
Ok(RepositoryListPage {
|
||||
rows: self.repository_rows(&state, pane),
|
||||
has_more: repositories.has_more,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn toggle_favorite(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
pane: RepositoryPane,
|
||||
) -> 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(pane, &server.url, &owner, &repository);
|
||||
if !state.preferences.favorites.remove(&key) {
|
||||
state.preferences.favorites.insert(key);
|
||||
}
|
||||
save_preferences(&state.preferences)?;
|
||||
Ok(self.repository_rows(&state, pane))
|
||||
}
|
||||
}
|
||||
144
crates/app/src/core/servers.rs
Normal file
144
crates/app/src/core/servers.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gotcha_gitea::Client;
|
||||
|
||||
use crate::*;
|
||||
|
||||
#[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,
|
||||
page: u32,
|
||||
filter: HomeActivityFilter,
|
||||
) -> Result<HomePage, GotchaError> {
|
||||
let server = self.server()?;
|
||||
let name = server.name.clone();
|
||||
Ok(home_page(
|
||||
name,
|
||||
load_home(&server, valid_page(page)?, filter.into()).await?,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub use gotcha_gitea::{
|
||||
HistoryCommit, HomeData, IssueDetails, IssueEditorData, MilestoneDetails, Page, PullDetails,
|
||||
parse_api_date,
|
||||
civil_from_days, days_from_civil, parse_api_date,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -146,31 +146,6 @@ pub fn open_status() -> String {
|
||||
"open".into()
|
||||
}
|
||||
|
||||
pub fn civil_from_days(days: i64) -> (i64, i64, i64) {
|
||||
let days = days + 719_468;
|
||||
let era = days.div_euclid(146_097);
|
||||
let day_of_era = days - era * 146_097;
|
||||
let year_of_era =
|
||||
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
|
||||
let mut year = year_of_era + era * 400;
|
||||
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
|
||||
let month_prime = (5 * day_of_year + 2) / 153;
|
||||
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
|
||||
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
|
||||
year += i64::from(month <= 2);
|
||||
(year, month, day)
|
||||
}
|
||||
|
||||
pub fn days_from_civil(mut year: i64, month: i64, day: i64) -> i64 {
|
||||
year -= i64::from(month <= 2);
|
||||
let era = year.div_euclid(400);
|
||||
let year_of_era = year - era * 400;
|
||||
let month_prime = month + if month > 2 { -3 } else { 9 };
|
||||
let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
|
||||
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
|
||||
era * 146_097 + day_of_era - 719_468
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use gotcha_gitea::Client;
|
||||
use std::sync::Mutex;
|
||||
use thiserror::Error;
|
||||
|
||||
mod api;
|
||||
mod core;
|
||||
mod domain;
|
||||
mod presentation;
|
||||
mod storage;
|
||||
@@ -65,766 +64,6 @@ pub struct GotchaCore {
|
||||
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,
|
||||
page: u32,
|
||||
filter: HomeActivityFilter,
|
||||
) -> Result<HomePage, GotchaError> {
|
||||
let server = self.server()?;
|
||||
let name = server.name.clone();
|
||||
Ok(home_page(
|
||||
name,
|
||||
load_home(&server, valid_page(page)?, filter.into()).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn repositories(
|
||||
&self,
|
||||
page: u32,
|
||||
pane: RepositoryPane,
|
||||
) -> Result<RepositoryListPage, GotchaError> {
|
||||
let server = self.server()?;
|
||||
let page_number = valid_page(page)?;
|
||||
let repositories = load_repositories(&server, page_number).await?;
|
||||
let mut state = self.state.lock().unwrap();
|
||||
if page_number == 1 {
|
||||
state.repositories.clear();
|
||||
}
|
||||
for repository in repositories.items {
|
||||
if let Some(existing) = state.repositories.iter_mut().find(|candidate| {
|
||||
candidate.owner == repository.owner && candidate.name == repository.name
|
||||
}) {
|
||||
*existing = repository;
|
||||
} else {
|
||||
state.repositories.push(repository);
|
||||
}
|
||||
}
|
||||
Ok(RepositoryListPage {
|
||||
rows: self.repository_rows(&state, pane),
|
||||
has_more: repositories.has_more,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn toggle_favorite(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
pane: RepositoryPane,
|
||||
) -> 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(pane, &server.url, &owner, &repository);
|
||||
if !state.preferences.favorites.remove(&key) {
|
||||
state.preferences.favorites.insert(key);
|
||||
}
|
||||
save_preferences(&state.preferences)?;
|
||||
Ok(self.repository_rows(&state, pane))
|
||||
}
|
||||
|
||||
pub async fn issues(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
page: u32,
|
||||
) -> Result<IssueListPage, GotchaError> {
|
||||
let server = self.server()?;
|
||||
let (status, filter) = {
|
||||
let state = self.state.lock().unwrap();
|
||||
(
|
||||
state.preferences.issue_status.clone(),
|
||||
state
|
||||
.preferences
|
||||
.issue_filters
|
||||
.get(&repository_key(&server.url, &owner, &repository))
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
};
|
||||
let page = load_issues(
|
||||
&server,
|
||||
&owner,
|
||||
&repository,
|
||||
&status,
|
||||
&filter,
|
||||
valid_page(page)?,
|
||||
)
|
||||
.await?;
|
||||
Ok(IssueListPage {
|
||||
rows: issue_rows(&page.items),
|
||||
has_more: page.has_more,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn issue_filters(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
) -> Result<IssueFilterOptions, GotchaError> {
|
||||
let server = self.server()?;
|
||||
let filter = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.preferences
|
||||
.issue_filters
|
||||
.get(&repository_key(&server.url, &owner, &repository))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let (labels, milestones) = tokio::try_join!(
|
||||
load_labels(&server, &owner, &repository),
|
||||
load_milestones(&server, &owner, &repository)
|
||||
)?;
|
||||
Ok(issue_filter_options(&labels, &milestones, &filter))
|
||||
}
|
||||
|
||||
pub fn issue_filters_active(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
) -> Result<bool, GotchaError> {
|
||||
let server = self.server()?;
|
||||
let state = self.state.lock().unwrap();
|
||||
let filter = state
|
||||
.preferences
|
||||
.issue_filters
|
||||
.get(&repository_key(
|
||||
&server.url,
|
||||
owner.trim(),
|
||||
repository.trim(),
|
||||
))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Ok(filter.is_active(&state.preferences.issue_status))
|
||||
}
|
||||
|
||||
pub fn set_issue_filters(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
milestone: String,
|
||||
labels: Vec<String>,
|
||||
search_text: String,
|
||||
) -> Result<(), GotchaError> {
|
||||
let owner = owner.trim();
|
||||
let repository = repository.trim();
|
||||
if owner.is_empty() || repository.is_empty() {
|
||||
return Err("Select a repository first.".into());
|
||||
}
|
||||
let filter = IssueFilter {
|
||||
milestone,
|
||||
labels: labels
|
||||
.into_iter()
|
||||
.filter(|label| !label.is_empty())
|
||||
.collect(),
|
||||
search_text: search_text.trim().into(),
|
||||
};
|
||||
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 = repository_key(&server.url, owner, repository);
|
||||
if filter == IssueFilter::default() {
|
||||
state.preferences.issue_filters.remove(&key);
|
||||
} else {
|
||||
state.preferences.issue_filters.insert(key, filter);
|
||||
}
|
||||
save_preferences(&state.preferences).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn clear_issue_filters(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
) -> Result<(), GotchaError> {
|
||||
let owner = owner.trim();
|
||||
let repository = repository.trim();
|
||||
if owner.is_empty() || repository.is_empty() {
|
||||
return Err("Select a repository first.".into());
|
||||
}
|
||||
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 = repository_key(&server.url, owner, repository);
|
||||
state.preferences.issue_status = "open".into();
|
||||
state.preferences.issue_filters.remove(&key);
|
||||
save_preferences(&state.preferences).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn issue(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: i64,
|
||||
page: u32,
|
||||
) -> Result<IssuePage, GotchaError> {
|
||||
Ok(issue_page(
|
||||
load_issue(
|
||||
&self.server()?,
|
||||
&owner,
|
||||
&repository,
|
||||
number,
|
||||
valid_page(page)?,
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn save_issue_comment(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: i64,
|
||||
comment_id: Option<i64>,
|
||||
body: String,
|
||||
) -> Result<(), GotchaError> {
|
||||
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||
if number <= 0 || comment_id.is_some_and(|id| id <= 0) {
|
||||
return Err("Invalid issue comment selection.".into());
|
||||
}
|
||||
if body.trim().is_empty() {
|
||||
return Err("Enter a comment.".into());
|
||||
}
|
||||
save_issue_comment(&self.server()?, owner, repository, number, comment_id, body).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn issue_editor(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: Option<i64>,
|
||||
) -> Result<IssueEditorPage, GotchaError> {
|
||||
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||
if number.is_some_and(|number| number <= 0) {
|
||||
return Err("Invalid issue number.".into());
|
||||
}
|
||||
Ok(issue_editor_page(
|
||||
load_issue_editor(&self.server()?, owner, repository, number).await?,
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn save_issue(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: Option<i64>,
|
||||
title: String,
|
||||
body: String,
|
||||
label_ids: Vec<i64>,
|
||||
milestone_id: Option<i64>,
|
||||
due_date: Option<i64>,
|
||||
closed: bool,
|
||||
) -> Result<i64, GotchaError> {
|
||||
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||
let title = title.trim();
|
||||
if title.is_empty() {
|
||||
return Err("Enter an issue title.".into());
|
||||
}
|
||||
if number.is_some_and(|number| number <= 0)
|
||||
|| milestone_id.is_some_and(|id| id <= 0)
|
||||
|| label_ids.iter().any(|id| *id <= 0)
|
||||
{
|
||||
return Err("Invalid issue editor selection.".into());
|
||||
}
|
||||
let label_ids: Vec<_> = label_ids
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let draft = IssueDraft {
|
||||
title: title.into(),
|
||||
body,
|
||||
label_ids,
|
||||
milestone_id,
|
||||
due_date,
|
||||
closed,
|
||||
};
|
||||
let server = self.server()?;
|
||||
let issue = match number {
|
||||
Some(number) => edit_issue(&server, owner, repository, number, draft).await?,
|
||||
None => create_issue(&server, owner, repository, draft).await?,
|
||||
};
|
||||
issue
|
||||
.number
|
||||
.ok_or_else(|| "The saved issue has no number.".into())
|
||||
}
|
||||
|
||||
pub async fn set_issue_closed(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: i64,
|
||||
closed: bool,
|
||||
) -> Result<(), GotchaError> {
|
||||
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||
if number <= 0 {
|
||||
return Err("Invalid issue number.".into());
|
||||
}
|
||||
set_issue_closed(&self.server()?, owner, repository, number, closed).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_issue(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: i64,
|
||||
) -> Result<(), GotchaError> {
|
||||
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||
if number <= 0 {
|
||||
return Err("Invalid issue number.".into());
|
||||
}
|
||||
delete_issue(&self.server()?, owner, repository, number).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn milestones(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
page: u32,
|
||||
) -> Result<MilestoneListPage, GotchaError> {
|
||||
let page =
|
||||
load_milestones_page(&self.server()?, &owner, &repository, valid_page(page)?).await?;
|
||||
Ok(MilestoneListPage {
|
||||
rows: milestone_rows(&page.items),
|
||||
has_more: page.has_more,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn milestone(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
id: i64,
|
||||
page: u32,
|
||||
) -> Result<MilestonePage, GotchaError> {
|
||||
Ok(milestone_page(
|
||||
load_milestone(&self.server()?, &owner, &repository, id, valid_page(page)?).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn milestone_editor(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
id: Option<i64>,
|
||||
) -> Result<MilestoneEditorPage, GotchaError> {
|
||||
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||
if id.is_some_and(|id| id <= 0) {
|
||||
return Err("Invalid milestone selection.".into());
|
||||
}
|
||||
let milestone = match id {
|
||||
Some(id) => Some(load_milestone_editor(&self.server()?, owner, repository, id).await?),
|
||||
None => None,
|
||||
};
|
||||
Ok(milestone_editor_page(milestone))
|
||||
}
|
||||
|
||||
pub async fn save_milestone(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
id: Option<i64>,
|
||||
draft: MilestoneEditorPage,
|
||||
) -> Result<i64, GotchaError> {
|
||||
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||
let title = draft.title.trim();
|
||||
if title.is_empty() {
|
||||
return Err("Enter a milestone title.".into());
|
||||
}
|
||||
if id.is_some_and(|id| id <= 0) {
|
||||
return Err("Invalid milestone selection.".into());
|
||||
}
|
||||
let milestone = save_milestone(
|
||||
&self.server()?,
|
||||
owner,
|
||||
repository,
|
||||
id,
|
||||
MilestoneDraft {
|
||||
title: title.into(),
|
||||
description: draft.description,
|
||||
due_date: draft.due_date,
|
||||
closed: draft.closed,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
milestone
|
||||
.id
|
||||
.ok_or_else(|| "The saved milestone has no ID.".into())
|
||||
}
|
||||
|
||||
pub async fn set_milestone_closed(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
id: i64,
|
||||
closed: bool,
|
||||
) -> Result<(), GotchaError> {
|
||||
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||
if id <= 0 {
|
||||
return Err("Invalid milestone selection.".into());
|
||||
}
|
||||
set_milestone_closed(&self.server()?, owner, repository, id, closed).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_milestone(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
id: i64,
|
||||
) -> Result<(), GotchaError> {
|
||||
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||
if id <= 0 {
|
||||
return Err("Invalid milestone selection.".into());
|
||||
}
|
||||
delete_milestone(&self.server()?, owner, repository, id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn pull_filters(&self) -> Result<PullFilterOptions, GotchaError> {
|
||||
let server = self.server()?;
|
||||
let filter = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.preferences
|
||||
.pull_filters
|
||||
.get(&server.url)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Ok(pull_filter_options(
|
||||
&load_pull_milestones(&server).await?,
|
||||
&filter,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn pull_filters_active(&self) -> Result<bool, GotchaError> {
|
||||
let server = self.server()?;
|
||||
let state = self.state.lock().unwrap();
|
||||
let filter = state
|
||||
.preferences
|
||||
.pull_filters
|
||||
.get(&server.url)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Ok(filter.is_active(&state.preferences.pull_status))
|
||||
}
|
||||
|
||||
pub fn set_pull_filters(
|
||||
&self,
|
||||
milestone: String,
|
||||
search_text: String,
|
||||
) -> Result<(), GotchaError> {
|
||||
let filter = PullFilter {
|
||||
milestone,
|
||||
search_text: search_text.trim().into(),
|
||||
};
|
||||
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 = server.url.clone();
|
||||
if filter == PullFilter::default() {
|
||||
state.preferences.pull_filters.remove(&key);
|
||||
} else {
|
||||
state.preferences.pull_filters.insert(key, filter);
|
||||
}
|
||||
save_preferences(&state.preferences).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn clear_pull_filters(&self) -> Result<(), 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 = server.url.clone();
|
||||
state.preferences.pull_status = "open".into();
|
||||
state.preferences.pull_filters.remove(&key);
|
||||
save_preferences(&state.preferences).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn pulls(&self, page: u32) -> Result<PullListPage, GotchaError> {
|
||||
let server = self.server()?;
|
||||
let (status, filter) = {
|
||||
let state = self.state.lock().unwrap();
|
||||
(
|
||||
state.preferences.pull_status.clone(),
|
||||
state
|
||||
.preferences
|
||||
.pull_filters
|
||||
.get(&server.url)
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
};
|
||||
let page = load_pulls(
|
||||
&server,
|
||||
&status,
|
||||
&filter.milestone,
|
||||
&filter.search_text,
|
||||
valid_page(page)?,
|
||||
)
|
||||
.await?;
|
||||
Ok(PullListPage {
|
||||
rows: pull_rows(&page.items),
|
||||
has_more: page.has_more,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn pull(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: i64,
|
||||
page: u32,
|
||||
) -> Result<PullPage, GotchaError> {
|
||||
Ok(pull_page(
|
||||
load_pull(
|
||||
&self.server()?,
|
||||
&owner,
|
||||
&repository,
|
||||
number,
|
||||
valid_page(page)?,
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn commits(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
branch: Option<String>,
|
||||
path: String,
|
||||
pages: u32,
|
||||
) -> 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 path = (!path.is_empty()).then_some(path.as_str());
|
||||
let commits = match branch.as_deref() {
|
||||
Some(branch) => {
|
||||
load_branch_commits(&server, &owner, &repository, branch, path, pages.max(1))
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
load_all_commits(&server, &owner, &repository, &branches, path, pages.max(1))
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(commit_page(
|
||||
branches,
|
||||
&commits.items,
|
||||
branch.is_none(),
|
||||
commits.has_more,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn repository_contents(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
path: String,
|
||||
) -> Result<Vec<RepositoryContentRow>, GotchaError> {
|
||||
Ok(repository_content_rows(
|
||||
load_repository_contents(&self.server()?, &owner, &repository, &path).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn repository_file(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
path: String,
|
||||
) -> Result<RepositoryFilePage, GotchaError> {
|
||||
let data = load_repository_file(&self.server()?, &owner, &repository, &path).await?;
|
||||
Ok(repository_file_page(&path, data))
|
||||
}
|
||||
|
||||
pub async fn commit_details(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
sha: String,
|
||||
branch: Option<String>,
|
||||
) -> Result<CommitDetailsPage, GotchaError> {
|
||||
Ok(commit_details_page(
|
||||
load_commit(&self.server()?, &owner, &repository, &sha).await?,
|
||||
branch,
|
||||
))
|
||||
}
|
||||
|
||||
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?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_repository<'a>(
|
||||
owner: &'a str,
|
||||
repository: &'a str,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
256
crates/app/src/presentation/details.rs
Normal file
256
crates/app/src/presentation/details.rs
Normal file
@@ -0,0 +1,256 @@
|
||||
use super::{helpers::*, *};
|
||||
|
||||
pub fn issue_page(details: IssueDetails) -> IssuePage {
|
||||
IssuePage {
|
||||
title: details
|
||||
.issue
|
||||
.title
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Untitled issue".into()),
|
||||
state: work_item_state(details.issue.state.as_deref()),
|
||||
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: work_item_state(pull.state.as_deref()),
|
||||
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, false));
|
||||
}
|
||||
if let Some(committer) = committer {
|
||||
metadata.push(metadata_row("Committer", committer, false));
|
||||
}
|
||||
if date.is_some() {
|
||||
metadata.push(metadata_row("Committed", compact_date(date), false));
|
||||
}
|
||||
if let Some(branch) = branch.filter(|branch| !branch.is_empty()) {
|
||||
metadata.push(metadata_row("Branch", branch, false));
|
||||
}
|
||||
metadata.push(metadata_row(
|
||||
"Commit",
|
||||
commit.sha.clone().unwrap_or_else(|| "unknown".into()),
|
||||
true,
|
||||
));
|
||||
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(),
|
||||
},
|
||||
false,
|
||||
));
|
||||
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, monospaced: bool) -> CommitMetadataRow {
|
||||
CommitMetadataRow {
|
||||
label: label.into(),
|
||||
value,
|
||||
monospaced,
|
||||
}
|
||||
}
|
||||
115
crates/app/src/presentation/files.rs
Normal file
115
crates/app/src/presentation/files.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use super::{helpers::*, *};
|
||||
|
||||
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: if content.r#type.as_deref() == Some("dir") {
|
||||
RepositoryContentKind::Directory
|
||||
} else {
|
||||
RepositoryContentKind::File
|
||||
},
|
||||
size: content.size.unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by_key(|row| {
|
||||
(
|
||||
row.kind != RepositoryContentKind::Directory,
|
||||
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: RepositoryFileKind::Preview,
|
||||
language,
|
||||
text: String::new(),
|
||||
data,
|
||||
};
|
||||
}
|
||||
match String::from_utf8(data) {
|
||||
Ok(text) => RepositoryFilePage {
|
||||
name,
|
||||
kind: if markdown {
|
||||
RepositoryFileKind::Markdown
|
||||
} else {
|
||||
RepositoryFileKind::Source
|
||||
},
|
||||
language,
|
||||
text,
|
||||
data: Vec::new(),
|
||||
},
|
||||
Err(error) => RepositoryFilePage {
|
||||
name,
|
||||
kind: RepositoryFileKind::Preview,
|
||||
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: match line.kind {
|
||||
"addition" => DiffLineKind::Addition,
|
||||
"removal" => DiffLineKind::Removal,
|
||||
"hunk" => DiffLineKind::Hunk,
|
||||
"header" => DiffLineKind::Header,
|
||||
_ => DiffLineKind::Context,
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
198
crates/app/src/presentation/helpers.rs
Normal file
198
crates/app/src/presentation/helpers.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) 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,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) 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),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn file_row(file: &models::ChangedFile) -> Option<FileRow> {
|
||||
Some(FileRow {
|
||||
path: file.filename.clone()?,
|
||||
status: file.status.clone().unwrap_or_else(|| "modified".into()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) 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(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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()
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn issue_milestone(issue: &models::Issue) -> String {
|
||||
issue
|
||||
.milestone
|
||||
.as_ref()
|
||||
.and_then(|milestone| milestone.title.as_deref())
|
||||
.unwrap_or_default()
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) 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: work_item_state(milestone.state.as_deref()),
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn work_item_state(state: Option<&str>) -> WorkItemState {
|
||||
match state {
|
||||
Some("open") => WorkItemState::Open,
|
||||
Some("closed") => WorkItemState::Closed,
|
||||
Some(_) | None => WorkItemState::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn short_sha(sha: &str) -> &str {
|
||||
&sha[..sha.len().min(8)]
|
||||
}
|
||||
|
||||
pub(super) 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,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn summary(body: &str) -> String {
|
||||
body.split_whitespace()
|
||||
.take(24)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
pub(super) 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()
|
||||
}
|
||||
229
crates/app/src/presentation/home.rs
Normal file
229
crates/app/src/presentation/home.rs
Normal file
@@ -0,0 +1,229 @@
|
||||
use super::{helpers::*, *};
|
||||
|
||||
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() => (
|
||||
ActivityIcon::Branch,
|
||||
format!("Created branch {branch} in {repository}"),
|
||||
),
|
||||
Some(OpType::CommitRepo | OpType::MirrorSyncPush) => (
|
||||
ActivityIcon::Push,
|
||||
format!("Pushed to {branch} in {repository}"),
|
||||
),
|
||||
Some(OpType::CreateRepo) => (ActivityIcon::Repository, format!("Created {repository}")),
|
||||
Some(OpType::RenameRepo) => (ActivityIcon::Repository, format!("Renamed {repository}")),
|
||||
Some(OpType::StarRepo) => (ActivityIcon::Repository, format!("Starred {repository}")),
|
||||
Some(OpType::WatchRepo) => (
|
||||
ActivityIcon::Repository,
|
||||
format!("Started watching {repository}"),
|
||||
),
|
||||
Some(OpType::CreateIssue) => (
|
||||
ActivityIcon::Issue,
|
||||
format!("Opened an issue in {repository}"),
|
||||
),
|
||||
Some(OpType::CloseIssue) => (
|
||||
ActivityIcon::Issue,
|
||||
format!("Closed an issue in {repository}"),
|
||||
),
|
||||
Some(OpType::ReopenIssue) => (
|
||||
ActivityIcon::Issue,
|
||||
format!("Reopened an issue in {repository}"),
|
||||
),
|
||||
Some(OpType::CommentIssue) => (
|
||||
ActivityIcon::Issue,
|
||||
format!("Commented on an issue in {repository}"),
|
||||
),
|
||||
Some(OpType::CreatePullRequest) => (
|
||||
ActivityIcon::PullRequest,
|
||||
format!("Opened a pull request in {repository}"),
|
||||
),
|
||||
Some(OpType::MergePullRequest | OpType::AutoMergePullRequest) => (
|
||||
ActivityIcon::PullRequest,
|
||||
format!("Merged a pull request in {repository}"),
|
||||
),
|
||||
Some(OpType::ClosePullRequest) => (
|
||||
ActivityIcon::PullRequest,
|
||||
format!("Closed a pull request in {repository}"),
|
||||
),
|
||||
Some(OpType::ReopenPullRequest) => (
|
||||
ActivityIcon::PullRequest,
|
||||
format!("Reopened a pull request in {repository}"),
|
||||
),
|
||||
Some(OpType::CommentPull) => (
|
||||
ActivityIcon::PullRequest,
|
||||
format!("Commented on a pull request in {repository}"),
|
||||
),
|
||||
Some(OpType::ApprovePullRequest) => (
|
||||
ActivityIcon::PullRequest,
|
||||
format!("Approved a pull request in {repository}"),
|
||||
),
|
||||
Some(OpType::RejectPullRequest) => (
|
||||
ActivityIcon::PullRequest,
|
||||
format!("Requested changes in {repository}"),
|
||||
),
|
||||
Some(OpType::PushTag) => (
|
||||
ActivityIcon::Tag,
|
||||
format!("Pushed tag {branch} in {repository}"),
|
||||
),
|
||||
Some(OpType::DeleteTag) => (
|
||||
ActivityIcon::Tag,
|
||||
format!("Deleted tag {branch} in {repository}"),
|
||||
),
|
||||
Some(OpType::DeleteBranch) => (
|
||||
ActivityIcon::Branch,
|
||||
format!("Deleted branch {branch} in {repository}"),
|
||||
),
|
||||
Some(OpType::PublishRelease) => (
|
||||
ActivityIcon::Release,
|
||||
format!("Published a release in {repository}"),
|
||||
),
|
||||
Some(_) | None => (ActivityIcon::Repository, format!("Updated {repository}")),
|
||||
};
|
||||
let target = activity::target(activity);
|
||||
let (target, owner, repository, number, sha) = match target {
|
||||
Some(activity::Target::Repository { owner, repository }) => (
|
||||
ActivityTargetKind::Repository,
|
||||
owner,
|
||||
repository,
|
||||
0,
|
||||
String::new(),
|
||||
),
|
||||
Some(activity::Target::Issue {
|
||||
owner,
|
||||
repository,
|
||||
number,
|
||||
}) => (
|
||||
ActivityTargetKind::Issue,
|
||||
owner,
|
||||
repository,
|
||||
number,
|
||||
String::new(),
|
||||
),
|
||||
Some(activity::Target::Pull {
|
||||
owner,
|
||||
repository,
|
||||
number,
|
||||
}) => (
|
||||
ActivityTargetKind::PullRequest,
|
||||
owner,
|
||||
repository,
|
||||
number,
|
||||
String::new(),
|
||||
),
|
||||
Some(activity::Target::Commit {
|
||||
owner,
|
||||
repository,
|
||||
sha,
|
||||
}) => (ActivityTargetKind::Commit, owner, repository, 0, sha),
|
||||
None => (
|
||||
ActivityTargetKind::None,
|
||||
String::new(),
|
||||
String::new(),
|
||||
0,
|
||||
String::new(),
|
||||
),
|
||||
};
|
||||
ActivityRow {
|
||||
icon,
|
||||
title,
|
||||
detail: activity_detail(activity),
|
||||
meta: compact_date(activity.created.as_deref()),
|
||||
target,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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)
|
||||
}
|
||||
272
crates/app/src/presentation/lists.rs
Normal file
272
crates/app/src/presentation/lists.rs
Normal file
@@ -0,0 +1,272 @@
|
||||
use super::{helpers::*, *};
|
||||
|
||||
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: work_item_state(issue.state.as_deref()),
|
||||
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: work_item_state(pull.state.as_deref()),
|
||||
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,
|
||||
}
|
||||
}
|
||||
387
crates/app/src/presentation/tests.rs
Normal file
387
crates/app/src/presentation/tests.rs
Normal file
@@ -0,0 +1,387 @@
|
||||
use super::{helpers::*, home::heat_cells, *};
|
||||
|
||||
#[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, WorkItemState::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, WorkItemState::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, WorkItemState::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, WorkItemState::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, WorkItemState::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,
|
||||
markdown.language.as_str()
|
||||
),
|
||||
("README.md", RepositoryFileKind::Markdown, "markdown")
|
||||
);
|
||||
assert_eq!(markdown.text, "# Hello");
|
||||
|
||||
let source = repository_file_page("Sources/App.swift", b"import UIKit".to_vec());
|
||||
assert_eq!(
|
||||
(source.kind, source.language.as_str()),
|
||||
(RepositoryFileKind::Source, "swift")
|
||||
);
|
||||
|
||||
let preview = repository_file_page("image.png", vec![0, 159]);
|
||||
assert_eq!(preview.kind, RepositoryFileKind::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,
|
||||
RepositoryFileKind::Preview
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user