Add issue creation and editing

This commit is contained in:
Georg Bauer
2026-07-31 14:39:17 +02:00
parent 86200b4668
commit 228879da15
11 changed files with 1175 additions and 40 deletions

View File

@@ -141,6 +141,21 @@ xcrun simctl launch booted de.rfc1437.gotcha
selections persist independently per repository and status persists.
- [ ] 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.
- [ ] 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
appears beside the title and VoiceOver announces the state; author
metadata, Markdown body, and comments remain correct. Links and

View File

@@ -9,8 +9,8 @@ use tokio::task::JoinSet;
use crate::{
diff,
domain::{
HistoryCommit, HomeData, IssueDetails, MilestoneDetails, PullDetails, RepositoryData,
Server,
HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, MilestoneDetails,
PullDetails, RepositoryData, Server, api_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(
server: &Server,
owner: &str,
@@ -845,4 +954,32 @@ mod tests {
assert!(commits[4].bottom_lanes.is_empty());
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));
}
}

View File

@@ -139,6 +139,20 @@ pub struct IssueDetails {
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 milestone: models::Milestone,
pub issues: Vec<models::Issue>,
@@ -155,6 +169,49 @@ 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
}
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)]
mod tests {
use super::*;
@@ -179,4 +236,12 @@ mod tests {
.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);
}
}

View File

@@ -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(
&self,
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 {
fn server(&self) -> Result<Server, GotchaError> {
let state = self.state.lock().unwrap();

View File

@@ -3,8 +3,8 @@ use gotcha_gitea::models;
use crate::{
activity,
domain::{
HistoryCommit, HomeData, IssueDetails, IssueFilter, MilestoneDetails, PullDetails,
PullFilter, RepositoryData,
HistoryCommit, HomeData, IssueDetails, IssueEditorData, IssueFilter, MilestoneDetails,
PullDetails, PullFilter, RepositoryData, civil_from_days, days_from_civil, parse_api_date,
},
};
@@ -67,6 +67,29 @@ pub struct IssuePage {
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)]
pub struct IssueFilterOptions {
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 {
let pull = &details.pull;
let author = pull
@@ -840,31 +927,6 @@ fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec<HeatCell>, i64) {
(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> {
let name = label.name.as_deref()?.trim();
let color = label.color.as_deref()?.trim().trim_start_matches('#');
@@ -1172,6 +1234,42 @@ mod tests {
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]
fn builds_sorted_issue_filter_options() {
let filter = IssueFilter {

View File

@@ -619,6 +619,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable {
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 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 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 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 {
return
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) {
uniffiCallStatus in
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 var milestones: [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)
@_documentation(visibility: private)
#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)
@_documentation(visibility: private)
#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)
@_documentation(visibility: private)
#endif
@@ -3230,6 +3547,9 @@ private let initializationResult: InitializationResult = {
if (uniffi_gotcha_core_checksum_method_gotchacore_issue() != 56735) {
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) {
return InitializationResult.apiChecksumMismatch
}
@@ -3269,6 +3589,9 @@ private let initializationResult: InitializationResult = {
if (uniffi_gotcha_core_checksum_method_gotchacore_repository_file() != 44948) {
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) {
return InitializationResult.apiChecksumMismatch
}

View File

@@ -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
);
#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
#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
@@ -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
);
#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
#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
@@ -720,6 +730,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_home(void
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE
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
#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
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
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SELECT_SERVER

View File

@@ -13,6 +13,7 @@
7DD332583B169B3CA1CA46E0 /* Highlighter in Frameworks */ = {isa = PBXBuildFile; productRef = EC5F999F50905E3801E8A71A /* Highlighter */; };
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C0921C76676A14A024BA417 /* AppDelegate.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 */; };
D78A121E9ADA0B72E2CF94DC /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 8AD463F3AC817A44306C2297 /* MarkdownUI */; };
E7AC0B140F5CFC5EF226D174 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */; };
@@ -23,6 +24,7 @@
/* Begin PBXFileReference section */
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; };
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>"; };
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>"; };
@@ -53,6 +55,7 @@
9C0921C76676A14A024BA417 /* AppDelegate.swift */,
CE5E10787062D1175125BB4C /* ContentScreens.swift */,
13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */,
722405F999984C7CBE838711 /* IssueEditorViewController.swift */,
AC828923A1E11A58AE348A74 /* ListScreens.swift */,
8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */,
F75B3E4FFB9C9992517C4D69 /* Support.swift */,
@@ -192,6 +195,7 @@
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */,
1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */,
25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */,
D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */,
374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */,
96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */,
F55A89489B2758D694F3B27D /* Support.swift in Sources */,

View File

@@ -8,6 +8,9 @@ final class IssuesViewController: RefreshingTableViewController {
private var rows: [IssueRow] = []
private var filterOptions: IssueFilterOptions?
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) {
self.context = context
@@ -25,6 +28,13 @@ final class IssuesViewController: RefreshingTableViewController {
override func viewDidLoad() {
super.viewDidLoad()
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()
loadContent(refreshing: false)
}
@@ -122,14 +132,8 @@ final class IssuesViewController: RefreshingTableViewController {
} else {
children.append(UIAction(title: "Loading filters…", attributes: .disabled) { _ in })
}
let item = navigationItem.rightBarButtonItem ?? UIBarButtonItem(
image: context.symbol("line.3.horizontal.decrease.circle")
)
item.menu = UIMenu(children: children)
item.accessibilityLabel = "Filter issues"
if navigationItem.rightBarButtonItem == nil {
navigationItem.rightBarButtonItem = item
}
filterButton.menu = UIMenu(children: children)
filterButton.accessibilityLabel = "Filter issues"
updateFilterTint()
}
@@ -211,12 +215,35 @@ final class IssuesViewController: RefreshingTableViewController {
}
private func updateFilterTint() {
navigationItem.rightBarButtonItem?.tintColor = filtersActive ? .tintColor : .secondaryLabel
navigationItem.rightBarButtonItem?.accessibilityValue = filtersActive
filterButton.tintColor = filtersActive ? .tintColor : .secondaryLabel
filterButton.accessibilityValue = filtersActive
? "Filters active"
: "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() {
guard rows.isEmpty else {
tableView.backgroundView = nil

View File

@@ -87,9 +87,28 @@ final class IssueViewController: MarkdownPageViewController {
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "Edit",
style: .plain,
target: self,
action: #selector(editIssue)
)
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) {
beginLoading(refreshing: refreshing)
loadingTask?.cancel()
@@ -538,7 +557,7 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD
}
}
private struct RepositoryMarkdownPreview: View {
struct RepositoryMarkdownPreview: View {
let source: String
var body: some View {

View 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)
}
}