Add issue creation and editing
This commit is contained in:
15
TESTING.md
15
TESTING.md
@@ -141,6 +141,21 @@ xcrun simctl launch booted de.rfc1437.gotcha
|
|||||||
selections persist independently per repository and status persists.
|
selections persist independently per repository and status persists.
|
||||||
- [ ] The issue-filter icon uses a neutral color for Open + All Milestones + no
|
- [ ] The issue-filter icon uses a neutral color for Open + All Milestones + no
|
||||||
labels, and the app accent color whenever any non-default filter is set.
|
labels, and the app accent color whenever any non-default filter is set.
|
||||||
|
- [ ] Tap the add button. **New Issue** appears as a native modal with Cancel
|
||||||
|
and Save, title and Markdown body fields, multi-select labels, a
|
||||||
|
single-select optional milestone, and an optional inline due-date picker.
|
||||||
|
- [ ] In the new-issue body, enter headings, emphasis, a list, a link, and a
|
||||||
|
fenced code block. **Write** syntax-highlights the Markdown and **Preview**
|
||||||
|
renders it; switching repeatedly preserves the exact source text.
|
||||||
|
- [ ] Save is disabled for an empty or whitespace-only title. Create issues with
|
||||||
|
no labels/milestone/due date and with multiple labels, a milestone, and a
|
||||||
|
due date; each new issue appears in the list and opens its detail.
|
||||||
|
- [ ] Tap **Edit** on an issue. Change its title and Markdown body, replace and
|
||||||
|
clear labels, select and clear its milestone, and add, change, and remove
|
||||||
|
its due date. Save, refresh, and relaunch to verify every change persists.
|
||||||
|
- [ ] Cancel both new and edited issues, including with the keyboard and date
|
||||||
|
picker visible. Nothing is saved, the modal dismisses normally, and the
|
||||||
|
issue list/detail remains usable with Dynamic Type and VoiceOver labels.
|
||||||
- [ ] Open both an open and a closed issue. A green open or purple closed icon
|
- [ ] Open both an open and a closed issue. A green open or purple closed icon
|
||||||
appears beside the title and VoiceOver announces the state; author
|
appears beside the title and VoiceOver announces the state; author
|
||||||
metadata, Markdown body, and comments remain correct. Links and
|
metadata, Markdown body, and comments remain correct. Links and
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use tokio::task::JoinSet;
|
|||||||
use crate::{
|
use crate::{
|
||||||
diff,
|
diff,
|
||||||
domain::{
|
domain::{
|
||||||
HistoryCommit, HomeData, IssueDetails, MilestoneDetails, PullDetails, RepositoryData,
|
HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, MilestoneDetails,
|
||||||
Server,
|
PullDetails, RepositoryData, Server, api_date,
|
||||||
},
|
},
|
||||||
presentation::compact_date,
|
presentation::compact_date,
|
||||||
};
|
};
|
||||||
@@ -430,6 +430,115 @@ pub async fn load_issue(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn load_issue_editor(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
number: Option<i64>,
|
||||||
|
) -> Result<IssueEditorData, String> {
|
||||||
|
let issue = async {
|
||||||
|
match number {
|
||||||
|
Some(number) => {
|
||||||
|
let client = Client::new(&server.url, Some(&server.token))
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
apis::issue_api::issue_get_issue(&client.configuration(), owner, repository, number)
|
||||||
|
.await
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (issue, labels, milestones) = tokio::try_join!(
|
||||||
|
issue,
|
||||||
|
load_labels(server, owner, repository),
|
||||||
|
load_milestones(server, owner, repository)
|
||||||
|
)?;
|
||||||
|
Ok(IssueEditorData {
|
||||||
|
issue,
|
||||||
|
labels,
|
||||||
|
milestones,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_issue_option(draft: IssueDraft) -> models::CreateIssueOption {
|
||||||
|
models::CreateIssueOption {
|
||||||
|
body: Some(draft.body),
|
||||||
|
due_date: draft.due_date.map(api_date),
|
||||||
|
labels: Some(draft.label_ids),
|
||||||
|
milestone: draft.milestone_id,
|
||||||
|
..models::CreateIssueOption::new(draft.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_issue_option(draft: &IssueDraft) -> models::EditIssueOption {
|
||||||
|
models::EditIssueOption {
|
||||||
|
body: Some(draft.body.clone()),
|
||||||
|
due_date: draft.due_date.map(api_date),
|
||||||
|
milestone: Some(draft.milestone_id.unwrap_or_default()),
|
||||||
|
title: Some(draft.title.clone()),
|
||||||
|
unset_due_date: draft.due_date.is_none().then_some(true),
|
||||||
|
..models::EditIssueOption::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_issue(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
draft: IssueDraft,
|
||||||
|
) -> Result<models::Issue, String> {
|
||||||
|
let client =
|
||||||
|
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||||
|
apis::issue_api::issue_create_issue(
|
||||||
|
&client.configuration(),
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
Some(create_issue_option(draft)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn edit_issue(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
number: i64,
|
||||||
|
draft: IssueDraft,
|
||||||
|
) -> Result<models::Issue, String> {
|
||||||
|
let client =
|
||||||
|
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||||
|
let configuration = client.configuration();
|
||||||
|
let issue = apis::issue_api::issue_edit_issue(
|
||||||
|
&configuration,
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
Some(edit_issue_option(&draft)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
apis::issue_api::issue_replace_labels(
|
||||||
|
&configuration,
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
Some(models::IssueLabelsOption {
|
||||||
|
labels: Some(
|
||||||
|
draft
|
||||||
|
.label_ids
|
||||||
|
.into_iter()
|
||||||
|
.map(serde_json::Value::from)
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
Ok(issue)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn load_pull(
|
pub async fn load_pull(
|
||||||
server: &Server,
|
server: &Server,
|
||||||
owner: &str,
|
owner: &str,
|
||||||
@@ -845,4 +954,32 @@ mod tests {
|
|||||||
assert!(commits[4].bottom_lanes.is_empty());
|
assert!(commits[4].bottom_lanes.is_empty());
|
||||||
assert_eq!(commits[4].node_lane, Some(0));
|
assert_eq!(commits[4].node_lane, Some(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_complete_create_and_edit_issue_requests() {
|
||||||
|
let create = create_issue_option(IssueDraft {
|
||||||
|
title: "Title".into(),
|
||||||
|
body: "**Body**".into(),
|
||||||
|
label_ids: vec![2, 3],
|
||||||
|
milestone_id: Some(5),
|
||||||
|
due_date: Some(1_709_164_800),
|
||||||
|
});
|
||||||
|
assert_eq!(create.title, "Title");
|
||||||
|
assert_eq!(create.body.as_deref(), Some("**Body**"));
|
||||||
|
assert_eq!(create.labels, Some(vec![2, 3]));
|
||||||
|
assert_eq!(create.milestone, Some(5));
|
||||||
|
assert_eq!(create.due_date.as_deref(), Some("2024-02-29T00:00:00Z"));
|
||||||
|
|
||||||
|
let edit = edit_issue_option(&IssueDraft {
|
||||||
|
title: "New".into(),
|
||||||
|
body: String::new(),
|
||||||
|
label_ids: Vec::new(),
|
||||||
|
milestone_id: None,
|
||||||
|
due_date: None,
|
||||||
|
});
|
||||||
|
assert_eq!(edit.title.as_deref(), Some("New"));
|
||||||
|
assert_eq!(edit.body.as_deref(), Some(""));
|
||||||
|
assert_eq!(edit.milestone, Some(0));
|
||||||
|
assert_eq!(edit.unset_due_date, Some(true));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,20 @@ pub struct IssueDetails {
|
|||||||
pub comments: Vec<models::Comment>,
|
pub comments: Vec<models::Comment>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct IssueEditorData {
|
||||||
|
pub issue: Option<models::Issue>,
|
||||||
|
pub labels: Vec<models::Label>,
|
||||||
|
pub milestones: Vec<models::Milestone>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct IssueDraft {
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub label_ids: Vec<i64>,
|
||||||
|
pub milestone_id: Option<i64>,
|
||||||
|
pub due_date: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct MilestoneDetails {
|
pub struct MilestoneDetails {
|
||||||
pub milestone: models::Milestone,
|
pub milestone: models::Milestone,
|
||||||
pub issues: Vec<models::Issue>,
|
pub issues: Vec<models::Issue>,
|
||||||
@@ -155,6 +169,49 @@ pub fn open_status() -> String {
|
|||||||
"open".into()
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn api_date(timestamp: i64) -> String {
|
||||||
|
let (year, month, day) = civil_from_days(timestamp.div_euclid(86_400));
|
||||||
|
format!("{year:04}-{month:02}-{day:02}T00:00:00Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_api_date(value: &str) -> Option<i64> {
|
||||||
|
let date = value.get(..10)?;
|
||||||
|
let mut parts = date.split('-');
|
||||||
|
let year = parts.next()?.parse().ok()?;
|
||||||
|
let month = parts.next()?.parse().ok()?;
|
||||||
|
let day = parts.next()?.parse().ok()?;
|
||||||
|
if parts.next().is_some() || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let days = days_from_civil(year, month, day);
|
||||||
|
(civil_from_days(days) == (year, month, day)).then_some(days * 86_400)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -179,4 +236,12 @@ mod tests {
|
|||||||
.is_active("open")
|
.is_active("open")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn issue_dates_round_trip_and_reject_invalid_dates() {
|
||||||
|
let leap_day = parse_api_date("2024-02-29T12:34:56Z").unwrap();
|
||||||
|
assert_eq!(api_date(leap_day), "2024-02-29T00:00:00Z");
|
||||||
|
assert_eq!(parse_api_date("2023-02-29T00:00:00Z"), None);
|
||||||
|
assert_eq!(parse_api_date("not-a-date"), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,6 +323,66 @@ impl GotchaCore {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>,
|
||||||
|
) -> 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,
|
||||||
|
};
|
||||||
|
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 milestones(
|
pub async fn milestones(
|
||||||
&self,
|
&self,
|
||||||
owner: String,
|
owner: String,
|
||||||
@@ -502,6 +562,18 @@ impl GotchaCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_repository<'a>(
|
||||||
|
owner: &'a str,
|
||||||
|
repository: &'a str,
|
||||||
|
) -> Result<(&'a str, &'a str), GotchaError> {
|
||||||
|
let owner = owner.trim();
|
||||||
|
let repository = repository.trim();
|
||||||
|
if owner.is_empty() || repository.is_empty() {
|
||||||
|
return Err("Select a repository first.".into());
|
||||||
|
}
|
||||||
|
Ok((owner, repository))
|
||||||
|
}
|
||||||
|
|
||||||
impl GotchaCore {
|
impl GotchaCore {
|
||||||
fn server(&self) -> Result<Server, GotchaError> {
|
fn server(&self) -> Result<Server, GotchaError> {
|
||||||
let state = self.state.lock().unwrap();
|
let state = self.state.lock().unwrap();
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use gotcha_gitea::models;
|
|||||||
use crate::{
|
use crate::{
|
||||||
activity,
|
activity,
|
||||||
domain::{
|
domain::{
|
||||||
HistoryCommit, HomeData, IssueDetails, IssueFilter, MilestoneDetails, PullDetails,
|
HistoryCommit, HomeData, IssueDetails, IssueEditorData, IssueFilter, MilestoneDetails,
|
||||||
PullFilter, RepositoryData,
|
PullDetails, PullFilter, RepositoryData, civil_from_days, days_from_civil, parse_api_date,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,6 +67,29 @@ pub struct IssuePage {
|
|||||||
pub comments: Vec<CommentRow>,
|
pub comments: Vec<CommentRow>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct IssueEditorLabel {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub selected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct IssueEditorMilestone {
|
||||||
|
pub id: i64,
|
||||||
|
pub title: String,
|
||||||
|
pub selected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct IssueEditorPage {
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub due_date: Option<i64>,
|
||||||
|
pub labels: Vec<IssueEditorLabel>,
|
||||||
|
pub milestones: Vec<IssueEditorMilestone>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, uniffi::Record)]
|
#[derive(Clone, uniffi::Record)]
|
||||||
pub struct IssueFilterOptions {
|
pub struct IssueFilterOptions {
|
||||||
pub milestones: Vec<String>,
|
pub milestones: Vec<String>,
|
||||||
@@ -457,6 +480,70 @@ pub fn issue_page(details: IssueDetails) -> IssuePage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn issue_editor_page(data: IssueEditorData) -> IssueEditorPage {
|
||||||
|
let selected_labels: std::collections::BTreeSet<_> = data
|
||||||
|
.issue
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|issue| issue.labels.as_deref())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|label| label.id)
|
||||||
|
.collect();
|
||||||
|
let selected_milestone = data
|
||||||
|
.issue
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|issue| issue.milestone.as_ref())
|
||||||
|
.and_then(|milestone| milestone.id);
|
||||||
|
let mut labels: Vec<_> = data
|
||||||
|
.labels
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|label| {
|
||||||
|
let id = label.id?;
|
||||||
|
Some(IssueEditorLabel {
|
||||||
|
id,
|
||||||
|
name: label.name?,
|
||||||
|
selected: selected_labels.contains(&id),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
labels.sort_by_key(|label| label.name.to_lowercase());
|
||||||
|
let mut milestones: Vec<_> = data
|
||||||
|
.milestones
|
||||||
|
.into_iter()
|
||||||
|
.filter(|milestone| {
|
||||||
|
milestone.state.as_deref() != Some("closed") || milestone.id == selected_milestone
|
||||||
|
})
|
||||||
|
.filter_map(|milestone| {
|
||||||
|
let id = milestone.id?;
|
||||||
|
Some(IssueEditorMilestone {
|
||||||
|
id,
|
||||||
|
title: milestone.title?,
|
||||||
|
selected: Some(id) == selected_milestone,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
milestones.sort_by_key(|milestone| milestone.title.to_lowercase());
|
||||||
|
IssueEditorPage {
|
||||||
|
title: data
|
||||||
|
.issue
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|issue| issue.title.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
body: data
|
||||||
|
.issue
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|issue| issue.body.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
due_date: data
|
||||||
|
.issue
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|issue| issue.due_date.as_deref())
|
||||||
|
.and_then(parse_api_date),
|
||||||
|
labels,
|
||||||
|
milestones,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn pull_page(details: PullDetails) -> PullPage {
|
pub fn pull_page(details: PullDetails) -> PullPage {
|
||||||
let pull = &details.pull;
|
let pull = &details.pull;
|
||||||
let author = pull
|
let author = pull
|
||||||
@@ -840,31 +927,6 @@ fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec<HeatCell>, i64) {
|
|||||||
(cells, contribution_count)
|
(cells, contribution_count)
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
fn label_row(label: &models::Label) -> Option<LabelRow> {
|
fn label_row(label: &models::Label) -> Option<LabelRow> {
|
||||||
let name = label.name.as_deref()?.trim();
|
let name = label.name.as_deref()?.trim();
|
||||||
let color = label.color.as_deref()?.trim().trim_start_matches('#');
|
let color = label.color.as_deref()?.trim().trim_start_matches('#');
|
||||||
@@ -1172,6 +1234,42 @@ mod tests {
|
|||||||
assert_eq!(page.state, "closed");
|
assert_eq!(page.state, "closed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn issue_editor_exposes_selected_metadata_and_due_date() {
|
||||||
|
let selected_label = models::Label {
|
||||||
|
id: Some(2),
|
||||||
|
name: Some("bug".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let page = issue_editor_page(IssueEditorData {
|
||||||
|
issue: Some(models::Issue {
|
||||||
|
title: Some("Title".into()),
|
||||||
|
body: Some("Body".into()),
|
||||||
|
due_date: Some("2024-02-29T18:00:00Z".into()),
|
||||||
|
labels: Some(vec![selected_label.clone()]),
|
||||||
|
milestone: Some(Box::new(models::Milestone {
|
||||||
|
id: Some(4),
|
||||||
|
title: Some("Current".into()),
|
||||||
|
state: Some("closed".into()),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
labels: vec![selected_label],
|
||||||
|
milestones: vec![models::Milestone {
|
||||||
|
id: Some(4),
|
||||||
|
title: Some("Current".into()),
|
||||||
|
state: Some("closed".into()),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!((page.title.as_str(), page.body.as_str()), ("Title", "Body"));
|
||||||
|
assert_eq!(page.due_date, Some(1_709_164_800));
|
||||||
|
assert!(page.labels[0].selected);
|
||||||
|
assert!(page.milestones[0].selected);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builds_sorted_issue_filter_options() {
|
fn builds_sorted_issue_filter_options() {
|
||||||
let filter = IssueFilter {
|
let filter = IssueFilter {
|
||||||
|
|||||||
@@ -619,6 +619,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable {
|
|||||||
|
|
||||||
func issue(owner: String, repository: String, number: Int64) async throws -> IssuePage
|
func issue(owner: String, repository: String, number: Int64) async throws -> IssuePage
|
||||||
|
|
||||||
|
func issueEditor(owner: String, repository: String, number: Int64?) async throws -> IssueEditorPage
|
||||||
|
|
||||||
func issueFilters(owner: String, repository: String) async throws -> IssueFilterOptions
|
func issueFilters(owner: String, repository: String) async throws -> IssueFilterOptions
|
||||||
|
|
||||||
func issueFiltersActive(owner: String, repository: String) throws -> Bool
|
func issueFiltersActive(owner: String, repository: String) throws -> Bool
|
||||||
@@ -645,6 +647,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable {
|
|||||||
|
|
||||||
func repositoryFile(owner: String, repository: String, path: String) async throws -> RepositoryFilePage
|
func repositoryFile(owner: String, repository: String, path: String) async throws -> RepositoryFilePage
|
||||||
|
|
||||||
|
func saveIssue(owner: String, repository: String, number: Int64?, title: String, body: String, labelIds: [Int64], milestoneId: Int64?, dueDate: Int64?) async throws -> Int64
|
||||||
|
|
||||||
func selectServer(index: UInt32) throws
|
func selectServer(index: UInt32) throws
|
||||||
|
|
||||||
func servers() -> [ServerRow]
|
func servers() -> [ServerRow]
|
||||||
@@ -841,6 +845,22 @@ open func issue(owner: String, repository: String, number: Int64)async throws -
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
open func issueEditor(owner: String, repository: String, number: Int64?)async throws -> IssueEditorPage {
|
||||||
|
return
|
||||||
|
try await uniffiRustCallAsync(
|
||||||
|
rustFutureFunc: {
|
||||||
|
uniffi_gotcha_core_fn_method_gotchacore_issue_editor(
|
||||||
|
self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterOptionInt64.lower(number)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer,
|
||||||
|
completeFunc: ffi_gotcha_core_rust_future_complete_rust_buffer,
|
||||||
|
freeFunc: ffi_gotcha_core_rust_future_free_rust_buffer,
|
||||||
|
liftFunc: FfiConverterTypeIssueEditorPage_lift,
|
||||||
|
errorHandler: FfiConverterTypeGotchaError_lift
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
open func issueFilters(owner: String, repository: String)async throws -> IssueFilterOptions {
|
open func issueFilters(owner: String, repository: String)async throws -> IssueFilterOptions {
|
||||||
return
|
return
|
||||||
try await uniffiRustCallAsync(
|
try await uniffiRustCallAsync(
|
||||||
@@ -1037,6 +1057,22 @@ open func repositoryFile(owner: String, repository: String, path: String)async t
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
open func saveIssue(owner: String, repository: String, number: Int64?, title: String, body: String, labelIds: [Int64], milestoneId: Int64?, dueDate: Int64?)async throws -> Int64 {
|
||||||
|
return
|
||||||
|
try await uniffiRustCallAsync(
|
||||||
|
rustFutureFunc: {
|
||||||
|
uniffi_gotcha_core_fn_method_gotchacore_save_issue(
|
||||||
|
self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterOptionInt64.lower(number),FfiConverterString.lower(title),FfiConverterString.lower(body),FfiConverterSequenceInt64.lower(labelIds),FfiConverterOptionInt64.lower(milestoneId),FfiConverterOptionInt64.lower(dueDate)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
pollFunc: ffi_gotcha_core_rust_future_poll_i64,
|
||||||
|
completeFunc: ffi_gotcha_core_rust_future_complete_i64,
|
||||||
|
freeFunc: ffi_gotcha_core_rust_future_free_i64,
|
||||||
|
liftFunc: FfiConverterInt64.lift,
|
||||||
|
errorHandler: FfiConverterTypeGotchaError_lift
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
open func selectServer(index: UInt32)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) {
|
open func selectServer(index: UInt32)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) {
|
||||||
uniffiCallStatus in
|
uniffiCallStatus in
|
||||||
uniffi_gotcha_core_fn_method_gotchacore_select_server(
|
uniffi_gotcha_core_fn_method_gotchacore_select_server(
|
||||||
@@ -1750,6 +1786,188 @@ public func FfiConverterTypeHomePage_lower(_ value: HomePage) -> RustBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public struct IssueEditorLabel: Equatable, Hashable {
|
||||||
|
public var id: Int64
|
||||||
|
public var name: String
|
||||||
|
public var selected: Bool
|
||||||
|
|
||||||
|
// Default memberwise initializers are never public by default, so we
|
||||||
|
// declare one manually.
|
||||||
|
public init(id: Int64, name: String, selected: Bool) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.selected = selected
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#if compiler(>=6)
|
||||||
|
extension IssueEditorLabel: Sendable {}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public struct FfiConverterTypeIssueEditorLabel: FfiConverterRustBuffer {
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> IssueEditorLabel {
|
||||||
|
return
|
||||||
|
try IssueEditorLabel(
|
||||||
|
id: FfiConverterInt64.read(from: &buf),
|
||||||
|
name: FfiConverterString.read(from: &buf),
|
||||||
|
selected: FfiConverterBool.read(from: &buf)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ value: IssueEditorLabel, into buf: inout [UInt8]) {
|
||||||
|
FfiConverterInt64.write(value.id, into: &buf)
|
||||||
|
FfiConverterString.write(value.name, into: &buf)
|
||||||
|
FfiConverterBool.write(value.selected, into: &buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeIssueEditorLabel_lift(_ buf: RustBuffer) throws -> IssueEditorLabel {
|
||||||
|
return try FfiConverterTypeIssueEditorLabel.lift(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeIssueEditorLabel_lower(_ value: IssueEditorLabel) -> RustBuffer {
|
||||||
|
return FfiConverterTypeIssueEditorLabel.lower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public struct IssueEditorMilestone: Equatable, Hashable {
|
||||||
|
public var id: Int64
|
||||||
|
public var title: String
|
||||||
|
public var selected: Bool
|
||||||
|
|
||||||
|
// Default memberwise initializers are never public by default, so we
|
||||||
|
// declare one manually.
|
||||||
|
public init(id: Int64, title: String, selected: Bool) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.selected = selected
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#if compiler(>=6)
|
||||||
|
extension IssueEditorMilestone: Sendable {}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public struct FfiConverterTypeIssueEditorMilestone: FfiConverterRustBuffer {
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> IssueEditorMilestone {
|
||||||
|
return
|
||||||
|
try IssueEditorMilestone(
|
||||||
|
id: FfiConverterInt64.read(from: &buf),
|
||||||
|
title: FfiConverterString.read(from: &buf),
|
||||||
|
selected: FfiConverterBool.read(from: &buf)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ value: IssueEditorMilestone, into buf: inout [UInt8]) {
|
||||||
|
FfiConverterInt64.write(value.id, into: &buf)
|
||||||
|
FfiConverterString.write(value.title, into: &buf)
|
||||||
|
FfiConverterBool.write(value.selected, into: &buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeIssueEditorMilestone_lift(_ buf: RustBuffer) throws -> IssueEditorMilestone {
|
||||||
|
return try FfiConverterTypeIssueEditorMilestone.lift(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeIssueEditorMilestone_lower(_ value: IssueEditorMilestone) -> RustBuffer {
|
||||||
|
return FfiConverterTypeIssueEditorMilestone.lower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public struct IssueEditorPage: Equatable, Hashable {
|
||||||
|
public var title: String
|
||||||
|
public var body: String
|
||||||
|
public var dueDate: Int64?
|
||||||
|
public var labels: [IssueEditorLabel]
|
||||||
|
public var milestones: [IssueEditorMilestone]
|
||||||
|
|
||||||
|
// Default memberwise initializers are never public by default, so we
|
||||||
|
// declare one manually.
|
||||||
|
public init(title: String, body: String, dueDate: Int64?, labels: [IssueEditorLabel], milestones: [IssueEditorMilestone]) {
|
||||||
|
self.title = title
|
||||||
|
self.body = body
|
||||||
|
self.dueDate = dueDate
|
||||||
|
self.labels = labels
|
||||||
|
self.milestones = milestones
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#if compiler(>=6)
|
||||||
|
extension IssueEditorPage: Sendable {}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public struct FfiConverterTypeIssueEditorPage: FfiConverterRustBuffer {
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> IssueEditorPage {
|
||||||
|
return
|
||||||
|
try IssueEditorPage(
|
||||||
|
title: FfiConverterString.read(from: &buf),
|
||||||
|
body: FfiConverterString.read(from: &buf),
|
||||||
|
dueDate: FfiConverterOptionInt64.read(from: &buf),
|
||||||
|
labels: FfiConverterSequenceTypeIssueEditorLabel.read(from: &buf),
|
||||||
|
milestones: FfiConverterSequenceTypeIssueEditorMilestone.read(from: &buf)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ value: IssueEditorPage, into buf: inout [UInt8]) {
|
||||||
|
FfiConverterString.write(value.title, into: &buf)
|
||||||
|
FfiConverterString.write(value.body, into: &buf)
|
||||||
|
FfiConverterOptionInt64.write(value.dueDate, into: &buf)
|
||||||
|
FfiConverterSequenceTypeIssueEditorLabel.write(value.labels, into: &buf)
|
||||||
|
FfiConverterSequenceTypeIssueEditorMilestone.write(value.milestones, into: &buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeIssueEditorPage_lift(_ buf: RustBuffer) throws -> IssueEditorPage {
|
||||||
|
return try FfiConverterTypeIssueEditorPage.lift(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeIssueEditorPage_lower(_ value: IssueEditorPage) -> RustBuffer {
|
||||||
|
return FfiConverterTypeIssueEditorPage.lower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public struct IssueFilterOptions: Equatable, Hashable {
|
public struct IssueFilterOptions: Equatable, Hashable {
|
||||||
public var milestones: [String]
|
public var milestones: [String]
|
||||||
public var labels: [String]
|
public var labels: [String]
|
||||||
@@ -2744,6 +2962,30 @@ fileprivate struct FfiConverterOptionUInt32: FfiConverterRustBuffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
fileprivate struct FfiConverterOptionInt64: FfiConverterRustBuffer {
|
||||||
|
typealias SwiftType = Int64?
|
||||||
|
|
||||||
|
public static func write(_ value: SwiftType, into buf: inout [UInt8]) {
|
||||||
|
guard let value = value else {
|
||||||
|
writeInt(&buf, Int8(0))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeInt(&buf, Int8(1))
|
||||||
|
FfiConverterInt64.write(value, into: &buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType {
|
||||||
|
switch try readInt(&buf) as Int8 {
|
||||||
|
case 0: return nil
|
||||||
|
case 1: return try FfiConverterInt64.read(from: &buf)
|
||||||
|
default: throw UniffiInternalError.unexpectedOptionalTag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if swift(>=5.8)
|
#if swift(>=5.8)
|
||||||
@_documentation(visibility: private)
|
@_documentation(visibility: private)
|
||||||
#endif
|
#endif
|
||||||
@@ -2793,6 +3035,31 @@ fileprivate struct FfiConverterSequenceUInt32: FfiConverterRustBuffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
fileprivate struct FfiConverterSequenceInt64: FfiConverterRustBuffer {
|
||||||
|
typealias SwiftType = [Int64]
|
||||||
|
|
||||||
|
public static func write(_ value: [Int64], into buf: inout [UInt8]) {
|
||||||
|
let len = Int32(value.count)
|
||||||
|
writeInt(&buf, len)
|
||||||
|
for item in value {
|
||||||
|
FfiConverterInt64.write(item, into: &buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [Int64] {
|
||||||
|
let len: Int32 = try readInt(&buf)
|
||||||
|
var seq = [Int64]()
|
||||||
|
seq.reserveCapacity(Int(len))
|
||||||
|
for _ in 0 ..< len {
|
||||||
|
seq.append(try FfiConverterInt64.read(from: &buf))
|
||||||
|
}
|
||||||
|
return seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if swift(>=5.8)
|
#if swift(>=5.8)
|
||||||
@_documentation(visibility: private)
|
@_documentation(visibility: private)
|
||||||
#endif
|
#endif
|
||||||
@@ -2968,6 +3235,56 @@ fileprivate struct FfiConverterSequenceTypeHeatCell: FfiConverterRustBuffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
fileprivate struct FfiConverterSequenceTypeIssueEditorLabel: FfiConverterRustBuffer {
|
||||||
|
typealias SwiftType = [IssueEditorLabel]
|
||||||
|
|
||||||
|
public static func write(_ value: [IssueEditorLabel], into buf: inout [UInt8]) {
|
||||||
|
let len = Int32(value.count)
|
||||||
|
writeInt(&buf, len)
|
||||||
|
for item in value {
|
||||||
|
FfiConverterTypeIssueEditorLabel.write(item, into: &buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [IssueEditorLabel] {
|
||||||
|
let len: Int32 = try readInt(&buf)
|
||||||
|
var seq = [IssueEditorLabel]()
|
||||||
|
seq.reserveCapacity(Int(len))
|
||||||
|
for _ in 0 ..< len {
|
||||||
|
seq.append(try FfiConverterTypeIssueEditorLabel.read(from: &buf))
|
||||||
|
}
|
||||||
|
return seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
fileprivate struct FfiConverterSequenceTypeIssueEditorMilestone: FfiConverterRustBuffer {
|
||||||
|
typealias SwiftType = [IssueEditorMilestone]
|
||||||
|
|
||||||
|
public static func write(_ value: [IssueEditorMilestone], into buf: inout [UInt8]) {
|
||||||
|
let len = Int32(value.count)
|
||||||
|
writeInt(&buf, len)
|
||||||
|
for item in value {
|
||||||
|
FfiConverterTypeIssueEditorMilestone.write(item, into: &buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [IssueEditorMilestone] {
|
||||||
|
let len: Int32 = try readInt(&buf)
|
||||||
|
var seq = [IssueEditorMilestone]()
|
||||||
|
seq.reserveCapacity(Int(len))
|
||||||
|
for _ in 0 ..< len {
|
||||||
|
seq.append(try FfiConverterTypeIssueEditorMilestone.read(from: &buf))
|
||||||
|
}
|
||||||
|
return seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if swift(>=5.8)
|
#if swift(>=5.8)
|
||||||
@_documentation(visibility: private)
|
@_documentation(visibility: private)
|
||||||
#endif
|
#endif
|
||||||
@@ -3230,6 +3547,9 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_gotcha_core_checksum_method_gotchacore_issue() != 56735) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_issue() != 56735) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
|
if (uniffi_gotcha_core_checksum_method_gotchacore_issue_editor() != 10078) {
|
||||||
|
return InitializationResult.apiChecksumMismatch
|
||||||
|
}
|
||||||
if (uniffi_gotcha_core_checksum_method_gotchacore_issue_filters() != 24054) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_issue_filters() != 24054) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
@@ -3269,6 +3589,9 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_gotcha_core_checksum_method_gotchacore_repository_file() != 44948) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_repository_file() != 44948) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
|
if (uniffi_gotcha_core_checksum_method_gotchacore_save_issue() != 55053) {
|
||||||
|
return InitializationResult.apiChecksumMismatch
|
||||||
|
}
|
||||||
if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 22721) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 22721) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,6 +299,11 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_home(uint64_t ptr
|
|||||||
uint64_t uniffi_gotcha_core_fn_method_gotchacore_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE_EDITOR
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE_EDITOR
|
||||||
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_issue_editor(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer number
|
||||||
|
);
|
||||||
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE_FILTERS
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE_FILTERS
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE_FILTERS
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE_FILTERS
|
||||||
uint64_t uniffi_gotcha_core_fn_method_gotchacore_issue_filters(uint64_t ptr, RustBuffer owner, RustBuffer repository
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_issue_filters(uint64_t ptr, RustBuffer owner, RustBuffer repository
|
||||||
@@ -364,6 +369,11 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_contents(uint64_t pt
|
|||||||
uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_file(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer path
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_file(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer path
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE
|
||||||
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer number, RustBuffer title, RustBuffer body, RustBuffer label_ids, RustBuffer milestone_id, RustBuffer due_date
|
||||||
|
);
|
||||||
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER
|
||||||
void uniffi_gotcha_core_fn_method_gotchacore_select_server(uint64_t ptr, uint32_t index, RustCallStatus *_Nonnull out_status
|
void uniffi_gotcha_core_fn_method_gotchacore_select_server(uint64_t ptr, uint32_t index, RustCallStatus *_Nonnull out_status
|
||||||
@@ -720,6 +730,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_home(void
|
|||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_issue(void
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_issue(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE_EDITOR
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE_EDITOR
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_issue_editor(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE_FILTERS
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE_FILTERS
|
||||||
@@ -798,6 +814,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repository_contents(void
|
|||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_FILE
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_FILE
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repository_file(void
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repository_file(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE
|
||||||
|
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_issue(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SELECT_SERVER
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SELECT_SERVER
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
7DD332583B169B3CA1CA46E0 /* Highlighter in Frameworks */ = {isa = PBXBuildFile; productRef = EC5F999F50905E3801E8A71A /* Highlighter */; };
|
7DD332583B169B3CA1CA46E0 /* Highlighter in Frameworks */ = {isa = PBXBuildFile; productRef = EC5F999F50905E3801E8A71A /* Highlighter */; };
|
||||||
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C0921C76676A14A024BA417 /* AppDelegate.swift */; };
|
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C0921C76676A14A024BA417 /* AppDelegate.swift */; };
|
||||||
96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */; };
|
96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */; };
|
||||||
|
D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722405F999984C7CBE838711 /* IssueEditorViewController.swift */; };
|
||||||
D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE12480725C13293FAFDDA52 /* gotcha_core.swift */; };
|
D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE12480725C13293FAFDDA52 /* gotcha_core.swift */; };
|
||||||
D78A121E9ADA0B72E2CF94DC /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 8AD463F3AC817A44306C2297 /* MarkdownUI */; };
|
D78A121E9ADA0B72E2CF94DC /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 8AD463F3AC817A44306C2297 /* MarkdownUI */; };
|
||||||
E7AC0B140F5CFC5EF226D174 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */; };
|
E7AC0B140F5CFC5EF226D174 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */; };
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailScreens.swift; sourceTree = "<group>"; };
|
13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailScreens.swift; sourceTree = "<group>"; };
|
||||||
5FB3250A93766966A60A685E /* Gotcha.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Gotcha.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
5FB3250A93766966A60A685E /* Gotcha.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Gotcha.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
722405F999984C7CBE838711 /* IssueEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueEditorViewController.swift; sourceTree = "<group>"; };
|
||||||
8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; };
|
8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; };
|
||||||
9C0921C76676A14A024BA417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
9C0921C76676A14A024BA417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
AC828923A1E11A58AE348A74 /* ListScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListScreens.swift; sourceTree = "<group>"; };
|
AC828923A1E11A58AE348A74 /* ListScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListScreens.swift; sourceTree = "<group>"; };
|
||||||
@@ -53,6 +55,7 @@
|
|||||||
9C0921C76676A14A024BA417 /* AppDelegate.swift */,
|
9C0921C76676A14A024BA417 /* AppDelegate.swift */,
|
||||||
CE5E10787062D1175125BB4C /* ContentScreens.swift */,
|
CE5E10787062D1175125BB4C /* ContentScreens.swift */,
|
||||||
13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */,
|
13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */,
|
||||||
|
722405F999984C7CBE838711 /* IssueEditorViewController.swift */,
|
||||||
AC828923A1E11A58AE348A74 /* ListScreens.swift */,
|
AC828923A1E11A58AE348A74 /* ListScreens.swift */,
|
||||||
8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */,
|
8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */,
|
||||||
F75B3E4FFB9C9992517C4D69 /* Support.swift */,
|
F75B3E4FFB9C9992517C4D69 /* Support.swift */,
|
||||||
@@ -192,6 +195,7 @@
|
|||||||
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */,
|
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */,
|
||||||
1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */,
|
1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */,
|
||||||
25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */,
|
25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */,
|
||||||
|
D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */,
|
||||||
374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */,
|
374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */,
|
||||||
96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */,
|
96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */,
|
||||||
F55A89489B2758D694F3B27D /* Support.swift in Sources */,
|
F55A89489B2758D694F3B27D /* Support.swift in Sources */,
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ final class IssuesViewController: RefreshingTableViewController {
|
|||||||
private var rows: [IssueRow] = []
|
private var rows: [IssueRow] = []
|
||||||
private var filterOptions: IssueFilterOptions?
|
private var filterOptions: IssueFilterOptions?
|
||||||
private var filterTask: Task<Void, Never>?
|
private var filterTask: Task<Void, Never>?
|
||||||
|
private lazy var filterButton = UIBarButtonItem(
|
||||||
|
image: context.symbol("line.3.horizontal.decrease.circle")
|
||||||
|
)
|
||||||
|
|
||||||
init(context: AppContext, owner: String, repository: String) {
|
init(context: AppContext, owner: String, repository: String) {
|
||||||
self.context = context
|
self.context = context
|
||||||
@@ -25,6 +28,13 @@ final class IssuesViewController: RefreshingTableViewController {
|
|||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
tableView.register(IssueCell.self, forCellReuseIdentifier: "issue")
|
tableView.register(IssueCell.self, forCellReuseIdentifier: "issue")
|
||||||
|
let addButton = UIBarButtonItem(
|
||||||
|
barButtonSystemItem: .add,
|
||||||
|
target: self,
|
||||||
|
action: #selector(createIssue)
|
||||||
|
)
|
||||||
|
addButton.accessibilityLabel = "New issue"
|
||||||
|
navigationItem.rightBarButtonItems = [addButton, filterButton]
|
||||||
updateFilterMenu()
|
updateFilterMenu()
|
||||||
loadContent(refreshing: false)
|
loadContent(refreshing: false)
|
||||||
}
|
}
|
||||||
@@ -122,14 +132,8 @@ final class IssuesViewController: RefreshingTableViewController {
|
|||||||
} else {
|
} else {
|
||||||
children.append(UIAction(title: "Loading filters…", attributes: .disabled) { _ in })
|
children.append(UIAction(title: "Loading filters…", attributes: .disabled) { _ in })
|
||||||
}
|
}
|
||||||
let item = navigationItem.rightBarButtonItem ?? UIBarButtonItem(
|
filterButton.menu = UIMenu(children: children)
|
||||||
image: context.symbol("line.3.horizontal.decrease.circle")
|
filterButton.accessibilityLabel = "Filter issues"
|
||||||
)
|
|
||||||
item.menu = UIMenu(children: children)
|
|
||||||
item.accessibilityLabel = "Filter issues"
|
|
||||||
if navigationItem.rightBarButtonItem == nil {
|
|
||||||
navigationItem.rightBarButtonItem = item
|
|
||||||
}
|
|
||||||
updateFilterTint()
|
updateFilterTint()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,12 +215,35 @@ final class IssuesViewController: RefreshingTableViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateFilterTint() {
|
private func updateFilterTint() {
|
||||||
navigationItem.rightBarButtonItem?.tintColor = filtersActive ? .tintColor : .secondaryLabel
|
filterButton.tintColor = filtersActive ? .tintColor : .secondaryLabel
|
||||||
navigationItem.rightBarButtonItem?.accessibilityValue = filtersActive
|
filterButton.accessibilityValue = filtersActive
|
||||||
? "Filters active"
|
? "Filters active"
|
||||||
: "Default filters"
|
: "Default filters"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func createIssue() {
|
||||||
|
let editor = IssueEditorViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository
|
||||||
|
) { [weak self] number in
|
||||||
|
guard let self else { return }
|
||||||
|
self.dismiss(animated: true) {
|
||||||
|
self.loadContent(refreshing: false)
|
||||||
|
self.navigationController?.pushViewController(
|
||||||
|
IssueViewController(
|
||||||
|
context: self.context,
|
||||||
|
owner: self.owner,
|
||||||
|
repository: self.repository,
|
||||||
|
number: number
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
present(UINavigationController(rootViewController: editor), animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
private func updateEmptyState() {
|
private func updateEmptyState() {
|
||||||
guard rows.isEmpty else {
|
guard rows.isEmpty else {
|
||||||
tableView.backgroundView = nil
|
tableView.backgroundView = nil
|
||||||
|
|||||||
@@ -87,9 +87,28 @@ final class IssueViewController: MarkdownPageViewController {
|
|||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
title: "Edit",
|
||||||
|
style: .plain,
|
||||||
|
target: self,
|
||||||
|
action: #selector(editIssue)
|
||||||
|
)
|
||||||
loadContent(refreshing: false)
|
loadContent(refreshing: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func editIssue() {
|
||||||
|
let editor = IssueEditorViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
number: number
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.dismiss(animated: true) { self.loadContent(refreshing: false) }
|
||||||
|
}
|
||||||
|
present(UINavigationController(rootViewController: editor), animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
override func loadContent(refreshing: Bool) {
|
override func loadContent(refreshing: Bool) {
|
||||||
beginLoading(refreshing: refreshing)
|
beginLoading(refreshing: refreshing)
|
||||||
loadingTask?.cancel()
|
loadingTask?.cancel()
|
||||||
@@ -538,7 +557,7 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct RepositoryMarkdownPreview: View {
|
struct RepositoryMarkdownPreview: View {
|
||||||
let source: String
|
let source: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|||||||
353
ios/Sources/IssueEditorViewController.swift
Normal file
353
ios/Sources/IssueEditorViewController.swift
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import Highlighter
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class IssueEditorViewController: UIViewController, UITextFieldDelegate, UITextViewDelegate {
|
||||||
|
private let context: AppContext
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private let number: Int64?
|
||||||
|
private let saved: (Int64) -> Void
|
||||||
|
private let scrollView = UIScrollView()
|
||||||
|
private let stack = UIStackView()
|
||||||
|
private let titleField = UITextField()
|
||||||
|
private let modeControl = UISegmentedControl(items: ["Write", "Preview"])
|
||||||
|
private let bodyView = UITextView()
|
||||||
|
private let previewView = UIView()
|
||||||
|
private let labelsButton = UIButton(type: .system)
|
||||||
|
private let milestoneButton = UIButton(type: .system)
|
||||||
|
private let dueSwitch = UISwitch()
|
||||||
|
private let duePicker = UIDatePicker()
|
||||||
|
private let spinner = UIActivityIndicatorView(style: .medium)
|
||||||
|
private var page: IssueEditorPage?
|
||||||
|
private var selectedLabels = Set<Int64>()
|
||||||
|
private var selectedMilestone: Int64?
|
||||||
|
private var task: Task<Void, Never>?
|
||||||
|
private var previewController: UIViewController?
|
||||||
|
private var editorFont: UIFont {
|
||||||
|
UIFontMetrics(forTextStyle: .body).scaledFont(
|
||||||
|
for: .monospacedSystemFont(ofSize: 15, weight: .regular)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
context: AppContext,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
number: Int64? = nil,
|
||||||
|
saved: @escaping (Int64) -> Void
|
||||||
|
) {
|
||||||
|
self.context = context
|
||||||
|
self.owner = owner
|
||||||
|
self.repository = repository
|
||||||
|
self.number = number
|
||||||
|
self.saved = saved
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
title = number == nil ? "New Issue" : "Edit Issue"
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
deinit { task?.cancel() }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .systemGroupedBackground
|
||||||
|
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||||
|
barButtonSystemItem: .cancel,
|
||||||
|
target: self,
|
||||||
|
action: #selector(cancel)
|
||||||
|
)
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
title: "Save",
|
||||||
|
style: .done,
|
||||||
|
target: self,
|
||||||
|
action: #selector(save)
|
||||||
|
)
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner)
|
||||||
|
spinner.startAnimating()
|
||||||
|
configureForm()
|
||||||
|
task = Task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureForm() {
|
||||||
|
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
scrollView.keyboardDismissMode = .interactive
|
||||||
|
view.addSubview(scrollView)
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 12
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
stack.isHidden = true
|
||||||
|
scrollView.addSubview(stack)
|
||||||
|
|
||||||
|
titleField.placeholder = "Title"
|
||||||
|
titleField.delegate = self
|
||||||
|
titleField.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
titleField.adjustsFontForContentSizeCategory = true
|
||||||
|
titleField.borderStyle = .roundedRect
|
||||||
|
titleField.autocapitalizationType = .sentences
|
||||||
|
titleField.returnKeyType = .next
|
||||||
|
titleField.accessibilityLabel = "Issue title"
|
||||||
|
titleField.addTarget(self, action: #selector(titleChanged), for: .editingChanged)
|
||||||
|
|
||||||
|
[labelsButton, milestoneButton].forEach { button in
|
||||||
|
var configuration = UIButton.Configuration.tinted()
|
||||||
|
configuration.imagePlacement = .leading
|
||||||
|
configuration.imagePadding = 8
|
||||||
|
button.configuration = configuration
|
||||||
|
button.showsMenuAsPrimaryAction = true
|
||||||
|
button.contentHorizontalAlignment = .leading
|
||||||
|
}
|
||||||
|
labelsButton.configuration?.image = context.symbol("tag")
|
||||||
|
milestoneButton.configuration?.image = context.symbol("flag")
|
||||||
|
|
||||||
|
let dueLabel = UILabel()
|
||||||
|
dueLabel.text = "Due Date"
|
||||||
|
dueLabel.font = .preferredFont(forTextStyle: .body)
|
||||||
|
dueLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
let dueRow = UIStackView(arrangedSubviews: [dueLabel, dueSwitch])
|
||||||
|
dueRow.alignment = .center
|
||||||
|
dueSwitch.accessibilityLabel = "Set due date"
|
||||||
|
dueSwitch.addTarget(self, action: #selector(dueChanged), for: .valueChanged)
|
||||||
|
duePicker.datePickerMode = .date
|
||||||
|
duePicker.preferredDatePickerStyle = .inline
|
||||||
|
duePicker.isHidden = true
|
||||||
|
|
||||||
|
modeControl.selectedSegmentIndex = 0
|
||||||
|
modeControl.addTarget(self, action: #selector(modeChanged), for: .valueChanged)
|
||||||
|
bodyView.delegate = self
|
||||||
|
bodyView.font = editorFont
|
||||||
|
bodyView.adjustsFontForContentSizeCategory = true
|
||||||
|
bodyView.backgroundColor = .secondarySystemGroupedBackground
|
||||||
|
bodyView.layer.cornerRadius = 10
|
||||||
|
bodyView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 8)
|
||||||
|
bodyView.autocapitalizationType = .sentences
|
||||||
|
bodyView.accessibilityLabel = "Issue description in Markdown"
|
||||||
|
previewView.backgroundColor = .secondarySystemGroupedBackground
|
||||||
|
previewView.layer.cornerRadius = 10
|
||||||
|
previewView.accessibilityLabel = "Issue description preview"
|
||||||
|
previewView.isHidden = true
|
||||||
|
|
||||||
|
stack.addArrangedSubview(titleField)
|
||||||
|
stack.addArrangedSubview(labelsButton)
|
||||||
|
stack.addArrangedSubview(milestoneButton)
|
||||||
|
stack.addArrangedSubview(dueRow)
|
||||||
|
stack.addArrangedSubview(duePicker)
|
||||||
|
stack.addArrangedSubview(modeControl)
|
||||||
|
stack.addArrangedSubview(bodyView)
|
||||||
|
stack.addArrangedSubview(previewView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||||
|
scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
|
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
|
scrollView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
|
||||||
|
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 16),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16),
|
||||||
|
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32),
|
||||||
|
bodyView.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
|
||||||
|
previewView.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
do {
|
||||||
|
let page = try await context.core.issueEditor(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
number: number
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
self.page = page
|
||||||
|
titleField.text = page.title
|
||||||
|
bodyView.text = page.body
|
||||||
|
selectedLabels = Set(page.labels.filter(\.selected).map(\.id))
|
||||||
|
selectedMilestone = page.milestones.first(where: \.selected)?.id
|
||||||
|
if let timestamp = page.dueDate {
|
||||||
|
dueSwitch.isOn = true
|
||||||
|
duePicker.date = localDate(forUTCTimestamp: timestamp)
|
||||||
|
duePicker.isHidden = false
|
||||||
|
}
|
||||||
|
updateLabelsMenu()
|
||||||
|
updateMilestoneMenu()
|
||||||
|
highlightBody()
|
||||||
|
stack.isHidden = false
|
||||||
|
navigationItem.rightBarButtonItem?.isEnabled = !page.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled { show(error: error) }
|
||||||
|
}
|
||||||
|
restoreSaveButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateLabelsMenu() {
|
||||||
|
guard let page else { return }
|
||||||
|
labelsButton.configuration?.title = selectedLabels.isEmpty
|
||||||
|
? "Labels"
|
||||||
|
: "Labels (\(selectedLabels.count))"
|
||||||
|
labelsButton.accessibilityValue = selectedLabels.isEmpty
|
||||||
|
? "None"
|
||||||
|
: page.labels.filter { selectedLabels.contains($0.id) }.map(\.name).joined(separator: ", ")
|
||||||
|
let actions = page.labels.map { label in
|
||||||
|
UIAction(
|
||||||
|
title: label.name,
|
||||||
|
attributes: .keepsMenuPresented,
|
||||||
|
state: selectedLabels.contains(label.id) ? .on : .off
|
||||||
|
) { [weak self] action in
|
||||||
|
guard let self else { return }
|
||||||
|
if self.selectedLabels.remove(label.id) == nil { self.selectedLabels.insert(label.id) }
|
||||||
|
action.state = self.selectedLabels.contains(label.id) ? .on : .off
|
||||||
|
self.updateLabelsMenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labelsButton.menu = UIMenu(children: actions.isEmpty
|
||||||
|
? [UIAction(title: "No labels", attributes: .disabled) { _ in }]
|
||||||
|
: actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateMilestoneMenu() {
|
||||||
|
guard let page else { return }
|
||||||
|
milestoneButton.configuration?.title = page.milestones
|
||||||
|
.first(where: { $0.id == selectedMilestone })?.title ?? "Milestone"
|
||||||
|
milestoneButton.accessibilityValue = page.milestones
|
||||||
|
.first(where: { $0.id == selectedMilestone })?.title ?? "None"
|
||||||
|
let none = UIAction(title: "No Milestone", state: selectedMilestone == nil ? .on : .off) {
|
||||||
|
[weak self] _ in
|
||||||
|
self?.selectedMilestone = nil
|
||||||
|
self?.updateMilestoneMenu()
|
||||||
|
}
|
||||||
|
milestoneButton.menu = UIMenu(options: .singleSelection, children: [none] + page.milestones.map { milestone in
|
||||||
|
UIAction(
|
||||||
|
title: milestone.title,
|
||||||
|
state: milestone.id == selectedMilestone ? .on : .off
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.selectedMilestone = milestone.id
|
||||||
|
self?.updateMilestoneMenu()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
|
highlightBody()
|
||||||
|
}
|
||||||
|
|
||||||
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||||
|
bodyView.becomeFirstResponder()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func highlightBody() {
|
||||||
|
let source = bodyView.text ?? ""
|
||||||
|
let selection = bodyView.selectedRange
|
||||||
|
let highlighter = Highlighter()
|
||||||
|
highlighter?.setTheme(traitCollection.userInterfaceStyle == .dark ? "atom-one-dark" : "atom-one-light")
|
||||||
|
highlighter?.theme.setCodeFont(editorFont)
|
||||||
|
bodyView.attributedText = highlighter?.highlight(source, as: "markdown")
|
||||||
|
?? NSAttributedString(string: source, attributes: [
|
||||||
|
.font: editorFont,
|
||||||
|
.foregroundColor: UIColor.label,
|
||||||
|
])
|
||||||
|
bodyView.selectedRange = selection
|
||||||
|
bodyView.typingAttributes = [
|
||||||
|
.font: editorFont,
|
||||||
|
.foregroundColor: UIColor.label,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func modeChanged() {
|
||||||
|
let preview = modeControl.selectedSegmentIndex == 1
|
||||||
|
if preview {
|
||||||
|
previewController?.willMove(toParent: nil)
|
||||||
|
previewController?.view.removeFromSuperview()
|
||||||
|
previewController?.removeFromParent()
|
||||||
|
let controller = UIHostingController(
|
||||||
|
rootView: RepositoryMarkdownPreview(source: bodyView.text ?? "")
|
||||||
|
)
|
||||||
|
addChild(controller)
|
||||||
|
controller.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
controller.view.layer.cornerRadius = 10
|
||||||
|
controller.view.clipsToBounds = true
|
||||||
|
previewView.addSubview(controller.view)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
controller.view.leadingAnchor.constraint(equalTo: previewView.leadingAnchor),
|
||||||
|
controller.view.trailingAnchor.constraint(equalTo: previewView.trailingAnchor),
|
||||||
|
controller.view.topAnchor.constraint(equalTo: previewView.topAnchor),
|
||||||
|
controller.view.bottomAnchor.constraint(equalTo: previewView.bottomAnchor),
|
||||||
|
])
|
||||||
|
controller.didMove(toParent: self)
|
||||||
|
previewController = controller
|
||||||
|
}
|
||||||
|
bodyView.isHidden = preview
|
||||||
|
previewView.isHidden = !preview
|
||||||
|
view.endEditing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func dueChanged() {
|
||||||
|
duePicker.isHidden = !dueSwitch.isOn
|
||||||
|
if dueSwitch.isOn, page?.dueDate == nil { duePicker.date = Date() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func titleChanged() {
|
||||||
|
navigationItem.rightBarButtonItem?.isEnabled = !(titleField.text ?? "")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func save() {
|
||||||
|
view.endEditing(true)
|
||||||
|
navigationItem.leftBarButtonItem?.isEnabled = false
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner)
|
||||||
|
spinner.startAnimating()
|
||||||
|
task?.cancel()
|
||||||
|
task = Task {
|
||||||
|
do {
|
||||||
|
let number = try await context.core.saveIssue(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
number: number,
|
||||||
|
title: titleField.text ?? "",
|
||||||
|
body: bodyView.text ?? "",
|
||||||
|
labelIds: Array(selectedLabels),
|
||||||
|
milestoneId: selectedMilestone,
|
||||||
|
dueDate: dueSwitch.isOn ? utcTimestamp(forLocalDate: duePicker.date) : nil
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
saved(number)
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled {
|
||||||
|
show(error: error)
|
||||||
|
restoreSaveButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restoreSaveButton() {
|
||||||
|
spinner.stopAnimating()
|
||||||
|
navigationItem.leftBarButtonItem?.isEnabled = true
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
title: "Save",
|
||||||
|
style: .done,
|
||||||
|
target: self,
|
||||||
|
action: #selector(save)
|
||||||
|
)
|
||||||
|
titleChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancel() { dismiss(animated: true) }
|
||||||
|
|
||||||
|
private func localDate(forUTCTimestamp timestamp: Int64) -> Date {
|
||||||
|
var utc = Calendar(identifier: .gregorian)
|
||||||
|
utc.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||||
|
let components = utc.dateComponents([.year, .month, .day], from: Date(timeIntervalSince1970: TimeInterval(timestamp)))
|
||||||
|
return Calendar.current.date(from: components) ?? Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func utcTimestamp(forLocalDate date: Date) -> Int64 {
|
||||||
|
let components = Calendar.current.dateComponents([.year, .month, .day], from: date)
|
||||||
|
var utc = Calendar(identifier: .gregorian)
|
||||||
|
utc.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||||
|
return Int64((utc.date(from: components) ?? date).timeIntervalSince1970)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user