diff --git a/TESTING.md b/TESTING.md index 1c76eef..6c77bf2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -44,7 +44,8 @@ Run `cargo run -p gotcha-tui -- --server NAME` in a normal terminal and in a Herdr pane. Use a non-production account with at least two server profiles and representative repositories. -- [ ] The five numbered panes open Home, Issues, Repos, PRs, and Milestones; +- [ ] The six numbered panes open Home, Issues, Repos, PRs, Milestones, and + Actions; the selected pane and row remain visibly distinct at narrow and wide terminal sizes. - [ ] `j`/`k`, arrows, `g`/`G`, Page Up/Down, Enter, and Backspace provide @@ -72,6 +73,14 @@ representative repositories. added and the current user's comments edited. - [ ] Milestones can be created, edited, closed/reopened, and deleted after confirmation; milestone details route to their issues and pulls. +- [ ] Actions selects a repository, lists workflows and paginated runs, and + preserves selection while active runs refresh. Dispatch a workflow with a + branch or tag plus multiple `KEY=VALUE` inputs; open the resulting run, + inspect every job, open its grouped setup/task/completion entries, and + open each group's complete scrollable log text. Verify a long multi-job + workflow such as RuDS release run 30 stays navigable without flattening + all jobs and logs into one view. + Queued, running, successful, failed, and cancelled states remain distinct. - [ ] Server profiles can be added, authenticated, selected, edited, renamed, and deleted. Blank token on edit preserves the existing token; removing the last server leaves the server manager available. @@ -89,6 +98,17 @@ representative repositories. `target/release/gotcha-tui`, and the installed executable starts from `PATH`. +## Actions CLI + +- [ ] `gotcha action workflow list OWNER/REPOSITORY` lists the repository's + workflows without using `api request`. +- [ ] `gotcha action workflow dispatch OWNER/REPOSITORY WORKFLOW --ref REF` + accepts repeated `--input KEY=VALUE` values, rejects malformed or + duplicate inputs, and creates a run with the requested ref and inputs. +- [ ] `gotcha action run list OWNER/REPOSITORY` supports page/limit plus event, + branch, status, actor, and SHA filters. `run show` returns every job and + step, and `run logs` returns every job's complete log. + Build and install the simulator app: ```sh @@ -209,7 +229,8 @@ source update are published, and installs or updates Gotcha through AltStore PAL The native refresh control appears, reloads data, and disappears. - [ ] On accounts/repositories with more than 30 results, Home activity, repositories, issues, pull requests, milestones, milestone contents, - commit history, and pull-request files initially show one page plus a + Action runs, commit history, and pull-request files initially show one + page plus a **Pull up or tap to load more** footer. Pull upward past the bottom and verify the footer changes to a native loading indicator, the next page appends without duplicates, and @@ -240,18 +261,27 @@ source update are published, and installs or updates Gotcha through AltStore PAL without clipping either edge. - [ ] Activity rows have the correct icon, repository, summary, and date. - [ ] The compact icon row below the activity graph sits flat on the normal - background with no border or pill and defaults to the clock. Select the - issue and pull-request icons in turn; each shows only matching recent - activity or the native empty state, then the clock restores all rows. - The people and bell icons remain separate, accessible Server Activity - and Notifications destinations. -- [ ] Tap **Server Activity**. The pushed native list combines activity from - every user visible to the selected server account in newest-first order, - identifies each actor, supports pull-to-refresh and pagination without - duplicates, and uses a native empty state when no activity is visible. + background with no border or pill. **Your activity** is selected by + default with a filled accent-colored icon. Switch to **All users** and + **Notifications**; only the current timeline uses the accent color and + selected accessibility trait, and returning to **Your activity** restores + the user's unfiltered feed. Rapid switching never replaces the selected + timeline with a cancelled result. +- [ ] The timeline mini-icons come first, followed immediately by every + non-timeline destination omitted from the tab bar. Each destination opens + its native screen and remains reachable when the row must scroll + horizontally at large Dynamic Type sizes. A pushed destination + keeps the native Back button and returns to the selected Home timeline. +- [ ] The **All users** timeline combines activity from every user visible to + the selected server account in newest-first order, identifies each actor, + supports pull-to-refresh and pagination without duplicates, and uses a + native empty state when no activity is visible. Tap another user's repository, issue, pull request, and commit events; - each opens the corresponding native detail screen and Back returns to - Server Activity. + each opens the corresponding native detail screen and Back returns to the + selected Home timeline. +- [ ] The **Notifications** timeline shows open notification rows with unread + state, supports pull-to-refresh and pagination, marks a selected unread + row read before routing, and keeps non-linkable rows non-tappable. - [ ] Pull-request activity includes older creation and close events beyond the first activity-feed page without requiring a manual pull-up; the empty state appears only after every available page has been checked. @@ -260,6 +290,32 @@ source update are published, and installs or updates Gotcha through AltStore PAL Back button and left-edge interactive swipe return directly to Home. - [ ] Non-linkable server activity does not navigate or appear tappable. +## Actions + +- [ ] Open Actions, select a repository, and switch repeatedly between native + Workflows and Runs segments. Pull to refresh both lists; load multiple run + pages without duplicate or stale rows and verify active runs refresh + automatically without disrupting navigation. +- [ ] Open a workflow. The native grouped dispatch form starts with the + repository's default branch, requires a non-empty reference, accepts + multiline `KEY=VALUE` inputs, keeps standard iOS text selection/editing, + reports invalid or duplicate inputs, and dismisses only after the server + accepts the dispatch. +- [ ] Open queued, running, successful, failed, and cancelled runs. Their + symbols, labels, progress, and jobs match Gitea and update while active. + Drill down through Run, Job, and grouped setup/task/completion lists; + each higher level shows its outcome and a disclosure indicator. Success + uses a green filled checkmark, failure a red filled X, and waiting, + queued, running, skipped, and cancelled remain visually distinct. + Finished jobs and tasks show elapsed time instead of a log-line count. Open a + group to verify its complete monospaced log text is selectable, readable, + and accessible at large Dynamic Type sizes. Use a long multi-job workflow + such as RuDS release run 30 to verify the hierarchy remains manageable. +- [ ] Exercise empty repositories, a network error, rapid mode changes, Back, + interactive swipe, pull-to-refresh, background/foreground, and request + cancellation. No stale result replaces newer content and no loading or + refresh indicator remains stuck. + ## Notifications - [ ] Launch a fresh install. Gotcha does not request notification permission @@ -274,15 +330,15 @@ source update are published, and installs or updates Gotcha through AltStore PAL the app switch persists across relaunches, and iOS Settings remains the source of truth for alerts, sounds, Focus, and scheduled summaries. Turning the app switch off cancels pending alerts and background refresh. -- [ ] Tap the Home bell. Open and Closed each show the matching server - notification threads with a type icon, title, repository, date, Dynamic - Type layout, and accessible Open/Closed value. Pull to refresh, switch - status repeatedly, and load a list longer than one page without duplicate - or stale rows. +- [ ] Select the Home **Notifications** timeline. The inline Open/Closed control + shows matching server notification threads with a type icon, title, + repository, date, Dynamic Type layout, and accessible Open/Closed value. + Pull to refresh, switch status repeatedly, and load a list longer than one + page without duplicate or stale rows. - [ ] Tap open and closed issue, pull-request, commit, and repository notifications. Each opens the native destination on the Home navigation stack; opening an unread thread marks it read, Back returns to the - notification list, and the next refresh moves it from Open to Closed. + selected Home timeline, and the next refresh moves it from Open to Closed. - [ ] After the first poll establishes a cursor, create or receive another server notification. Launch the Debug simulator build with `xcrun simctl launch booted de.rfc1437.gotcha --validate-background-notifications`, @@ -534,6 +590,16 @@ source update are published, and installs or updates Gotcha through AltStore PAL ## Settings, accessibility, and lifecycle - [ ] Settings opens from the gear button on Home and is not a tab. +- [ ] Home, Issues, Repositories, Pull Requests, Milestones, Actions, and Server + Activity use the destination name as the primary navigation title and the + selected server as the smaller subtitle; changing servers updates both. +- [ ] Primary Navigation always keeps Home first and allows up to four ordered, + unique choices from Issues, Repos, Pulls, Milestones, Actions, and Server + Activity. Add, remove, and reorder destinations with native table + controls; tab order updates immediately, omitted destinations remain on + Home, and the configuration survives relaunch and server changes. An + upgrade from the previous build starts with Issues, Repos, Pulls, and + Milestones. - [ ] Appearance changes between Auto, Light, and Dark immediately; Auto follows the simulator system appearance. - [ ] Icon and appearance settings remain selected after relaunch. diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs index 1a0b135..92291c1 100644 --- a/crates/app/src/api.rs +++ b/crates/app/src/api.rs @@ -1,7 +1,82 @@ use gotcha_gitea::{ - Client, DEFAULT_PAGE_SIZE as PAGE_SIZE, IssueQuery, RepositoryId, api_date, diff, models, + ActionJobDetails, ActionRunDetails, ActionRunQuery, Client, DEFAULT_PAGE_SIZE as PAGE_SIZE, + IssueQuery, Page as GiteaPage, RepositoryId, api_date, diff, models, parse_action_inputs, }; +pub async fn load_action_workflows( + server: &Server, + owner: &str, + repository: &str, +) -> Result, String> { + client(server)? + .action_workflows(&scope(owner, repository)?) + .await + .map_err(message) +} + +pub async fn dispatch_action_workflow( + server: &Server, + owner: &str, + repository: &str, + workflow: &str, + reference: &str, + inputs: &[String], +) -> Result<(), String> { + client(server)? + .dispatch_action_workflow( + &scope(owner, repository)?, + workflow, + reference, + &parse_action_inputs(inputs).map_err(message)?, + ) + .await + .map_err(message) +} + +pub async fn load_action_runs( + server: &Server, + owner: &str, + repository: &str, + page: i32, +) -> Result, String> { + client(server)? + .action_runs( + &scope(owner, repository)?, + &ActionRunQuery { + page, + limit: PAGE_SIZE, + ..Default::default() + }, + ) + .await + .map_err(message) +} + +pub async fn load_action_run( + server: &Server, + owner: &str, + repository: &str, + run: i64, +) -> Result { + client(server)? + .action_run_details(&scope(owner, repository)?, run) + .await + .map_err(message) +} + +pub async fn load_action_job_details( + server: &Server, + owner: &str, + repository: &str, + run: i64, + job: i64, +) -> Result { + client(server)? + .action_job_details(&scope(owner, repository)?, run, job) + .await + .map_err(message) +} + use crate::{ domain::{ HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, IssueFilter, diff --git a/crates/app/src/core/actions.rs b/crates/app/src/core/actions.rs new file mode 100644 index 0000000..f1e304f --- /dev/null +++ b/crates/app/src/core/actions.rs @@ -0,0 +1,75 @@ +use crate::*; + +#[uniffi::export(async_runtime = "tokio")] +impl GotchaCore { + pub async fn action_workflows( + &self, + owner: String, + repository: String, + ) -> Result, GotchaError> { + let (owner, repository) = validate_repository(&owner, &repository)?; + Ok(action_workflow_rows( + load_action_workflows(&self.server()?, owner, repository).await?, + )) + } + + pub async fn dispatch_action_workflow( + &self, + owner: String, + repository: String, + workflow: String, + reference: String, + inputs: Vec, + ) -> Result<(), GotchaError> { + let (owner, repository) = validate_repository(&owner, &repository)?; + dispatch_action_workflow( + &self.server()?, + owner, + repository, + &workflow, + &reference, + &inputs, + ) + .await + .map_err(Into::into) + } + + pub async fn action_runs( + &self, + owner: String, + repository: String, + page: u32, + ) -> Result { + let (owner, repository) = validate_repository(&owner, &repository)?; + let page = load_action_runs(&self.server()?, owner, repository, valid_page(page)?).await?; + Ok(ActionRunListPage { + rows: action_run_rows(&page.items), + has_more: page.has_more, + }) + } + + pub async fn action_run( + &self, + owner: String, + repository: String, + run: i64, + ) -> Result { + let (owner, repository) = validate_repository(&owner, &repository)?; + Ok(action_run_page( + load_action_run(&self.server()?, owner, repository, run).await?, + )) + } + + pub async fn action_job_log( + &self, + owner: String, + repository: String, + run: i64, + job: i64, + ) -> Result { + let (owner, repository) = validate_repository(&owner, &repository)?; + Ok(action_job_log_page( + load_action_job_details(&self.server()?, owner, repository, run, job).await?, + )) + } +} diff --git a/crates/app/src/core/mod.rs b/crates/app/src/core/mod.rs index 42878f0..9c28d89 100644 --- a/crates/app/src/core/mod.rs +++ b/crates/app/src/core/mod.rs @@ -1,3 +1,4 @@ +mod actions; mod content; mod issues; mod milestones; diff --git a/crates/app/src/core/servers.rs b/crates/app/src/core/servers.rs index bd6002f..31b1d86 100644 --- a/crates/app/src/core/servers.rs +++ b/crates/app/src/core/servers.rs @@ -229,9 +229,25 @@ impl GotchaCore { pull_status: state.preferences.pull_status.clone(), appearance: state.preferences.appearance.index() as u32, notifications_enabled: state.preferences.notifications_enabled, + primary_destinations: state.preferences.primary_destinations.clone(), } } + pub fn set_primary_destinations( + &self, + destinations: Vec, + ) -> Result<(), GotchaError> { + let mut normalized = destinations; + if normalize_primary_destinations(&mut normalized) { + return Err( + "Choose each navigation destination once, with no more than four selected.".into(), + ); + } + let mut state = self.state.lock().unwrap(); + state.preferences.primary_destinations = normalized; + save_preferences(&state.preferences).map_err(Into::into) + } + pub fn set_issue_status(&self, status: String) -> Result<(), GotchaError> { if !matches!(status.as_str(), "open" | "closed") { return Err("Unsupported issue status.".into()); diff --git a/crates/app/src/domain.rs b/crates/app/src/domain.rs index d646c1b..8514f67 100644 --- a/crates/app/src/domain.rs +++ b/crates/app/src/domain.rs @@ -5,7 +5,7 @@ use std::{ pub use gotcha_gitea::{ HistoryCommit, HomeData, IssueDetails, IssueEditorData, MilestoneDetails, Page, PullDetails, - civil_from_days, days_from_civil, parse_api_date, + civil_from_days, days_from_civil, parse_api_date, parse_api_timestamp, }; use serde::{Deserialize, Serialize}; @@ -74,6 +74,8 @@ pub struct Preferences { pub notifications_enabled: bool, #[serde(default)] pub notification_cursors: BTreeMap, + #[serde(default = "default_primary_destinations")] + pub primary_destinations: Vec, } impl Default for Preferences { @@ -90,10 +92,32 @@ impl Default for Preferences { appearance: AppearanceMode::default(), notifications_enabled: false, notification_cursors: BTreeMap::new(), + primary_destinations: default_primary_destinations(), } } } +pub fn default_primary_destinations() -> Vec { + vec![ + crate::PrimaryDestination::Issues, + crate::PrimaryDestination::Repositories, + crate::PrimaryDestination::PullRequests, + crate::PrimaryDestination::Milestones, + ] +} + +pub fn normalize_primary_destinations(destinations: &mut Vec) -> bool { + let original = destinations.clone(); + let mut unique = Vec::with_capacity(destinations.len().min(4)); + for destination in destinations.drain(..) { + if !unique.contains(&destination) && unique.len() < 4 { + unique.push(destination); + } + } + *destinations = unique; + *destinations != original +} + #[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)] pub struct IssueFilter { #[serde(default)] @@ -222,4 +246,32 @@ mod tests { "" ); } + + #[test] + fn primary_destinations_are_ordered_unique_and_limited() { + let migrated: Preferences = serde_json::from_str("{}").unwrap(); + assert_eq!( + migrated.primary_destinations, + default_primary_destinations() + ); + + let mut destinations = vec![ + crate::PrimaryDestination::Actions, + crate::PrimaryDestination::Actions, + crate::PrimaryDestination::Issues, + crate::PrimaryDestination::Repositories, + crate::PrimaryDestination::PullRequests, + crate::PrimaryDestination::Milestones, + ]; + assert!(normalize_primary_destinations(&mut destinations)); + assert_eq!( + destinations, + [ + crate::PrimaryDestination::Actions, + crate::PrimaryDestination::Issues, + crate::PrimaryDestination::Repositories, + crate::PrimaryDestination::PullRequests, + ] + ); + } } diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 8236e8e..f1d0fb8 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -1,3 +1,4 @@ +use serde::{Deserialize, Serialize}; use std::sync::Mutex; use thiserror::Error; @@ -14,11 +15,23 @@ use storage::*; uniffi::setup_scaffolding!(); +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, uniffi::Enum)] +#[serde(rename_all = "snake_case")] +pub enum PrimaryDestination { + Issues, + Repositories, + PullRequests, + Milestones, + Actions, + ServerActivity, +} + #[derive(Clone, Copy, Debug, uniffi::Enum)] pub enum RepositoryPane { Issues, Commits, Milestones, + Actions, } #[derive(Clone, Copy, Debug, uniffi::Enum)] @@ -46,13 +59,14 @@ impl From for ServerProvider { } impl RepositoryPane { - const ALL: [Self; 3] = [Self::Issues, Self::Commits, Self::Milestones]; + const ALL: [Self; 4] = [Self::Issues, Self::Commits, Self::Milestones, Self::Actions]; const fn key(self) -> &'static str { match self { Self::Issues => "issues", Self::Commits => "commits", Self::Milestones => "milestones", + Self::Actions => "actions", } } } @@ -81,6 +95,7 @@ pub struct Settings { pub pull_status: String, pub appearance: u32, pub notifications_enabled: bool, + pub primary_destinations: Vec, } #[derive(uniffi::Object)] diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index c343af8..1b2c139 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -5,6 +5,7 @@ use gotcha_gitea::{ use crate::domain::{ HistoryCommit, HomeData, IssueDetails, IssueEditorData, IssueFilter, MilestoneDetails, PullDetails, PullFilter, RepositoryData, civil_from_days, days_from_civil, parse_api_date, + parse_api_timestamp, }; #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] @@ -35,6 +36,84 @@ pub struct RepositoryRow { pub description: String, pub meta: String, pub favorite: bool, + pub default_branch: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum ActionState { + Queued, + Waiting, + InProgress, + Succeeded, + Failed, + Cancelled, + Skipped, + Unknown, +} + +#[derive(Clone, uniffi::Record)] +pub struct ActionWorkflowRow { + pub id: String, + pub name: String, + pub path: String, + pub state: String, +} + +#[derive(Clone, uniffi::Record)] +pub struct ActionRunRow { + pub id: i64, + pub number: i64, + pub title: String, + pub state: ActionState, + pub status: String, + pub conclusion: String, + pub branch: String, + pub event: String, + pub meta: String, +} + +#[derive(Clone, uniffi::Record)] +pub struct ActionStepRow { + pub name: String, + pub state: ActionState, + pub meta: String, +} + +#[derive(Clone, uniffi::Record)] +pub struct ActionJobRow { + pub id: i64, + pub name: String, + pub state: ActionState, + pub meta: String, + pub steps: Vec, +} + +#[derive(Clone, uniffi::Record)] +pub struct ActionRunListPage { + pub rows: Vec, + pub has_more: bool, +} + +#[derive(Clone, uniffi::Record)] +pub struct ActionRunPage { + pub run: ActionRunRow, + pub jobs: Vec, +} + +#[derive(Clone, uniffi::Record)] +pub struct ActionJobLogPage { + pub job: ActionJobRow, + pub text: String, + pub groups: Vec, +} + +#[derive(Clone, uniffi::Record)] +pub struct ActionLogGroupRow { + pub name: String, + pub text: String, + pub line_count: u64, + pub state: ActionState, + pub duration: String, } #[derive(Clone, uniffi::Record)] @@ -404,6 +483,7 @@ pub struct WidgetPullPage { pub rows: Vec, } +mod actions; mod details; mod files; mod helpers; @@ -411,6 +491,7 @@ mod home; mod lists; mod notifications; +pub use actions::*; pub use details::*; pub use files::*; pub use helpers::compact_date; diff --git a/crates/app/src/presentation/actions.rs b/crates/app/src/presentation/actions.rs new file mode 100644 index 0000000..885b47d --- /dev/null +++ b/crates/app/src/presentation/actions.rs @@ -0,0 +1,240 @@ +use gotcha_gitea::{ActionJobDetails, ActionRunDetails, models}; + +use super::*; + +pub fn action_workflow_rows(workflows: Vec) -> Vec { + workflows + .into_iter() + .map(|workflow| { + let path = workflow.path.unwrap_or_default(); + ActionWorkflowRow { + id: workflow.id.unwrap_or_else(|| path.clone()), + name: workflow.name.unwrap_or_else(|| "Unnamed workflow".into()), + path, + state: workflow.state.unwrap_or_else(|| "unknown".into()), + } + }) + .collect() +} + +pub fn action_run_rows(runs: &[models::ActionWorkflowRun]) -> Vec { + runs.iter().map(action_run_row).collect() +} + +pub fn action_run_page(details: ActionRunDetails) -> ActionRunPage { + ActionRunPage { + run: action_run_row(&details.run), + jobs: details.jobs.iter().map(action_job_row).collect(), + } +} + +pub fn action_job_log_page(details: ActionJobDetails) -> ActionJobLogPage { + let job = action_job_row(&details.job); + let groups = details + .groups + .into_iter() + .map(|group| ActionLogGroupRow { + state: action_log_group_state(&job, &group.name, &group.text), + duration: group + .duration_seconds + .map(format_duration) + .unwrap_or_default(), + line_count: group.text.lines().count() as u64, + name: group.name, + text: group.text, + }) + .collect(); + ActionJobLogPage { + job, + text: details.text, + groups, + } +} + +fn format_duration(seconds: u64) -> String { + if seconds >= 3_600 { + format!("{}h {}m", seconds / 3_600, seconds % 3_600 / 60) + } else if seconds >= 60 { + format!("{}m {}s", seconds / 60, seconds % 60) + } else { + format!("{seconds}s") + } +} + +fn action_log_group_state(job: &ActionJobRow, name: &str, text: &str) -> ActionState { + if let Some(step) = job + .steps + .iter() + .find(|step| step.name.eq_ignore_ascii_case(name)) + { + return step.state; + } + if text.contains("::error::") + || text.contains("##[error]") + || text.contains("Process completed with exit code") + || text.contains("Job failed") + { + ActionState::Failed + } else if name == "Complete job" { + job.state + } else if matches!(job.state, ActionState::Succeeded | ActionState::Failed) { + ActionState::Succeeded + } else { + ActionState::Unknown + } +} + +fn action_run_row(run: &models::ActionWorkflowRun) -> ActionRunRow { + let status = run.status.clone().unwrap_or_else(|| "unknown".into()); + let conclusion = run.conclusion.clone().unwrap_or_default(); + let duration = duration_between(run.started_at.as_deref(), run.completed_at.as_deref()); + ActionRunRow { + id: run.id.unwrap_or_default(), + number: run.run_number.unwrap_or_default(), + title: run + .display_title + .clone() + .or_else(|| run.path.clone()) + .unwrap_or_else(|| "Workflow run".into()), + state: action_state(&status, &conclusion), + status, + conclusion, + branch: run.head_branch.clone().unwrap_or_default(), + event: run.event.clone().unwrap_or_default(), + meta: [ + duration.as_deref().unwrap_or_default(), + run.started_at.as_deref().unwrap_or_default(), + run.actor + .as_ref() + .and_then(|actor| actor.login.as_deref()) + .unwrap_or_default(), + ] + .into_iter() + .filter(|value| !value.is_empty()) + .collect::>() + .join(" · "), + } +} + +fn action_job_row(job: &models::ActionWorkflowJob) -> ActionJobRow { + let status = job.status.as_deref().unwrap_or("unknown"); + let conclusion = job.conclusion.as_deref().unwrap_or_default(); + let duration = duration_between(job.started_at.as_deref(), job.completed_at.as_deref()); + ActionJobRow { + id: job.id.unwrap_or_default(), + name: job.name.clone().unwrap_or_else(|| "Job".into()), + state: action_state(status, conclusion), + meta: [ + status, + conclusion, + duration.as_deref().unwrap_or_default(), + job.runner_name.as_deref().unwrap_or_default(), + ] + .into_iter() + .filter(|value| !value.is_empty()) + .collect::>() + .join(" · "), + steps: job + .steps + .as_deref() + .unwrap_or_default() + .iter() + .map(|step| { + let status = step.status.as_deref().unwrap_or("unknown"); + let conclusion = step.conclusion.as_deref().unwrap_or_default(); + let duration = + duration_between(step.started_at.as_deref(), step.completed_at.as_deref()); + ActionStepRow { + name: step.name.clone().unwrap_or_else(|| "Step".into()), + state: action_state(status, conclusion), + meta: [status, conclusion, duration.as_deref().unwrap_or_default()] + .into_iter() + .filter(|value| !value.is_empty()) + .collect::>() + .join(" · "), + } + }) + .collect(), + } +} + +fn duration_between(start: Option<&str>, end: Option<&str>) -> Option { + let seconds = parse_api_timestamp(end?)?.checked_sub(parse_api_timestamp(start?)?)?; + u64::try_from(seconds).ok().map(format_duration) +} + +fn action_state(status: &str, conclusion: &str) -> ActionState { + match conclusion { + "success" => ActionState::Succeeded, + "failure" | "timed_out" | "startup_failure" | "stale" => ActionState::Failed, + "cancelled" => ActionState::Cancelled, + "skipped" | "neutral" => ActionState::Skipped, + _ => match status { + "queued" | "requested" => ActionState::Queued, + "waiting" | "pending" | "blocked" => ActionState::Waiting, + "in_progress" | "running" => ActionState::InProgress, + "success" => ActionState::Succeeded, + "failure" | "timed_out" | "startup_failure" | "stale" => ActionState::Failed, + "cancelled" => ActionState::Cancelled, + "skipped" | "neutral" => ActionState::Skipped, + _ => ActionState::Unknown, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_progress_and_terminal_conclusions() { + assert_eq!(action_state("queued", ""), ActionState::Queued); + assert_eq!(action_state("waiting", ""), ActionState::Waiting); + assert_eq!(action_state("in_progress", ""), ActionState::InProgress); + assert_eq!(action_state("completed", "success"), ActionState::Succeeded); + assert_eq!(action_state("completed", "failure"), ActionState::Failed); + assert_eq!( + action_state("completed", "cancelled"), + ActionState::Cancelled + ); + + let mut workflow = models::ActionWorkflow::new(); + workflow.path = Some(".gitea/workflows/ci.yml".into()); + assert_eq!( + action_workflow_rows(vec![workflow])[0].id, + ".gitea/workflows/ci.yml" + ); + + let mut job = models::ActionWorkflowJob::new(); + job.id = Some(1); + job.name = Some("build".into()); + job.status = Some("completed".into()); + job.conclusion = Some("failure".into()); + let mut step = models::ActionWorkflowStep::new(); + step.name = Some("Test".into()); + step.status = Some("completed".into()); + step.conclusion = Some("failure".into()); + job.steps = Some(vec![step]); + let log = action_job_log_page(ActionJobDetails { + job, + text: "one\ntwo".into(), + groups: vec![ + gotcha_gitea::ActionLogGroup { + name: "Test".into(), + text: "one\ntwo".into(), + duration_seconds: Some(65), + }, + gotcha_gitea::ActionLogGroup { + name: "Set up job".into(), + text: "ready".into(), + duration_seconds: Some(1), + }, + ], + }); + assert_eq!(log.groups[0].name, "Test"); + assert_eq!(log.groups[0].line_count, 2); + assert_eq!(log.groups[0].state, ActionState::Failed); + assert_eq!(log.groups[0].duration, "1m 5s"); + assert_eq!(log.groups[1].state, ActionState::Succeeded); + } +} diff --git a/crates/app/src/presentation/lists.rs b/crates/app/src/presentation/lists.rs index 761a442..1d3b140 100644 --- a/crates/app/src/presentation/lists.rs +++ b/crates/app/src/presentation/lists.rs @@ -18,6 +18,7 @@ pub fn repository_rows( name: repository.name, owner: repository.owner, description: repository.description, + default_branch: repository.default_branch, }) .collect() } diff --git a/crates/app/src/storage.rs b/crates/app/src/storage.rs index 79be903..d5d827b 100644 --- a/crates/app/src/storage.rs +++ b/crates/app/src/storage.rs @@ -13,7 +13,7 @@ use security_framework::passwords::{ use crate::{ RepositoryPane, - domain::{Preferences, Server, open_status}, + domain::{Preferences, Server, normalize_primary_destinations, open_status}, }; pub fn favorite_key(pane: RepositoryPane, server: &str, owner: &str, repository: &str) -> String { @@ -81,6 +81,7 @@ pub fn load_preferences(storage_directory: Option<&str>) -> Result) -> Result - { - dispatch(client, &scope(selection, None)?, workflow, reference).await?; - } - [domain, group, action, workflow, reference, repository] + [domain, group, action, arguments @ ..] if domain == "action" && group == "workflow" && action == "dispatch" => { + let arguments = dispatch_arguments(arguments, selection)?; dispatch( client, - &scope(selection, Some(repository))?, - workflow, - reference, + &arguments.repository, + &arguments.workflow, + &arguments.reference, + &arguments.inputs, ) .await?; } - [domain, group, action] if domain == "action" && group == "run" && action == "list" => { - print_runs(&client.action_runs(&scope(selection, None)?).await?); - } - [domain, group, action, repository] + [domain, group, action, arguments @ ..] if domain == "action" && group == "run" && action == "list" => { - print_runs( - &client - .action_runs(&scope(selection, Some(repository))?) - .await?, - ); + let (repository, query) = run_list_arguments(arguments, selection)?; + print_runs(&client.action_runs(&repository, &query).await?.items); } [domain, group, action, id] if domain == "action" && group == "run" && action == "show" => { print_run( @@ -161,14 +157,96 @@ async fn dispatch( repository: &RepositoryScope, workflow: &str, reference: &str, + inputs: &std::collections::BTreeMap, ) -> Result<(), Box> { client - .dispatch_action_workflow(repository, workflow, reference) + .dispatch_action_workflow(repository, workflow, reference, inputs) .await?; println!("Dispatched workflow {workflow} on {reference}."); Ok(()) } +struct DispatchArguments { + repository: RepositoryScope, + workflow: String, + reference: String, + inputs: std::collections::BTreeMap, +} + +fn dispatch_arguments( + arguments: &[String], + selection: &Selection, +) -> Result> { + let [workflow, reference, remaining @ ..] = arguments else { + return Err("usage: gotcha action workflow dispatch WORKFLOW REF [OWNER/REPOSITORY] [--input KEY=VALUE]…".into()); + }; + let mut repository = None; + let mut inputs = Vec::new(); + let mut index = 0; + while index < remaining.len() { + if remaining[index] == "--input" { + index += 1; + inputs.push( + remaining + .get(index) + .ok_or("--input requires KEY=VALUE")? + .clone(), + ); + } else if remaining[index].starts_with('-') { + return Err(format!("unknown dispatch option: {}", remaining[index]).into()); + } else if repository.replace(remaining[index].as_str()).is_some() { + return Err("only one OWNER/REPOSITORY may be supplied".into()); + } + index += 1; + } + Ok(DispatchArguments { + repository: scope(selection, repository)?, + workflow: workflow.clone(), + reference: reference.clone(), + inputs: parse_action_inputs(&inputs)?, + }) +} + +fn run_list_arguments( + arguments: &[String], + selection: &Selection, +) -> Result<(RepositoryScope, ActionRunQuery), Box> { + let mut repository = None; + let mut query = ActionRunQuery::default(); + let mut index = 0; + while index < arguments.len() { + let argument = &arguments[index]; + if argument.starts_with('-') { + index += 1; + let value = arguments + .get(index) + .ok_or_else(|| format!("{argument} requires a value"))?; + match argument.as_str() { + "--page" => query.page = positive_number(value, "page")?, + "--limit" => query.limit = positive_number(value, "limit")?, + "--status" => query.status = Some(value.clone()), + "--event" => query.event = Some(value.clone()), + "--branch" => query.branch = Some(value.clone()), + "--actor" => query.actor = Some(value.clone()), + "--sha" => query.head_sha = Some(value.clone()), + _ => return Err(format!("unknown run-list option: {argument}").into()), + } + } else if repository.replace(argument.as_str()).is_some() { + return Err("only one OWNER/REPOSITORY may be supplied".into()); + } + index += 1; + } + Ok((scope(selection, repository)?, query)) +} + +fn positive_number(value: &str, name: &str) -> Result> { + value + .parse::() + .ok() + .filter(|value| *value > 0) + .ok_or_else(|| format!("{name} must be a positive integer").into()) +} + fn print_workflows(workflows: &[ActionWorkflow]) { print_table( &[("ID", 40), ("STATE", 12), ("NAME", 50), ("PATH", 70)], @@ -299,4 +377,39 @@ mod tests { .contains("logs ID") ); } + + #[test] + fn parses_dispatch_inputs_and_run_filters() { + let selection = Selection { + name: Some("gitea".into()), + url: "https://gitea.example.com".into(), + token: Some("secret".into()), + provider: gotcha_gitea::Provider::Gitea, + repository: Some(RepositoryScope::parse("hugo/Gotcha").unwrap()), + }; + let dispatch = dispatch_arguments( + &[ + "release.yml".into(), + "main".into(), + "--input".into(), + "channel=stable".into(), + ], + &selection, + ) + .unwrap(); + assert_eq!(dispatch.inputs["channel"], "stable"); + + let (_, query) = run_list_arguments( + &[ + "--page".into(), + "2".into(), + "--status".into(), + "in_progress".into(), + ], + &selection, + ) + .unwrap(); + assert_eq!(query.page, 2); + assert_eq!(query.status.as_deref(), Some("in_progress")); + } } diff --git a/crates/gitea/src/actions.rs b/crates/gitea/src/actions.rs index 0c5a904..64cfe7f 100644 --- a/crates/gitea/src/actions.rs +++ b/crates/gitea/src/actions.rs @@ -1,8 +1,35 @@ +use std::collections::{BTreeMap, HashMap}; + use reqwest::header::ACCEPT; -use crate::{Client, Error, Method, RepositoryId, Result, models, positive}; +use crate::{Client, Error, Method, Page, RepositoryId, Result, models, positive, validate_page}; use gitea_openapi::apis; +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ActionRunQuery { + pub event: Option, + pub branch: Option, + pub status: Option, + pub actor: Option, + pub head_sha: Option, + pub page: i32, + pub limit: i32, +} + +impl Default for ActionRunQuery { + fn default() -> Self { + Self { + event: None, + branch: None, + status: None, + actor: None, + head_sha: None, + page: 1, + limit: crate::DEFAULT_PAGE_SIZE, + } + } +} + #[derive(Debug)] pub struct ActionRunDetails { pub run: models::ActionWorkflowRun, @@ -15,6 +42,20 @@ pub struct ActionJobLog { pub text: String, } +#[derive(Debug)] +pub struct ActionJobDetails { + pub job: models::ActionWorkflowJob, + pub text: String, + pub groups: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ActionLogGroup { + pub name: String, + pub text: String, + pub duration_seconds: Option, +} + impl Client { pub async fn action_workflows( &self, @@ -35,18 +76,28 @@ impl Client { repository: &RepositoryId, workflow: &str, reference: &str, + inputs: &BTreeMap, ) -> Result<()> { + let workflow = workflow.trim(); + let reference = reference.trim(); if workflow.is_empty() || reference.is_empty() { return Err(Error::InvalidInput( "workflow and reference must not be empty".into(), )); } + let mut dispatch = models::CreateActionWorkflowDispatch::new(reference.into()); + dispatch.inputs = (!inputs.is_empty()).then(|| { + inputs + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>() + }); apis::repository_api::actions_dispatch_workflow( &self.configuration(), &repository.owner, &repository.repository, workflow, - Some(models::CreateActionWorkflowDispatch::new(reference.into())), + Some(dispatch), ) .await .map_err(Error::generated) @@ -55,21 +106,23 @@ impl Client { pub async fn action_runs( &self, repository: &RepositoryId, - ) -> Result> { + query: &ActionRunQuery, + ) -> Result> { + validate_page(query.page, query.limit)?; apis::repository_api::get_workflow_runs( &self.configuration(), &repository.owner, &repository.repository, - None, - None, - None, - None, - None, - Some(1), - Some(100), + query.event.as_deref(), + query.branch.as_deref(), + query.status.as_deref(), + query.actor.as_deref(), + query.head_sha.as_deref(), + Some(query.page), + Some(query.limit), ) .await - .map(|response| response.workflow_runs.unwrap_or_default()) + .map(|response| Page::from_items(response.workflow_runs.unwrap_or_default(), query.limit)) .map_err(Error::generated) } @@ -91,24 +144,9 @@ impl Client { .await .map_err(Error::generated) }, - async { - apis::repository_api::list_workflow_run_jobs( - &self.configuration(), - &repository.owner, - &repository.repository, - run_id, - None, - Some(1), - Some(100), - ) - .await - .map_err(Error::generated) - } + async { self.action_jobs(repository, run_id).await } )?; - Ok(ActionRunDetails { - run, - jobs: jobs.jobs.unwrap_or_default(), - }) + Ok(ActionRunDetails { run, jobs }) } pub async fn action_run_logs( @@ -117,41 +155,274 @@ impl Client { run: i64, ) -> Result> { let run = action_run_id(run)?; - let jobs = apis::repository_api::list_workflow_run_jobs( - &self.configuration(), - &repository.owner, - &repository.repository, - run, - None, - Some(1), - Some(100), - ) - .await - .map_err(Error::generated)? - .jobs - .unwrap_or_default(); + let jobs = self.action_jobs(repository, run).await?; let mut logs = Vec::with_capacity(jobs.len()); for job in jobs { let id = job .id .ok_or_else(|| Error::Generated("Action job has no ID.".into()))?; - let endpoint = format!( - "repos/{}/{}/actions/jobs/{id}/logs", - apis::urlencode(&repository.owner), - apis::urlencode(&repository.repository), - ); - let text = self - .execute( - self.request(Method::GET, &endpoint)? - .header(ACCEPT, "text/plain"), - ) - .await? - .text() - .await?; + let text = self.action_job_log(repository, id).await?; logs.push(ActionJobLog { job, text }); } Ok(logs) } + + pub async fn action_job_log(&self, repository: &RepositoryId, job: i64) -> Result { + positive(job, "job ID")?; + let endpoint = format!( + "repos/{}/{}/actions/jobs/{job}/logs", + apis::urlencode(&repository.owner), + apis::urlencode(&repository.repository), + ); + self.execute( + self.request(Method::GET, &endpoint)? + .header(ACCEPT, "text/plain"), + ) + .await? + .text() + .await + .map_err(Error::from) + } + + pub async fn action_job( + &self, + repository: &RepositoryId, + job: i64, + ) -> Result { + positive(job, "job ID")?; + apis::repository_api::get_workflow_job( + &self.configuration(), + &repository.owner, + &repository.repository, + &job.to_string(), + ) + .await + .map_err(Error::generated) + } + + pub async fn action_run( + &self, + repository: &RepositoryId, + run: i64, + ) -> Result { + positive(run, "run ID")?; + apis::repository_api::get_workflow_run( + &self.configuration(), + &repository.owner, + &repository.repository, + &run.to_string(), + ) + .await + .map_err(Error::generated) + } + + pub async fn action_job_details( + &self, + repository: &RepositoryId, + run: i64, + job: i64, + ) -> Result { + let (run, job, text) = tokio::try_join!( + self.action_run(repository, run), + self.action_job(repository, job), + self.action_job_log(repository, job), + )?; + let workflow = run + .path + .as_deref() + .and_then(|path| path.split('@').next()) + .filter(|path| !path.is_empty()) + .map(|path| { + if path.starts_with(".gitea/workflows/") { + path.to_owned() + } else { + format!(".gitea/workflows/{path}") + } + }); + let steps = if let (Some(path), Some(reference), Some(job_name)) = ( + workflow.as_deref(), + run.head_sha.as_deref(), + job.name.as_deref(), + ) { + match self + .repository_file_at_ref(repository, path, Some(reference)) + .await + { + Ok(yaml) => action_workflow_step_names(&yaml, job_name), + Err(_) => Vec::new(), + } + } else { + Vec::new() + }; + let groups = group_action_log_with_steps(&text, &steps); + Ok(ActionJobDetails { job, text, groups }) + } + + async fn action_jobs( + &self, + repository: &RepositoryId, + run: i32, + ) -> Result> { + let mut jobs = Vec::new(); + for page in 1.. { + let mut batch = apis::repository_api::list_workflow_run_jobs( + &self.configuration(), + &repository.owner, + &repository.repository, + run, + None, + Some(page), + Some(100), + ) + .await + .map_err(Error::generated)? + .jobs + .unwrap_or_default(); + let complete = batch.len() < 100; + jobs.append(&mut batch); + if complete { + return Ok(jobs); + } + } + unreachable!() + } +} + +pub fn parse_action_inputs(values: &[String]) -> Result> { + let mut inputs = BTreeMap::new(); + for value in values { + for line in value.lines().map(str::trim).filter(|line| !line.is_empty()) { + let (key, value) = line.split_once('=').ok_or_else(|| { + Error::InvalidInput(format!("workflow input must be KEY=VALUE: {line}")) + })?; + let key = key.trim(); + if key.is_empty() { + return Err(Error::InvalidInput( + "workflow input key must not be empty".into(), + )); + } + if inputs.insert(key.into(), value.trim().into()).is_some() { + return Err(Error::InvalidInput(format!( + "workflow input {key} was provided more than once" + ))); + } + } + } + Ok(inputs) +} + +pub fn group_action_log(log: &str) -> Vec { + group_action_log_with_steps(log, &[]) +} + +pub fn group_action_log_with_steps(log: &str, steps: &[String]) -> Vec { + let mut groups = Vec::new(); + let mut name = "Set up job".to_string(); + let mut lines = Vec::new(); + let mut depth = 0_u32; + let mut step_index = None; + for line in log.lines() { + if let Some((prefix, label)) = log_group_marker(line) { + if depth == 0 + && let Some(step) = label + .strip_prefix("Run ") + .or_else(|| label.strip_prefix("Post ")) + { + push_log_group(&mut groups, name, lines); + name = if label.starts_with("Post ") || step.starts_with("Post ") { + step_index = None; + "Complete job".into() + } else { + step_index = steps.iter().position(|name| name == step); + step.into() + }; + lines = Vec::new(); + } else { + lines.push(format!("{prefix}{label}")); + } + depth += 1; + } else if is_log_group_end(line) { + depth = depth.saturating_sub(1); + if depth == 0 + && let Some(index) = step_index + && let Some(next) = steps.get(index + 1) + { + push_log_group(&mut groups, name, lines); + name = next.clone(); + lines = Vec::new(); + step_index = Some(index + 1); + } + } else if depth == 0 && log_message(line).starts_with("Run Post ") { + push_log_group(&mut groups, name, lines); + name = "Complete job".into(); + lines = vec![line.to_string()]; + step_index = None; + } else { + lines.push(line.to_string()); + } + } + push_log_group(&mut groups, name, lines); + if groups.len() == 1 && groups[0].name == "Set up job" { + groups[0].name = "Job log".into(); + } + groups +} + +fn log_group_marker(line: &str) -> Option<(&str, &str)> { + ["::group::", "##[group]"] + .into_iter() + .find_map(|marker| line.split_once(marker)) + .map(|(prefix, label)| (prefix, label.trim())) +} + +fn is_log_group_end(line: &str) -> bool { + line.contains("::endgroup::") || line.contains("##[endgroup]") +} + +fn push_log_group(groups: &mut Vec, name: String, lines: Vec) { + if !lines.is_empty() { + let duration_seconds = log_duration(&lines); + if let Some(group) = groups.last_mut() + && group.name == name + { + group.text.push('\n'); + group.text.push_str(&lines.join("\n")); + group.duration_seconds = + log_duration(&group.text.lines().map(str::to_owned).collect::>()); + return; + } + groups.push(ActionLogGroup { + name, + text: lines.join("\n"), + duration_seconds, + }); + } +} + +fn action_workflow_step_names(yaml: &[u8], job: &str) -> Vec { + let Ok(value) = serde_yaml::from_slice::(yaml) else { + return Vec::new(); + }; + value["jobs"][job]["steps"] + .as_sequence() + .into_iter() + .flatten() + .filter_map(|step| step["name"].as_str().map(str::to_owned)) + .collect() +} + +fn log_message(line: &str) -> &str { + line.split_once("Z ").map_or(line, |(_, message)| message) +} + +fn log_duration(lines: &[String]) -> Option { + let start = lines.iter().find_map(|line| log_timestamp(line))?; + let end = lines.iter().rev().find_map(|line| log_timestamp(line))?; + Some(end.saturating_sub(start).max(1)) +} + +fn log_timestamp(line: &str) -> Option { + crate::parse_api_timestamp(line).and_then(|value| value.try_into().ok()) } fn action_run_id(run: i64) -> Result { @@ -170,6 +441,31 @@ mod tests { use super::*; use crate::Provider; + fn read_request(stream: &mut std::net::TcpStream) -> String { + let mut request = Vec::new(); + loop { + let mut buffer = [0; 4096]; + let length = stream.read(&mut buffer).unwrap(); + request.extend_from_slice(&buffer[..length]); + let Some(header_end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") else { + continue; + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length: ") + .map(str::to_owned) + }) + .and_then(|length| length.parse::().ok()) + .unwrap_or(0); + if request.len() >= header_end + 4 + content_length { + return String::from_utf8(request).unwrap(); + } + } + } + #[tokio::test] async fn gets_every_job_log_for_a_run() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -210,4 +506,104 @@ mod tests { assert!(requests[1].starts_with("GET /api/v1/repos/hugo/Gotcha/actions/jobs/139/logs ")); assert!(requests[1].contains("accept: text/plain")); } + + #[tokio::test] + async fn dispatches_reference_and_inputs() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_request(&mut stream); + write!( + stream, + "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .unwrap(); + request + }); + + let client = + Client::with_provider(&format!("http://{address}"), None, Provider::Gitea).unwrap(); + let repository = RepositoryId::new("hugo", "Gotcha").unwrap(); + client + .dispatch_action_workflow( + &repository, + "release.yml", + "main", + &BTreeMap::from([ + ("channel".into(), "stable".into()), + ("dry_run".into(), "false".into()), + ]), + ) + .await + .unwrap(); + + let request = server.join().unwrap(); + assert!(request.starts_with( + "POST /api/v1/repos/hugo/Gotcha/actions/workflows/release.yml/dispatches " + )); + assert!(request.contains(r#""ref":"main""#)); + assert!(request.contains(r#""channel":"stable""#)); + assert!(request.contains(r#""dry_run":"false""#)); + } + + #[test] + fn parses_dispatch_inputs_once() { + let inputs = parse_action_inputs(&[ + "channel=stable".into(), + "notes=public update\ndry_run=false".into(), + ]) + .unwrap(); + assert_eq!(inputs["channel"], "stable"); + assert_eq!(inputs["notes"], "public update"); + assert!(parse_action_inputs(&["channel".into()]).is_err()); + assert!(parse_action_inputs(&["channel=a\nchannel=b".into()]).is_err()); + } + + #[test] + fn groups_runner_logs_into_setup_steps_and_completion() { + let groups = group_action_log( + "00 ::group::Runner Information\n01 runner\n02 ::endgroup::\n03 ::group::Run Check out repository\n04 checkout\n05 ::endgroup::\n06 ::group::Run Post Check out repository\n07 cleanup\n08 ::endgroup::\n", + ); + assert_eq!( + groups + .iter() + .map(|group| group.name.as_str()) + .collect::>(), + ["Set up job", "Check out repository", "Complete job"] + ); + assert_eq!(groups[0].text, "00 Runner Information\n01 runner"); + assert_eq!(groups[1].text, "04 checkout"); + assert_eq!(groups[2].text, "07 cleanup"); + + let completion = group_action_log( + "::group::Run Post first\none\n::endgroup::\n::group::Run Post second\ntwo\n::endgroup::", + ); + assert_eq!(completion.len(), 1); + assert_eq!(completion[0].name, "Complete job"); + assert_eq!(completion[0].text, "one\ntwo"); + + let steps = action_workflow_step_names( + b"jobs:\n audit:\n steps:\n - name: Check out repository\n - name: Scan dependencies\n", + "audit", + ); + let groups = group_action_log_with_steps( + "2026-08-10T03:17:25Z setup\n2026-08-10T03:17:26Z ::group::Run Check out repository\n2026-08-10T03:17:27Z checkout\n2026-08-10T03:17:28Z ::endgroup::\n2026-08-10T03:17:29Z scanning\n2026-08-10T03:17:32Z Run Post Check out repository\n2026-08-10T03:17:33Z cleanup\n", + &steps, + ); + assert_eq!( + groups + .iter() + .map(|group| group.name.as_str()) + .collect::>(), + [ + "Set up job", + "Check out repository", + "Scan dependencies", + "Complete job" + ] + ); + assert_eq!(groups[2].duration_seconds, Some(1)); + assert_eq!(groups[3].duration_seconds, Some(1)); + } } diff --git a/crates/gitea/src/domain.rs b/crates/gitea/src/domain.rs index 26afd4f..5bc35bb 100644 --- a/crates/gitea/src/domain.rs +++ b/crates/gitea/src/domain.rs @@ -197,6 +197,14 @@ pub fn parse_api_date(value: &str) -> Option { (civil_from_days(days) == (year, month, day)).then_some(days * 86_400) } +pub fn parse_api_timestamp(value: &str) -> Option { + let date = parse_api_date(value)?; + let hour = value.get(11..13)?.parse::().ok()?; + let minute = value.get(14..16)?.parse::().ok()?; + let second = value.get(17..19)?.parse::().ok()?; + (hour < 24 && minute < 60 && second < 60).then_some(date + hour * 3_600 + minute * 60 + second) +} + pub fn civil_from_days(days: i64) -> (i64, i64, i64) { let days = days + 719_468; let era = days.div_euclid(146_097); @@ -241,6 +249,11 @@ mod tests { 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!(api_timestamp(leap_day + 45_296), "2024-02-29T12:34:56Z"); + assert_eq!( + parse_api_timestamp("2024-02-29T12:34:56.123Z"), + Some(leap_day + 45_296) + ); assert_eq!(parse_api_date("2023-02-29T00:00:00Z"), None); + assert_eq!(parse_api_timestamp("2024-02-29T25:00:00Z"), None); } } diff --git a/crates/gitea/src/lib.rs b/crates/gitea/src/lib.rs index a59ceb9..3af4d12 100644 --- a/crates/gitea/src/lib.rs +++ b/crates/gitea/src/lib.rs @@ -22,13 +22,16 @@ pub mod notifications; mod pulls; mod repositories; -pub use actions::{ActionJobLog, ActionRunDetails}; +pub use actions::{ + ActionJobDetails, ActionJobLog, ActionLogGroup, ActionRunDetails, ActionRunQuery, + group_action_log, group_action_log_with_steps, parse_action_inputs, +}; pub use activity::{ActivityFilter, ServerActivityPager}; pub use config::{Config, Selection, ServerProfile, TuiPreferences, server_url}; pub use domain::{ CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, IssueQuery, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryId, - api_date, api_timestamp, civil_from_days, days_from_civil, parse_api_date, + api_date, api_timestamp, civil_from_days, days_from_civil, parse_api_date, parse_api_timestamp, }; pub use issues::comment_can_edit; pub use pulls::{PullFileSource, pull_file_source, pull_state}; diff --git a/crates/gitea/src/repositories.rs b/crates/gitea/src/repositories.rs index adf0642..fedd9bf 100644 --- a/crates/gitea/src/repositories.rs +++ b/crates/gitea/src/repositories.rs @@ -128,6 +128,31 @@ impl Client { .map_err(Into::into) } + pub async fn repository_file_at_ref( + &self, + repository: &RepositoryId, + path: &str, + reference: Option<&str>, + ) -> Result> { + let endpoint = format!( + "repos/{}/{}/raw/{}", + apis::urlencode(&repository.owner), + apis::urlencode(&repository.repository), + encode_path(path), + ); + let request = self.request(Method::GET, &endpoint)?; + let request = match reference { + Some(reference) => request.query(&[("ref", reference)]), + None => request, + }; + self.execute(request) + .await? + .bytes() + .await + .map(|bytes| bytes.to_vec()) + .map_err(Into::into) + } + pub async fn branch_history( &self, repository: &RepositoryId, diff --git a/crates/gitea/src/repositories/tests.rs b/crates/gitea/src/repositories/tests.rs index 1129367..45c2dae 100644 --- a/crates/gitea/src/repositories/tests.rs +++ b/crates/gitea/src/repositories/tests.rs @@ -175,3 +175,34 @@ async fn reads_file_with_spaces_from_raw_endpoint() { ) ); } + +#[tokio::test] +async fn reads_historical_file_from_requested_ref() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut buffer = [0; 4096]; + let length = stream.read(&mut buffer).unwrap(); + let request = String::from_utf8_lossy(&buffer[..length]).into_owned(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\ncontents" + ) + .unwrap(); + request + }); + + let client = Client::new(&format!("http://{address}"), None).unwrap(); + let repository = RepositoryId::new("gitea", "gitea").unwrap(); + client + .repository_file_at_ref(&repository, ".gitea/workflows/ci.yml", Some("abc123")) + .await + .unwrap(); + + assert!( + server.join().unwrap().starts_with( + "GET /api/v1/repos/gitea/gitea/raw/.gitea%2Fworkflows%2Fci.yml?ref=abc123 " + ) + ); +} diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 6a43146..e1f2727 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -5,8 +5,8 @@ use std::{ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; use gotcha_gitea::{ - ActivityFilter, Client, Config, HistoryCommit, IssueDraft, IssueQuery, MilestoneDraft, - Provider, RepositoryId, Selection, models, + ActionRunQuery, ActivityFilter, Client, Config, HistoryCommit, IssueDraft, IssueQuery, + MilestoneDraft, Provider, RepositoryId, Selection, models, parse_action_inputs, }; use crate::editor::{Editor, EditorAction, EditorEvent}; @@ -21,15 +21,17 @@ pub enum Tab { Repositories, Pulls, Milestones, + Actions, } impl Tab { - pub const ALL: [Self; 5] = [ + pub const ALL: [Self; 6] = [ Self::Home, Self::Issues, Self::Repositories, Self::Pulls, Self::Milestones, + Self::Actions, ]; pub const fn title(self) -> &'static str { @@ -39,6 +41,7 @@ impl Tab { Self::Repositories => "3 Repos", Self::Pulls => "4 PRs", Self::Milestones => "5 Milestones", + Self::Actions => "6 Actions", } } } @@ -48,6 +51,7 @@ pub enum RepositoryDestination { Issues, Commits, Milestones, + Actions, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -66,6 +70,10 @@ pub enum ScreenKind { Pull(RepositoryId, i64), Milestones(RepositoryId), Milestone(RepositoryId, i64), + ActionWorkflows(RepositoryId), + ActionRuns(RepositoryId), + ActionRun(RepositoryId, i64), + ActionLog(RepositoryId, i64, i64, String), } impl ScreenKind { @@ -80,6 +88,9 @@ impl ScreenKind { | Self::Files(_, _) | Self::Pulls | Self::Milestones(_) + | Self::ActionWorkflows(_) + | Self::ActionRuns(_) + | Self::ActionRun(_, _) ) } } @@ -96,6 +107,10 @@ pub enum Target { Content(RepositoryId, String, bool), Comment(RepositoryId, i64, i64, String, bool), Text(String, String), + ActionRuns(RepositoryId), + ActionWorkflow(RepositoryId, String, String), + ActionRun(RepositoryId, i64), + ActionJob(RepositoryId, i64, i64, String), } #[derive(Clone, Debug)] @@ -354,6 +369,7 @@ impl App { KeyCode::Char('3') => self.switch_tab(Tab::Repositories).await, KeyCode::Char('4') => self.switch_tab(Tab::Pulls).await, KeyCode::Char('5') => self.switch_tab(Tab::Milestones).await, + KeyCode::Char('6') => self.switch_tab(Tab::Actions).await, KeyCode::Char('j') | KeyCode::Down => self.move_selection(1), KeyCode::Char('k') | KeyCode::Up => self.move_selection(-1), KeyCode::Char('g') => self.screen.select(0), @@ -552,6 +568,7 @@ impl App { RepositoryDestination::Issues => ScreenKind::Issues(repository), RepositoryDestination::Commits => ScreenKind::Commits(repository), RepositoryDestination::Milestones => ScreenKind::Milestones(repository), + RepositoryDestination::Actions => ScreenKind::ActionWorkflows(repository), }; self.open(kind).await; } @@ -579,6 +596,19 @@ impl App { Target::Text(title, text) => { self.open(ScreenKind::Text(title, text)).await; } + Target::ActionRuns(repository) => { + self.open(ScreenKind::ActionRuns(repository)).await; + } + Target::ActionWorkflow(repository, workflow, reference) => { + self.editor = Some(Editor::action_dispatch(repository, workflow, reference)); + } + Target::ActionRun(repository, run) => { + self.open(ScreenKind::ActionRun(repository, run)).await; + } + Target::ActionJob(repository, run, job, title) => { + self.open(ScreenKind::ActionLog(repository, run, job, title)) + .await; + } } } @@ -1042,6 +1072,13 @@ impl App { Ok(()) } } + EditorAction::ActionDispatch { + repository, + workflow, + } => { + self.save_action_dispatch(&repository, &workflow, &values) + .await + } }; match result { Ok(()) => { @@ -1100,6 +1137,20 @@ impl App { .map_err(|error| error.to_string()) } + async fn save_action_dispatch( + &self, + repository: &RepositoryId, + workflow: &str, + values: &[String], + ) -> Result<(), String> { + let inputs = + parse_action_inputs(&[values[1].clone()]).map_err(|error| error.to_string())?; + self.client + .dispatch_action_workflow(repository, workflow, values[0].trim(), &inputs) + .await + .map_err(|error| error.to_string()) + } + async fn save_server( &mut self, original_name: Option<&str>, @@ -1168,6 +1219,12 @@ impl App { ScreenKind::Milestone(repository, id) => { self.load_milestone(repository, id, page).await } + ScreenKind::ActionWorkflows(repository) => self.load_action_workflows(repository).await, + ScreenKind::ActionRuns(repository) => self.load_action_runs(repository, page).await, + ScreenKind::ActionRun(repository, run) => self.load_action_run(repository, run).await, + ScreenKind::ActionLog(repository, run, job, title) => { + self.load_action_log(repository, run, job, title).await + } } } @@ -1214,7 +1271,7 @@ impl App { screen.page = page; screen.has_more = data.next_page.is_some(); screen.detail = format!( - "{contributions} contributions in the available heatmap range\nFilter: {:?}\n\n[v] cycle activity filter · 1–5 switch panes · s servers · , preferences", + "{contributions} contributions in the available heatmap range\nFilter: {:?}\n\n[v] cycle activity filter · 1–6 switch panes · s servers · , preferences", self.activity_filter ); screen.items = data @@ -1341,6 +1398,7 @@ impl App { RepositoryDestination::Issues => "Issue repositories", RepositoryDestination::Commits => "Repositories", RepositoryDestination::Milestones => "Milestone repositories", + RepositoryDestination::Actions => "Actions repositories", }, ); screen.page = page; @@ -1772,6 +1830,275 @@ impl App { .collect(); Ok(screen) } + + async fn load_action_workflows(&self, repository: RepositoryId) -> Result { + let (workflows, repository_data) = tokio::try_join!( + self.client.action_workflows(&repository), + self.client + .repository(&repository.owner, &repository.repository) + ) + .map_err(err)?; + let default_branch = repository_data + .default_branch + .unwrap_or_else(|| "main".into()); + let mut screen = Screen::new( + ScreenKind::ActionWorkflows(repository.clone()), + format!("{}/{} · Workflows", repository.owner, repository.repository), + ); + screen.detail = + "Enter dispatches a workflow · Runs shows queued, running, and completed work".into(); + screen.items.push(Item { + key: "runs".into(), + title: "Workflow runs".into(), + graph_lane: None, + decoration: ItemDecoration::None, + meta: "progress, jobs, steps, and logs".into(), + detail: "Open the repository's workflow-run history.".into(), + target: Target::ActionRuns(repository.clone()), + }); + screen.items.extend(workflows.into_iter().map(|workflow| { + let id = workflow + .id + .clone() + .or_else(|| workflow.path.clone()) + .unwrap_or_default(); + Item { + key: format!("workflow-{id}"), + title: workflow.name.unwrap_or_else(|| "Unnamed workflow".into()), + graph_lane: None, + decoration: ItemDecoration::None, + meta: format!( + "{} · {}", + workflow.state.as_deref().unwrap_or("unknown"), + workflow.path.as_deref().unwrap_or_default() + ), + detail: "Enter to choose a Git reference and workflow_dispatch inputs.".into(), + target: Target::ActionWorkflow(repository.clone(), id, default_branch.clone()), + } + })); + Ok(screen) + } + + async fn load_action_runs( + &self, + repository: RepositoryId, + page: u32, + ) -> Result { + let result = self + .client + .action_runs( + &repository, + &ActionRunQuery { + page: page as i32, + ..Default::default() + }, + ) + .await + .map_err(err)?; + let mut screen = Screen::new( + ScreenKind::ActionRuns(repository.clone()), + format!("{}/{} · Runs", repository.owner, repository.repository), + ); + screen.page = page; + screen.has_more = result.has_more; + screen.detail = + "Auto-refresh preserves the selected run · Enter opens jobs and steps".into(); + screen.items = result + .items + .into_iter() + .filter_map(|run| { + let id = run.id?; + let status = action_status( + run.status.as_deref().unwrap_or("unknown"), + run.conclusion.as_deref().unwrap_or_default(), + ); + Some(Item { + key: id.to_string(), + title: format!( + "{} #{} {}", + status.0, + run.run_number.unwrap_or_default(), + run.display_title + .as_deref() + .or(run.path.as_deref()) + .unwrap_or("Workflow run") + ), + graph_lane: None, + decoration: ItemDecoration::None, + meta: format!( + "{} · {} · {}", + status.1, + run.event.as_deref().unwrap_or("unknown event"), + run.head_branch.as_deref().unwrap_or("unknown ref") + ), + detail: format!( + "Started: {}\nCommit: {}", + run.started_at.as_deref().unwrap_or("not started"), + run.head_sha.as_deref().unwrap_or("unknown") + ), + target: Target::ActionRun(repository.clone(), id), + }) + }) + .collect(); + Ok(screen) + } + + async fn load_action_run(&self, repository: RepositoryId, run: i64) -> Result { + let details = self + .client + .action_run_details(&repository, run) + .await + .map_err(err)?; + let status = action_status( + details.run.status.as_deref().unwrap_or("unknown"), + details.run.conclusion.as_deref().unwrap_or_default(), + ); + let mut screen = Screen::new( + ScreenKind::ActionRun(repository.clone(), run), + format!( + "Run #{} · {}", + details.run.run_number.unwrap_or_default(), + details + .run + .display_title + .as_deref() + .unwrap_or("Workflow run") + ), + ); + screen.detail = format!( + "{} {} \nEvent: {} \nReference: {} \nStarted: {} \nCompleted: {}\n\nAuto-refresh follows queued and running jobs.", + status.0, + status.1, + details.run.event.as_deref().unwrap_or("unknown"), + details.run.head_branch.as_deref().unwrap_or("unknown"), + details.run.started_at.as_deref().unwrap_or("not started"), + details + .run + .completed_at + .as_deref() + .unwrap_or("not completed") + ); + screen.items = details + .jobs + .into_iter() + .map(|job| { + let status = action_status( + job.status.as_deref().unwrap_or("unknown"), + job.conclusion.as_deref().unwrap_or_default(), + ); + let title = job.name.unwrap_or_else(|| "Job".into()); + let detail = job + .steps + .as_deref() + .unwrap_or_default() + .iter() + .map(|step| { + let status = action_status( + step.status.as_deref().unwrap_or("unknown"), + step.conclusion.as_deref().unwrap_or_default(), + ); + format!( + "{} {} · {}", + status.0, + step.name.as_deref().unwrap_or("Step"), + status.1 + ) + }) + .collect::>() + .join("\n"); + Item { + key: job.id.unwrap_or_default().to_string(), + title: format!("{} {title}", status.0), + graph_lane: None, + decoration: ItemDecoration::None, + meta: format!( + "{} · {}", + status.1, + job.runner_name.as_deref().unwrap_or("no runner") + ), + detail: if detail.is_empty() { + "No steps reported yet.".into() + } else { + detail + }, + target: job.id.map_or(Target::None, |id| { + Target::ActionJob(repository.clone(), run, id, format!("{title} · Log")) + }), + } + }) + .collect(); + Ok(screen) + } + + async fn load_action_log( + &self, + repository: RepositoryId, + run: i64, + job: i64, + title: String, + ) -> Result { + let details = self + .client + .action_job_details(&repository, run, job) + .await + .map_err(err)?; + let mut screen = Screen::new( + ScreenKind::ActionLog(repository, run, job, title.clone()), + title, + ); + screen.detail = if details.groups.is_empty() { + "This job has no log output.".into() + } else { + "Select a setup, workflow step, or completion group to inspect its log output.".into() + }; + screen.items = details + .groups + .into_iter() + .enumerate() + .map(|(index, group)| Item { + key: index.to_string(), + title: group.name, + graph_lane: None, + decoration: ItemDecoration::None, + meta: group.duration_seconds.map_or_else( + || format!("{} log lines", group.text.lines().count()), + action_duration, + ), + detail: group.text, + target: Target::None, + }) + .collect(); + Ok(screen) + } +} + +fn action_status(status: &str, conclusion: &str) -> (&'static str, &'static str) { + match conclusion { + "success" => ("✓", "succeeded"), + "failure" | "timed_out" | "startup_failure" | "stale" => ("✗", "failed"), + "cancelled" => ("⊘", "cancelled"), + "skipped" | "neutral" => ("−", "skipped"), + _ => match status { + "queued" | "requested" => ("◷", "queued"), + "waiting" | "pending" | "blocked" => ("◷", "waiting"), + "in_progress" | "running" => ("●", "in progress"), + "success" => ("✓", "succeeded"), + "failure" | "timed_out" | "startup_failure" | "stale" => ("✗", "failed"), + "cancelled" => ("⊘", "cancelled"), + "skipped" | "neutral" => ("−", "skipped"), + _ => ("?", "unknown"), + }, + } +} + +fn action_duration(seconds: u64) -> String { + if seconds >= 3_600 { + format!("{}h {}m", seconds / 3_600, seconds % 3_600 / 60) + } else if seconds >= 60 { + format!("{}m {}s", seconds / 60, seconds % 60) + } else { + format!("{seconds}s") + } } fn list_item_at( @@ -1829,6 +2156,7 @@ fn root_kind(tab: Tab) -> ScreenKind { Tab::Repositories => ScreenKind::Repositories(RepositoryDestination::Commits), Tab::Pulls => ScreenKind::Pulls, Tab::Milestones => ScreenKind::Repositories(RepositoryDestination::Milestones), + Tab::Actions => ScreenKind::Repositories(RepositoryDestination::Actions), } } @@ -1837,6 +2165,7 @@ fn repository_pane(destination: RepositoryDestination) -> &'static str { RepositoryDestination::Issues => "issues", RepositoryDestination::Commits => "commits", RepositoryDestination::Milestones => "milestones", + RepositoryDestination::Actions => "actions", } } diff --git a/crates/tui/src/editor.rs b/crates/tui/src/editor.rs index 0df6d71..69dcf44 100644 --- a/crates/tui/src/editor.rs +++ b/crates/tui/src/editor.rs @@ -23,6 +23,10 @@ pub enum EditorAction { Settings, IssueFilter, PullFilter, + ActionDispatch { + repository: RepositoryId, + workflow: String, + }, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -187,6 +191,20 @@ impl Editor { ) } + pub fn action_dispatch(repository: RepositoryId, workflow: String, reference: String) -> Self { + Self::new( + "Dispatch workflow", + EditorAction::ActionDispatch { + repository, + workflow, + }, + vec![ + Field::new("Git reference", reference), + Field::new("Inputs (one KEY=VALUE per line)", "").multiline(), + ], + ) + } + fn new(title: &str, action: EditorAction, fields: Vec) -> Self { let cursor = fields .first() diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index 317e134..0e3e3c3 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -478,6 +478,22 @@ fileprivate struct FfiConverterUInt32: FfiConverterPrimitive { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterUInt64: FfiConverterPrimitive { + typealias FfiType = UInt64 + typealias SwiftType = UInt64 + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UInt64 { + return try lift(readInt(&buf)) + } + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -603,6 +619,16 @@ fileprivate struct FfiConverterData: FfiConverterRustBuffer { public protocol GotchaCoreProtocol: AnyObject, Sendable { + func actionJobLog(owner: String, repository: String, run: Int64, job: Int64) async throws -> ActionJobLogPage + + func actionRun(owner: String, repository: String, run: Int64) async throws -> ActionRunPage + + func actionRuns(owner: String, repository: String, page: UInt32) async throws -> ActionRunListPage + + func actionWorkflows(owner: String, repository: String) async throws -> [ActionWorkflowRow] + + func dispatchActionWorkflow(owner: String, repository: String, workflow: String, reference: String, inputs: [String]) async throws + func commitDetails(owner: String, repository: String, sha: String, branch: String?) async throws -> CommitDetailsPage func commitDiff(owner: String, repository: String, sha: String, path: String) async throws -> DiffPage @@ -695,6 +721,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func setNotificationsEnabled(enabled: Bool) throws + func setPrimaryDestinations(destinations: [PrimaryDestination]) throws + func setPullStatus(status: String) throws func settings() -> Settings @@ -770,6 +798,86 @@ public convenience init(storageDirectory: String?) { +open func actionJobLog(owner: String, repository: String, run: Int64, job: Int64)async throws -> ActionJobLogPage { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_action_job_log( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(run),FfiConverterInt64.lower(job) + ) + }, + 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: FfiConverterTypeActionJobLogPage_lift, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + +open func actionRun(owner: String, repository: String, run: Int64)async throws -> ActionRunPage { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_action_run( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(run) + ) + }, + 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: FfiConverterTypeActionRunPage_lift, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + +open func actionRuns(owner: String, repository: String, page: UInt32)async throws -> ActionRunListPage { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_action_runs( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterUInt32.lower(page) + ) + }, + 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: FfiConverterTypeActionRunListPage_lift, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + +open func actionWorkflows(owner: String, repository: String)async throws -> [ActionWorkflowRow] { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_action_workflows( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository) + ) + }, + 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: FfiConverterSequenceTypeActionWorkflowRow.lift, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + +open func dispatchActionWorkflow(owner: String, repository: String, workflow: String, reference: String, inputs: [String])async throws { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_dispatch_action_workflow( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterString.lower(workflow),FfiConverterString.lower(reference),FfiConverterSequenceString.lower(inputs) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_void, + completeFunc: ffi_gotcha_core_rust_future_complete_void, + freeFunc: ffi_gotcha_core_rust_future_free_void, + liftFunc: { $0 }, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + open func commitDetails(owner: String, repository: String, sha: String, branch: String?)async throws -> CommitDetailsPage { return try await uniffiRustCallAsync( @@ -1412,6 +1520,15 @@ open func setNotificationsEnabled(enabled: Bool)throws {try rustCallWithError( } } +open func setPrimaryDestinations(destinations: [PrimaryDestination])throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { + uniffiCallStatus in + uniffi_gotcha_core_fn_method_gotchacore_set_primary_destinations( + self.uniffiCloneHandle(), + FfiConverterSequenceTypePrimaryDestination.lower(destinations),uniffiCallStatus + ) +} +} + open func setPullStatus(status: String)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { uniffiCallStatus in uniffi_gotcha_core_fn_method_gotchacore_set_pull_status( @@ -1535,6 +1652,506 @@ public func FfiConverterTypeGotchaCore_lower(_ value: GotchaCore) -> UInt64 { +public struct ActionJobLogPage: Equatable, Hashable { + public var job: ActionJobRow + public var text: String + public var groups: [ActionLogGroupRow] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(job: ActionJobRow, text: String, groups: [ActionLogGroupRow]) { + self.job = job + self.text = text + self.groups = groups + } + + + + +} + +#if compiler(>=6) +extension ActionJobLogPage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeActionJobLogPage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ActionJobLogPage { + return + try ActionJobLogPage( + job: FfiConverterTypeActionJobRow.read(from: &buf), + text: FfiConverterString.read(from: &buf), + groups: FfiConverterSequenceTypeActionLogGroupRow.read(from: &buf) + ) + } + + public static func write(_ value: ActionJobLogPage, into buf: inout [UInt8]) { + FfiConverterTypeActionJobRow.write(value.job, into: &buf) + FfiConverterString.write(value.text, into: &buf) + FfiConverterSequenceTypeActionLogGroupRow.write(value.groups, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActionJobLogPage_lift(_ buf: RustBuffer) throws -> ActionJobLogPage { + return try FfiConverterTypeActionJobLogPage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActionJobLogPage_lower(_ value: ActionJobLogPage) -> RustBuffer { + return FfiConverterTypeActionJobLogPage.lower(value) +} + + +public struct ActionJobRow: Equatable, Hashable { + public var id: Int64 + public var name: String + public var state: ActionState + public var meta: String + public var steps: [ActionStepRow] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: Int64, name: String, state: ActionState, meta: String, steps: [ActionStepRow]) { + self.id = id + self.name = name + self.state = state + self.meta = meta + self.steps = steps + } + + + + +} + +#if compiler(>=6) +extension ActionJobRow: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeActionJobRow: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ActionJobRow { + return + try ActionJobRow( + id: FfiConverterInt64.read(from: &buf), + name: FfiConverterString.read(from: &buf), + state: FfiConverterTypeActionState.read(from: &buf), + meta: FfiConverterString.read(from: &buf), + steps: FfiConverterSequenceTypeActionStepRow.read(from: &buf) + ) + } + + public static func write(_ value: ActionJobRow, into buf: inout [UInt8]) { + FfiConverterInt64.write(value.id, into: &buf) + FfiConverterString.write(value.name, into: &buf) + FfiConverterTypeActionState.write(value.state, into: &buf) + FfiConverterString.write(value.meta, into: &buf) + FfiConverterSequenceTypeActionStepRow.write(value.steps, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActionJobRow_lift(_ buf: RustBuffer) throws -> ActionJobRow { + return try FfiConverterTypeActionJobRow.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActionJobRow_lower(_ value: ActionJobRow) -> RustBuffer { + return FfiConverterTypeActionJobRow.lower(value) +} + + +public struct ActionLogGroupRow: Equatable, Hashable { + public var name: String + public var text: String + public var lineCount: UInt64 + public var state: ActionState + public var duration: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(name: String, text: String, lineCount: UInt64, state: ActionState, duration: String) { + self.name = name + self.text = text + self.lineCount = lineCount + self.state = state + self.duration = duration + } + + + + +} + +#if compiler(>=6) +extension ActionLogGroupRow: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeActionLogGroupRow: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ActionLogGroupRow { + return + try ActionLogGroupRow( + name: FfiConverterString.read(from: &buf), + text: FfiConverterString.read(from: &buf), + lineCount: FfiConverterUInt64.read(from: &buf), + state: FfiConverterTypeActionState.read(from: &buf), + duration: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: ActionLogGroupRow, into buf: inout [UInt8]) { + FfiConverterString.write(value.name, into: &buf) + FfiConverterString.write(value.text, into: &buf) + FfiConverterUInt64.write(value.lineCount, into: &buf) + FfiConverterTypeActionState.write(value.state, into: &buf) + FfiConverterString.write(value.duration, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActionLogGroupRow_lift(_ buf: RustBuffer) throws -> ActionLogGroupRow { + return try FfiConverterTypeActionLogGroupRow.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActionLogGroupRow_lower(_ value: ActionLogGroupRow) -> RustBuffer { + return FfiConverterTypeActionLogGroupRow.lower(value) +} + + +public struct ActionRunListPage: Equatable, Hashable { + public var rows: [ActionRunRow] + public var hasMore: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(rows: [ActionRunRow], hasMore: Bool) { + self.rows = rows + self.hasMore = hasMore + } + + + + +} + +#if compiler(>=6) +extension ActionRunListPage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeActionRunListPage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ActionRunListPage { + return + try ActionRunListPage( + rows: FfiConverterSequenceTypeActionRunRow.read(from: &buf), + hasMore: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: ActionRunListPage, into buf: inout [UInt8]) { + FfiConverterSequenceTypeActionRunRow.write(value.rows, into: &buf) + FfiConverterBool.write(value.hasMore, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActionRunListPage_lift(_ buf: RustBuffer) throws -> ActionRunListPage { + return try FfiConverterTypeActionRunListPage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActionRunListPage_lower(_ value: ActionRunListPage) -> RustBuffer { + return FfiConverterTypeActionRunListPage.lower(value) +} + + +public struct ActionRunPage: Equatable, Hashable { + public var run: ActionRunRow + public var jobs: [ActionJobRow] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(run: ActionRunRow, jobs: [ActionJobRow]) { + self.run = run + self.jobs = jobs + } + + + + +} + +#if compiler(>=6) +extension ActionRunPage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeActionRunPage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ActionRunPage { + return + try ActionRunPage( + run: FfiConverterTypeActionRunRow.read(from: &buf), + jobs: FfiConverterSequenceTypeActionJobRow.read(from: &buf) + ) + } + + public static func write(_ value: ActionRunPage, into buf: inout [UInt8]) { + FfiConverterTypeActionRunRow.write(value.run, into: &buf) + FfiConverterSequenceTypeActionJobRow.write(value.jobs, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActionRunPage_lift(_ buf: RustBuffer) throws -> ActionRunPage { + return try FfiConverterTypeActionRunPage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActionRunPage_lower(_ value: ActionRunPage) -> RustBuffer { + return FfiConverterTypeActionRunPage.lower(value) +} + + +public struct ActionRunRow: Equatable, Hashable { + public var id: Int64 + public var number: Int64 + public var title: String + public var state: ActionState + public var status: String + public var conclusion: String + public var branch: String + public var event: String + public var meta: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: Int64, number: Int64, title: String, state: ActionState, status: String, conclusion: String, branch: String, event: String, meta: String) { + self.id = id + self.number = number + self.title = title + self.state = state + self.status = status + self.conclusion = conclusion + self.branch = branch + self.event = event + self.meta = meta + } + + + + +} + +#if compiler(>=6) +extension ActionRunRow: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeActionRunRow: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ActionRunRow { + return + try ActionRunRow( + id: FfiConverterInt64.read(from: &buf), + number: FfiConverterInt64.read(from: &buf), + title: FfiConverterString.read(from: &buf), + state: FfiConverterTypeActionState.read(from: &buf), + status: FfiConverterString.read(from: &buf), + conclusion: FfiConverterString.read(from: &buf), + branch: FfiConverterString.read(from: &buf), + event: FfiConverterString.read(from: &buf), + meta: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: ActionRunRow, into buf: inout [UInt8]) { + FfiConverterInt64.write(value.id, into: &buf) + FfiConverterInt64.write(value.number, into: &buf) + FfiConverterString.write(value.title, into: &buf) + FfiConverterTypeActionState.write(value.state, into: &buf) + FfiConverterString.write(value.status, into: &buf) + FfiConverterString.write(value.conclusion, into: &buf) + FfiConverterString.write(value.branch, into: &buf) + FfiConverterString.write(value.event, into: &buf) + FfiConverterString.write(value.meta, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActionRunRow_lift(_ buf: RustBuffer) throws -> ActionRunRow { + return try FfiConverterTypeActionRunRow.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActionRunRow_lower(_ value: ActionRunRow) -> RustBuffer { + return FfiConverterTypeActionRunRow.lower(value) +} + + +public struct ActionStepRow: Equatable, Hashable { + public var name: String + public var state: ActionState + public var meta: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(name: String, state: ActionState, meta: String) { + self.name = name + self.state = state + self.meta = meta + } + + + + +} + +#if compiler(>=6) +extension ActionStepRow: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeActionStepRow: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ActionStepRow { + return + try ActionStepRow( + name: FfiConverterString.read(from: &buf), + state: FfiConverterTypeActionState.read(from: &buf), + meta: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: ActionStepRow, into buf: inout [UInt8]) { + FfiConverterString.write(value.name, into: &buf) + FfiConverterTypeActionState.write(value.state, into: &buf) + FfiConverterString.write(value.meta, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActionStepRow_lift(_ buf: RustBuffer) throws -> ActionStepRow { + return try FfiConverterTypeActionStepRow.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActionStepRow_lower(_ value: ActionStepRow) -> RustBuffer { + return FfiConverterTypeActionStepRow.lower(value) +} + + +public struct ActionWorkflowRow: Equatable, Hashable { + public var id: String + public var name: String + public var path: String + public var state: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: String, name: String, path: String, state: String) { + self.id = id + self.name = name + self.path = path + self.state = state + } + + + + +} + +#if compiler(>=6) +extension ActionWorkflowRow: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeActionWorkflowRow: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ActionWorkflowRow { + return + try ActionWorkflowRow( + id: FfiConverterString.read(from: &buf), + name: FfiConverterString.read(from: &buf), + path: FfiConverterString.read(from: &buf), + state: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: ActionWorkflowRow, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterString.write(value.name, into: &buf) + FfiConverterString.write(value.path, into: &buf) + FfiConverterString.write(value.state, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActionWorkflowRow_lift(_ buf: RustBuffer) throws -> ActionWorkflowRow { + return try FfiConverterTypeActionWorkflowRow.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActionWorkflowRow_lower(_ value: ActionWorkflowRow) -> RustBuffer { + return FfiConverterTypeActionWorkflowRow.lower(value) +} + + public struct ActivityRow: Equatable, Hashable { public var icon: ActivityIcon public var title: String @@ -3667,15 +4284,17 @@ public struct RepositoryRow: Equatable, Hashable { public var description: String public var meta: String public var favorite: Bool + public var defaultBranch: String // Default memberwise initializers are never public by default, so we // declare one manually. - public init(name: String, owner: String, description: String, meta: String, favorite: Bool) { + public init(name: String, owner: String, description: String, meta: String, favorite: Bool, defaultBranch: String) { self.name = name self.owner = owner self.description = description self.meta = meta self.favorite = favorite + self.defaultBranch = defaultBranch } @@ -3698,7 +4317,8 @@ public struct FfiConverterTypeRepositoryRow: FfiConverterRustBuffer { owner: FfiConverterString.read(from: &buf), description: FfiConverterString.read(from: &buf), meta: FfiConverterString.read(from: &buf), - favorite: FfiConverterBool.read(from: &buf) + favorite: FfiConverterBool.read(from: &buf), + defaultBranch: FfiConverterString.read(from: &buf) ) } @@ -3708,6 +4328,7 @@ public struct FfiConverterTypeRepositoryRow: FfiConverterRustBuffer { FfiConverterString.write(value.description, into: &buf) FfiConverterString.write(value.meta, into: &buf) FfiConverterBool.write(value.favorite, into: &buf) + FfiConverterString.write(value.defaultBranch, into: &buf) } } @@ -3902,14 +4523,16 @@ public struct Settings: Equatable, Hashable { public var pullStatus: String public var appearance: UInt32 public var notificationsEnabled: Bool + public var primaryDestinations: [PrimaryDestination] // Default memberwise initializers are never public by default, so we // declare one manually. - public init(issueStatus: String, pullStatus: String, appearance: UInt32, notificationsEnabled: Bool) { + public init(issueStatus: String, pullStatus: String, appearance: UInt32, notificationsEnabled: Bool, primaryDestinations: [PrimaryDestination]) { self.issueStatus = issueStatus self.pullStatus = pullStatus self.appearance = appearance self.notificationsEnabled = notificationsEnabled + self.primaryDestinations = primaryDestinations } @@ -3931,7 +4554,8 @@ public struct FfiConverterTypeSettings: FfiConverterRustBuffer { issueStatus: FfiConverterString.read(from: &buf), pullStatus: FfiConverterString.read(from: &buf), appearance: FfiConverterUInt32.read(from: &buf), - notificationsEnabled: FfiConverterBool.read(from: &buf) + notificationsEnabled: FfiConverterBool.read(from: &buf), + primaryDestinations: FfiConverterSequenceTypePrimaryDestination.read(from: &buf) ) } @@ -3940,6 +4564,7 @@ public struct FfiConverterTypeSettings: FfiConverterRustBuffer { FfiConverterString.write(value.pullStatus, into: &buf) FfiConverterUInt32.write(value.appearance, into: &buf) FfiConverterBool.write(value.notificationsEnabled, into: &buf) + FfiConverterSequenceTypePrimaryDestination.write(value.primaryDestinations, into: &buf) } } @@ -4068,6 +4693,114 @@ public func FfiConverterTypeWidgetPullPage_lower(_ value: WidgetPullPage) -> Rus +public enum ActionState: Equatable, Hashable { + + case queued + case waiting + case inProgress + case succeeded + case failed + case cancelled + case skipped + case unknown + + + + + +} + +#if compiler(>=6) +extension ActionState: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeActionState: FfiConverterRustBuffer { + typealias SwiftType = ActionState + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ActionState { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .queued + + case 2: return .waiting + + case 3: return .inProgress + + case 4: return .succeeded + + case 5: return .failed + + case 6: return .cancelled + + case 7: return .skipped + + case 8: return .unknown + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: ActionState, into buf: inout [UInt8]) { + switch value { + + + case .queued: + writeInt(&buf, Int32(1)) + + + case .waiting: + writeInt(&buf, Int32(2)) + + + case .inProgress: + writeInt(&buf, Int32(3)) + + + case .succeeded: + writeInt(&buf, Int32(4)) + + + case .failed: + writeInt(&buf, Int32(5)) + + + case .cancelled: + writeInt(&buf, Int32(6)) + + + case .skipped: + writeInt(&buf, Int32(7)) + + + case .unknown: + writeInt(&buf, Int32(8)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActionState_lift(_ buf: RustBuffer) throws -> ActionState { + return try FfiConverterTypeActionState.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeActionState_lower(_ value: ActionState) -> RustBuffer { + return FfiConverterTypeActionState.lower(value) +} + + + + public enum ActivityIcon: Equatable, Hashable { case repository @@ -4557,6 +5290,100 @@ public func FfiConverterTypeNotificationStatus_lower(_ value: NotificationStatus +public enum PrimaryDestination: Equatable, Hashable { + + case issues + case repositories + case pullRequests + case milestones + case actions + case serverActivity + + + + + +} + +#if compiler(>=6) +extension PrimaryDestination: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePrimaryDestination: FfiConverterRustBuffer { + typealias SwiftType = PrimaryDestination + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PrimaryDestination { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .issues + + case 2: return .repositories + + case 3: return .pullRequests + + case 4: return .milestones + + case 5: return .actions + + case 6: return .serverActivity + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: PrimaryDestination, into buf: inout [UInt8]) { + switch value { + + + case .issues: + writeInt(&buf, Int32(1)) + + + case .repositories: + writeInt(&buf, Int32(2)) + + + case .pullRequests: + writeInt(&buf, Int32(3)) + + + case .milestones: + writeInt(&buf, Int32(4)) + + + case .actions: + writeInt(&buf, Int32(5)) + + + case .serverActivity: + writeInt(&buf, Int32(6)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePrimaryDestination_lift(_ buf: RustBuffer) throws -> PrimaryDestination { + return try FfiConverterTypePrimaryDestination.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePrimaryDestination_lower(_ value: PrimaryDestination) -> RustBuffer { + return FfiConverterTypePrimaryDestination.lower(value) +} + + + + public enum RepositoryContentKind: Equatable, Hashable { case directory @@ -4701,6 +5528,7 @@ public enum RepositoryPane: Equatable, Hashable { case issues case commits case milestones + case actions @@ -4728,6 +5556,8 @@ public struct FfiConverterTypeRepositoryPane: FfiConverterRustBuffer { case 3: return .milestones + case 4: return .actions + default: throw UniffiInternalError.unexpectedEnumCase } } @@ -4747,6 +5577,10 @@ public struct FfiConverterTypeRepositoryPane: FfiConverterRustBuffer { case .milestones: writeInt(&buf, Int32(3)) + + case .actions: + writeInt(&buf, Int32(4)) + } } } @@ -5053,6 +5887,131 @@ fileprivate struct FfiConverterSequenceString: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeActionJobRow: FfiConverterRustBuffer { + typealias SwiftType = [ActionJobRow] + + public static func write(_ value: [ActionJobRow], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeActionJobRow.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [ActionJobRow] { + let len: Int32 = try readInt(&buf) + var seq = [ActionJobRow]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeActionJobRow.read(from: &buf)) + } + return seq + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeActionLogGroupRow: FfiConverterRustBuffer { + typealias SwiftType = [ActionLogGroupRow] + + public static func write(_ value: [ActionLogGroupRow], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeActionLogGroupRow.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [ActionLogGroupRow] { + let len: Int32 = try readInt(&buf) + var seq = [ActionLogGroupRow]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeActionLogGroupRow.read(from: &buf)) + } + return seq + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeActionRunRow: FfiConverterRustBuffer { + typealias SwiftType = [ActionRunRow] + + public static func write(_ value: [ActionRunRow], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeActionRunRow.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [ActionRunRow] { + let len: Int32 = try readInt(&buf) + var seq = [ActionRunRow]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeActionRunRow.read(from: &buf)) + } + return seq + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeActionStepRow: FfiConverterRustBuffer { + typealias SwiftType = [ActionStepRow] + + public static func write(_ value: [ActionStepRow], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeActionStepRow.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [ActionStepRow] { + let len: Int32 = try readInt(&buf) + var seq = [ActionStepRow]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeActionStepRow.read(from: &buf)) + } + return seq + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeActionWorkflowRow: FfiConverterRustBuffer { + typealias SwiftType = [ActionWorkflowRow] + + public static func write(_ value: [ActionWorkflowRow], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeActionWorkflowRow.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [ActionWorkflowRow] { + let len: Int32 = try readInt(&buf) + var seq = [ActionWorkflowRow]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeActionWorkflowRow.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -5477,6 +6436,31 @@ fileprivate struct FfiConverterSequenceTypeServerRow: FfiConverterRustBuffer { return seq } } + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypePrimaryDestination: FfiConverterRustBuffer { + typealias SwiftType = [PrimaryDestination] + + public static func write(_ value: [PrimaryDestination], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypePrimaryDestination.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [PrimaryDestination] { + let len: Int32 = try readInt(&buf) + var seq = [PrimaryDestination]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypePrimaryDestination.read(from: &buf)) + } + return seq + } +} private let UNIFFI_RUST_FUTURE_POLL_READY: Int8 = 0 private let UNIFFI_RUST_FUTURE_POLL_WAKE: Int8 = 1 @@ -5541,6 +6525,21 @@ private let initializationResult: InitializationResult = { if bindings_contract_version != scaffolding_contract_version { return InitializationResult.contractVersionMismatch } + if (uniffi_gotcha_core_checksum_method_gotchacore_action_job_log() != 40890) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_gotcha_core_checksum_method_gotchacore_action_run() != 64762) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_gotcha_core_checksum_method_gotchacore_action_runs() != 9900) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_gotcha_core_checksum_method_gotchacore_action_workflows() != 591) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_gotcha_core_checksum_method_gotchacore_dispatch_action_workflow() != 53262) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_gotcha_core_checksum_method_gotchacore_commit_details() != 61777) { return InitializationResult.apiChecksumMismatch } @@ -5679,6 +6678,9 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_set_notifications_enabled() != 13019) { return InitializationResult.apiChecksumMismatch } + if (uniffi_gotcha_core_checksum_method_gotchacore_set_primary_destinations() != 18300) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_gotcha_core_checksum_method_gotchacore_set_pull_status() != 40421) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/Generated/gotcha_coreFFI.h b/ios/Generated/gotcha_coreFFI.h index 99fd06d..f99719c 100644 --- a/ios/Generated/gotcha_coreFFI.h +++ b/ios/Generated/gotcha_coreFFI.h @@ -258,6 +258,31 @@ void uniffi_gotcha_core_fn_free_gotchacore(uint64_t handle, RustCallStatus *_Non uint64_t uniffi_gotcha_core_fn_constructor_gotchacore_new(RustBuffer storage_directory, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTION_JOB_LOG +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTION_JOB_LOG +uint64_t uniffi_gotcha_core_fn_method_gotchacore_action_job_log(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t run, int64_t job +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTION_RUN +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTION_RUN +uint64_t uniffi_gotcha_core_fn_method_gotchacore_action_run(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t run +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTION_RUNS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTION_RUNS +uint64_t uniffi_gotcha_core_fn_method_gotchacore_action_runs(uint64_t ptr, RustBuffer owner, RustBuffer repository, uint32_t page +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTION_WORKFLOWS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ACTION_WORKFLOWS +uint64_t uniffi_gotcha_core_fn_method_gotchacore_action_workflows(uint64_t ptr, RustBuffer owner, RustBuffer repository +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DISPATCH_ACTION_WORKFLOW +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DISPATCH_ACTION_WORKFLOW +uint64_t uniffi_gotcha_core_fn_method_gotchacore_dispatch_action_workflow(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer workflow, RustBuffer reference, RustBuffer inputs +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMIT_DETAILS #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMIT_DETAILS uint64_t uniffi_gotcha_core_fn_method_gotchacore_commit_details(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer sha, RustBuffer branch @@ -488,6 +513,11 @@ void uniffi_gotcha_core_fn_method_gotchacore_set_issue_status(uint64_t ptr, Rust void uniffi_gotcha_core_fn_method_gotchacore_set_notifications_enabled(uint64_t ptr, int8_t enabled, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PRIMARY_DESTINATIONS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PRIMARY_DESTINATIONS +void uniffi_gotcha_core_fn_method_gotchacore_set_primary_destinations(uint64_t ptr, RustBuffer destinations, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_STATUS #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_STATUS void uniffi_gotcha_core_fn_method_gotchacore_set_pull_status(uint64_t ptr, RustBuffer status, RustCallStatus *_Nonnull out_status @@ -776,6 +806,36 @@ void ffi_gotcha_core_rust_future_free_void(uint64_t handle #ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_VOID #define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_VOID void ffi_gotcha_core_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTION_JOB_LOG +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTION_JOB_LOG +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_action_job_log(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTION_RUN +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTION_RUN +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_action_run(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTION_RUNS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTION_RUNS +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_action_runs(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTION_WORKFLOWS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ACTION_WORKFLOWS +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_action_workflows(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DISPATCH_ACTION_WORKFLOW +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DISPATCH_ACTION_WORKFLOW +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_dispatch_action_workflow(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMIT_DETAILS @@ -1052,6 +1112,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_status(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_NOTIFICATIONS_ENABLED uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_notifications_enabled(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PRIMARY_DESTINATIONS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PRIMARY_DESTINATIONS +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_primary_destinations(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_STATUS diff --git a/ios/Gotcha.xcodeproj/project.pbxproj b/ios/Gotcha.xcodeproj/project.pbxproj index 60d6d18..7c51937 100644 --- a/ios/Gotcha.xcodeproj/project.pbxproj +++ b/ios/Gotcha.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 4652515AE4CB10963D995143 /* CommitScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */; }; 6DAA1D3230197F537D70735E /* NotificationsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE88A28F99C6D0224865E04D /* NotificationsScreen.swift */; }; 74626C144E9214BF89F821D2 /* WidgetIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AE1D594C38D2BDAA2E86AD /* WidgetIntents.swift */; }; + 77576F6C61D65F12BB64A2E9 /* ActionsScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB8F58445A6BDB7FDE8B9CA /* ActionsScreens.swift */; }; 7A511844F3CBA0603A894DDE /* NotificationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */; }; 7A9D1ADD6623C89A7D5E016F /* GotchaWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 52ABD1B07CEEF00263038653 /* GotchaWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 7BBE64F66374221F1743BC24 /* IssueScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */; }; @@ -31,6 +32,7 @@ A2AF2C1F3C8B0716E1EF36B2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */; }; BDFE5D7A54541A109B35BF45 /* gotcha_core.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE12480725C13293FAFDDA52 /* gotcha_core.swift */; }; C33BA07C5F7DA72CBF72CEAE /* MilestoneScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64B529D84926EEEDC163A929 /* MilestoneScreens.swift */; }; + C4FA15BB48499D3EED99522E /* NavigationSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A45865CA6E36C30A9EBC75C /* NavigationSettingsViewController.swift */; }; CD50D82AC4B92576A2ADE163 /* CommitDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81A55AAC9DB99FEB79F56DAF /* CommitDetailScreen.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 */; }; @@ -77,6 +79,7 @@ 52ABD1B07CEEF00263038653 /* GotchaWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = GotchaWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 5FB3250A93766966A60A685E /* Gotcha.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Gotcha.app; sourceTree = BUILT_PRODUCTS_DIR; }; 64B529D84926EEEDC163A929 /* MilestoneScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MilestoneScreens.swift; sourceTree = ""; }; + 6A45865CA6E36C30A9EBC75C /* NavigationSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationSettingsViewController.swift; sourceTree = ""; }; 722405F999984C7CBE838711 /* IssueEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueEditorViewController.swift; sourceTree = ""; }; 7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitScreens.swift; sourceTree = ""; }; 77AE1D594C38D2BDAA2E86AD /* WidgetIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetIntents.swift; sourceTree = ""; }; @@ -85,6 +88,7 @@ 8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentEditorViewController.swift; sourceTree = ""; }; 99B8279189276A084B69D7D0 /* PullScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullScreens.swift; sourceTree = ""; }; 9C0921C76676A14A024BA417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + ABB8F58445A6BDB7FDE8B9CA /* ActionsScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionsScreens.swift; sourceTree = ""; }; ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueScreens.swift; sourceTree = ""; }; BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCoordinator.swift; sourceTree = ""; }; C028DA45E7CB9E1CD56754AC /* ServerActivityScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityScreen.swift; sourceTree = ""; }; @@ -124,6 +128,7 @@ 710A50F51478401FC642E6E3 /* Sources */ = { isa = PBXGroup; children = ( + ABB8F58445A6BDB7FDE8B9CA /* ActionsScreens.swift */, F61515849F6AACD721FE915C /* AppContext.swift */, 9C0921C76676A14A024BA417 /* AppDelegate.swift */, 8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */, @@ -136,6 +141,7 @@ ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */, CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */, 64B529D84926EEEDC163A929 /* MilestoneScreens.swift */, + 6A45865CA6E36C30A9EBC75C /* NavigationSettingsViewController.swift */, BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */, DE88A28F99C6D0224865E04D /* NotificationsScreen.swift */, 99B8279189276A084B69D7D0 /* PullScreens.swift */, @@ -348,6 +354,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 77576F6C61D65F12BB64A2E9 /* ActionsScreens.swift in Sources */, EC7F8B4703DDE37A0B10CD9B /* AppContext.swift in Sources */, 950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */, 381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */, @@ -360,6 +367,7 @@ 7BBE64F66374221F1743BC24 /* IssueScreens.swift in Sources */, 1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.swift in Sources */, C33BA07C5F7DA72CBF72CEAE /* MilestoneScreens.swift in Sources */, + C4FA15BB48499D3EED99522E /* NavigationSettingsViewController.swift in Sources */, 7A511844F3CBA0603A894DDE /* NotificationCoordinator.swift in Sources */, 6DAA1D3230197F537D70735E /* NotificationsScreen.swift in Sources */, 33D3E65C9E50522B2039816A /* PullScreens.swift in Sources */, diff --git a/ios/Sources/ActionsScreens.swift b/ios/Sources/ActionsScreens.swift new file mode 100644 index 0000000..4fc7473 --- /dev/null +++ b/ios/Sources/ActionsScreens.swift @@ -0,0 +1,661 @@ +import UIKit + +@MainActor +final class ActionsViewController: RefreshingTableViewController { + private enum Mode: Int { case workflows, runs } + + private let context: AppContext + private let owner: String + private let repository: String + private let defaultBranch: String + private let modeControl = UISegmentedControl(items: ["Workflows", "Runs"]) + private var mode = Mode.workflows + private var workflows: [ActionWorkflowRow] = [] + private var runs: [ActionRunRow] = [] + private var currentPage: UInt32 = 0 + private var autoRefreshTask: Task? + private var loading = false + private var requestGeneration = 0 + + init(context: AppContext, owner: String, repository: String, defaultBranch: String) { + self.context = context + self.owner = owner + self.repository = repository + self.defaultBranch = defaultBranch + super.init() + title = "Actions" + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + deinit { autoRefreshTask?.cancel() } + + override func viewDidLoad() { + super.viewDidLoad() + modeControl.selectedSegmentIndex = mode.rawValue + modeControl.addTarget(self, action: #selector(modeChanged), for: .valueChanged) + navigationItem.titleView = modeControl + loadContent(refreshing: false) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + startAutoRefresh() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + autoRefreshTask?.cancel() + } + + override func loadContent(refreshing: Bool) { + loadPage(1, refreshing: refreshing) + } + + override func loadMoreContent() { + guard mode == .runs else { return } + loadPage(currentPage + 1, refreshing: false) + } + + @objc private func modeChanged() { + guard let mode = Mode(rawValue: modeControl.selectedSegmentIndex) else { return } + self.mode = mode + loadingTask?.cancel() + loading = false + loadContent(refreshing: false) + } + + private func loadPage(_ page: UInt32, refreshing: Bool) { + guard !loading else { + refreshControl?.endRefreshing() + return + } + loading = true + requestGeneration += 1 + let generation = requestGeneration + let requestedMode = mode + if page == 1 { + resetPagination() + beginLoading(refreshing: refreshing) + } + loadingTask?.cancel() + loadingTask = Task { + defer { + if requestGeneration == generation { + loading = false + if page == 1 { endLoading() } + } + } + do { + switch requestedMode { + case .workflows: + let result = try await context.core.actionWorkflows( + owner: owner, + repository: repository + ) + guard !Task.isCancelled, mode == requestedMode else { return } + workflows = result + currentPage = 1 + finishPagination(hasMore: false) + case .runs: + let result = try await context.core.actionRuns( + owner: owner, + repository: repository, + page: page + ) + guard !Task.isCancelled, mode == requestedMode else { return } + runs = page == 1 ? result.rows : runs + result.rows + currentPage = page + finishPagination(hasMore: result.hasMore) + } + tableView.reloadData() + updateEmptyState() + } catch { + if !Task.isCancelled { + show(error: error) + failPagination() + } + } + } + } + + private func startAutoRefresh() { + autoRefreshTask?.cancel() + autoRefreshTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(5)) + guard let self, self.mode == .runs, !self.loading else { continue } + self.loadPage(1, refreshing: false) + } + } + } + + private func updateEmptyState() { + let empty = mode == .workflows ? workflows.isEmpty : runs.isEmpty + tableView.backgroundView = empty + ? EmptyBackgroundView( + title: mode == .workflows ? "No workflows" : "No workflow runs", + detail: mode == .workflows + ? "This repository has no Actions workflows." + : "Dispatch a workflow to create the first run." + ) + : nil + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + mode == .workflows ? workflows.count : runs.count + } + + override func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "action") + ?? UITableViewCell(style: .subtitle, reuseIdentifier: "action") + if mode == .workflows { + let row = workflows[indexPath.row] + cell.accessoryView = nil + configureTextCell( + cell, + title: row.name, + detail: "\(row.state) · \(row.path)", + image: context.symbol("play.square") + ) + cell.accessoryType = .disclosureIndicator + } else { + cell.accessoryType = .none + let row = runs[indexPath.row] + configureActionCell(cell, row: row, context: context) + } + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + if mode == .workflows { + let workflow = workflows[indexPath.row] + navigationController?.pushViewController( + WorkflowDispatchViewController( + context: context, + owner: owner, + repository: repository, + workflow: workflow, + defaultBranch: defaultBranch + ), + animated: true + ) + } else { + navigationController?.pushViewController( + ActionRunViewController( + context: context, + owner: owner, + repository: repository, + run: runs[indexPath.row] + ), + animated: true + ) + } + } +} + +@MainActor +final class WorkflowDispatchViewController: UITableViewController, UITextViewDelegate { + private let context: AppContext + private let owner: String + private let repository: String + private let workflow: ActionWorkflowRow + private let referenceField = UITextField() + private let inputsView = UITextView() + private lazy var runButton = UIBarButtonItem( + title: "Run Workflow", + primaryAction: UIAction { [weak self] _ in self?.dispatch() } + ) + private var dispatchTask: Task? + + init( + context: AppContext, + owner: String, + repository: String, + workflow: ActionWorkflowRow, + defaultBranch: String + ) { + self.context = context + self.owner = owner + self.repository = repository + self.workflow = workflow + referenceField.text = defaultBranch + super.init(style: .insetGrouped) + title = "Dispatch" + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + deinit { dispatchTask?.cancel() } + + override func viewDidLoad() { + super.viewDidLoad() + referenceField.placeholder = "Branch or tag" + referenceField.autocapitalizationType = .none + referenceField.autocorrectionType = .no + referenceField.clearButtonMode = .whileEditing + referenceField.addTarget(self, action: #selector(referenceChanged), for: .editingChanged) + inputsView.font = .preferredFont(forTextStyle: .body) + inputsView.adjustsFontForContentSizeCategory = true + inputsView.autocapitalizationType = .none + inputsView.autocorrectionType = .no + inputsView.accessibilityLabel = "Workflow dispatch inputs" + navigationItem.rightBarButtonItem = runButton + referenceChanged() + } + + override func numberOfSections(in tableView: UITableView) -> Int { 3 } + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + [workflow.name, "Git Reference", "Inputs"][section] + } + + override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + section == 2 ? "Enter one workflow_dispatch input per line as KEY=VALUE. Leave blank when the workflow has no inputs." : nil + } + + override func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + if indexPath.section == 0 { + var content = cell.defaultContentConfiguration() + content.text = workflow.path + content.secondaryText = workflow.state + cell.contentConfiguration = content + cell.selectionStyle = .none + } else { + let view = indexPath.section == 1 ? referenceField : inputsView + view.translatesAutoresizingMaskIntoConstraints = false + cell.contentView.addSubview(view) + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 16), + view.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -16), + view.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 8), + view.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -8), + ]) + cell.selectionStyle = .none + } + return cell + } + + override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + indexPath.section == 2 ? 140 : UITableView.automaticDimension + } + + private func dispatch() { + navigationItem.rightBarButtonItem?.isEnabled = false + dispatchTask?.cancel() + dispatchTask = Task { + do { + try await context.core.dispatchActionWorkflow( + owner: owner, + repository: repository, + workflow: workflow.id, + reference: referenceField.text ?? "", + inputs: [inputsView.text] + ) + navigationController?.popViewController(animated: true) + } catch { + if !Task.isCancelled { show(error: error) } + navigationItem.rightBarButtonItem?.isEnabled = true + } + } + } + + @objc private func referenceChanged() { + runButton.isEnabled = !(referenceField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} + +@MainActor +final class ActionRunViewController: RefreshingTableViewController { + private let context: AppContext + private let owner: String + private let repository: String + private let runID: Int64 + private var page: ActionRunPage? + private var autoRefreshTask: Task? + private var loading = false + + init(context: AppContext, owner: String, repository: String, run: ActionRunRow) { + self.context = context + self.owner = owner + self.repository = repository + runID = run.id + super.init() + title = "Run #\(run.number)" + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + deinit { autoRefreshTask?.cancel() } + + override func viewDidLoad() { + super.viewDidLoad() + loadContent(refreshing: false) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + autoRefreshTask?.cancel() + autoRefreshTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(5)) + guard let self, !self.loading else { continue } + self.loadContent(refreshing: false) + } + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + autoRefreshTask?.cancel() + } + + override func loadContent(refreshing: Bool) { + guard !loading else { + refreshControl?.endRefreshing() + return + } + loading = true + beginLoading(refreshing: refreshing) + loadingTask?.cancel() + loadingTask = Task { + defer { + loading = false + endLoading() + } + do { + page = try await context.core.actionRun( + owner: owner, + repository: repository, + run: runID + ) + tableView.reloadData() + tableView.backgroundView = page?.jobs.isEmpty == true + ? EmptyBackgroundView( + title: "No jobs yet", + detail: "Pull to refresh while the run is queued." + ) + : nil + } catch { + if !Task.isCancelled { show(error: error) } + } + } + } + + override func numberOfSections(in tableView: UITableView) -> Int { page == nil ? 0 : 2 } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + section == 0 ? 1 : page?.jobs.count ?? 0 + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + section == 0 ? "Run" : "Jobs" + } + + override func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "actionDetail") + ?? UITableViewCell(style: .subtitle, reuseIdentifier: "actionDetail") + if indexPath.section == 0 { + let run = page!.run + configureTextCell( + cell, + title: run.title, + detail: "\(actionStateLabel(run.state)) · \(run.event) · \(run.branch)\n\(run.meta)", + image: context.symbol(actionStateSymbolName(run.state)) + ) + tintActionIcon(in: cell, state: run.state) + cell.selectionStyle = .none + cell.accessoryType = .none + } else { + let job = page!.jobs[indexPath.row] + configureTextCell( + cell, + title: job.name, + detail: job.meta, + image: context.symbol(actionStateSymbolName(job.state)) + ) + tintActionIcon(in: cell, state: job.state) + cell.selectionStyle = .default + cell.accessoryType = .disclosureIndicator + } + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard indexPath.section == 1 else { return } + tableView.deselectRow(at: indexPath, animated: true) + let job = page!.jobs[indexPath.row] + navigationController?.pushViewController( + ActionJobViewController( + context: context, + owner: owner, + repository: repository, + runID: runID, + job: job + ), + animated: true + ) + } +} + +@MainActor +final class ActionJobViewController: RefreshingTableViewController { + private let context: AppContext + private let owner: String + private let repository: String + private let runID: Int64 + private let job: ActionJobRow + private var page: ActionJobLogPage? + + init( + context: AppContext, + owner: String, + repository: String, + runID: Int64, + job: ActionJobRow + ) { + self.context = context + self.owner = owner + self.repository = repository + self.runID = runID + self.job = job + super.init() + title = job.name + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override func viewDidLoad() { + super.viewDidLoad() + loadContent(refreshing: false) + } + + override func loadContent(refreshing: Bool) { + beginLoading(refreshing: refreshing) + loadingTask?.cancel() + loadingTask = Task { + defer { endLoading() } + do { + page = try await context.core.actionJobLog( + owner: owner, + repository: repository, + run: runID, + job: job.id + ) + tableView.reloadData() + tableView.backgroundView = page?.groups.isEmpty == true + ? EmptyBackgroundView( + title: "No log groups", + detail: "This job has no log output yet. Pull to refresh." + ) + : nil + } catch { + if !Task.isCancelled { show(error: error) } + } + } + } + + override func numberOfSections(in tableView: UITableView) -> Int { page == nil ? 0 : 2 } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + section == 0 ? 1 : page?.groups.count ?? 0 + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + section == 0 ? "Job" : "Tasks" + } + + override func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "jobDetail") + ?? UITableViewCell(style: .subtitle, reuseIdentifier: "jobDetail") + if indexPath.section == 0 { + configureTextCell( + cell, + title: page!.job.name, + detail: page!.job.meta, + image: context.symbol(actionStateSymbolName(page!.job.state)) + ) + tintActionIcon(in: cell, state: page!.job.state) + cell.selectionStyle = .none + cell.accessoryType = .none + } else { + let group = page!.groups[indexPath.row] + configureTextCell( + cell, + title: group.name, + detail: group.duration.isEmpty + ? actionStateLabel(group.state) + : "\(actionStateLabel(group.state)) · \(group.duration)", + image: context.symbol(actionStateSymbolName(group.state)) + ) + tintActionIcon(in: cell, state: group.state) + cell.selectionStyle = .default + cell.accessoryType = .disclosureIndicator + } + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard indexPath.section == 1 else { return } + tableView.deselectRow(at: indexPath, animated: true) + let group = page!.groups[indexPath.row] + navigationController?.pushViewController( + ActionLogTextViewController(title: group.name, text: group.text), + animated: true + ) + } +} + +@MainActor +final class ActionLogTextViewController: UIViewController { + private let logTitle: String + private let text: String + + init(title: String, text: String) { + logTitle = title + self.text = text + super.init(nibName: nil, bundle: nil) + self.title = title + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + let textView = UITextView() + textView.isEditable = false + textView.font = UIFontMetrics(forTextStyle: .body).scaledFont( + for: .monospacedSystemFont(ofSize: 15, weight: .regular) + ) + textView.adjustsFontForContentSizeCategory = true + textView.text = text + textView.accessibilityLabel = "\(logTitle) log" + textView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(textView) + NSLayoutConstraint.activate([ + textView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + textView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + textView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + textView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } +} + +@MainActor +private func configureActionCell(_ cell: UITableViewCell, row: ActionRunRow, context: AppContext) { + configureTextCell( + cell, + title: "#\(row.number) \(row.title)", + detail: "\(actionStateLabel(row.state)) · \(row.event) · \(row.branch)\n\(row.meta)", + image: context.symbol(actionStateSymbolName(row.state)) + ) + tintActionIcon(in: cell, state: row.state) + cell.accessoryView = nil + cell.accessoryType = .disclosureIndicator +} + +private func tintActionIcon(in cell: UITableViewCell, state: ActionState) { + guard var content = cell.contentConfiguration as? UIListContentConfiguration else { return } + content.imageProperties.tintColor = actionStateColor(state) + cell.contentConfiguration = content +} + +private func actionStateColor(_ state: ActionState) -> UIColor { + switch state { + case .succeeded: return .systemGreen + case .failed: return .systemRed + case .cancelled: return .systemGray + case .skipped: return .tertiaryLabel + case .queued: return .systemOrange + case .waiting: return .systemOrange + case .inProgress: return .systemBlue + case .unknown: return .secondaryLabel + } +} + +private func actionStateLabel(_ state: ActionState) -> String { + switch state { + case .queued: return "Queued" + case .waiting: return "Waiting" + case .inProgress: return "In Progress" + case .succeeded: return "Succeeded" + case .failed: return "Failed" + case .cancelled: return "Cancelled" + case .skipped: return "Skipped" + case .unknown: return "Unknown" + } +} + +private func actionStateSymbolName(_ state: ActionState) -> String { + switch state { + case .succeeded: return "checkmark.circle.fill" + case .failed: return "xmark.circle.fill" + case .cancelled: return "slash.circle.fill" + case .skipped: return "minus.circle.fill" + case .queued: return "clock.fill" + case .waiting: return "hourglass.circle.fill" + case .inProgress: return "circle.fill" + case .unknown: return "questionmark.circle.fill" + } +} diff --git a/ios/Sources/AppContext.swift b/ios/Sources/AppContext.swift index 1595fea..228c9c5 100644 --- a/ios/Sources/AppContext.swift +++ b/ios/Sources/AppContext.swift @@ -19,21 +19,20 @@ final class AppContext { } func makeRootController() -> UIViewController { - let roots: [UIViewController] = [ - HomeViewController(context: self), - repositoryRoot(mode: .issues), - repositoryRoot(mode: .commits), - PullsViewController(context: self), - repositoryRoot(mode: .milestones), - ] - let items = [ - ("Home", "house", "house.fill"), - ("Issues", "exclamationmark.circle", "exclamationmark.circle.fill"), - ("Repos", "books.vertical", "books.vertical.fill"), - ("PRs", "arrow.triangle.pull", "arrow.triangle.pull"), - ("Milestones", "flag", "flag.fill"), - ] - navigationControllers = zip(roots, items).map { root, item in + rebuildTabs(preservingHomeStack: false) + return tabs + } + + func applyPrimaryDestinations() { + rebuildTabs(preservingHomeStack: true) + } + + private func rebuildTabs(preservingHomeStack: Bool) { + let destinations = core.settings().primaryDestinations + let home = preservingHomeStack ? navigationControllers.first : nil + let roots = destinations.map(destinationController) + let items = [("Home", "house", "house.fill")] + destinations.map(destinationItem) + let destinationNavigations = zip(roots, items.dropFirst()).map { root, item in let navigation = UINavigationController(rootViewController: root) navigation.tabBarItem = UITabBarItem( title: item.0, @@ -42,8 +41,17 @@ final class AppContext { ) return navigation } + let homeNavigation = home ?? UINavigationController( + rootViewController: HomeViewController(context: self) + ) + homeNavigation.tabBarItem = UITabBarItem( + title: items[0].0, + image: UIImage(systemName: items[0].1), + selectedImage: UIImage(systemName: items[0].2) + ) + navigationControllers = [homeNavigation] + destinationNavigations tabs.viewControllers = navigationControllers - return tabs + (homeNavigation.viewControllers.first as? HomeViewController)?.refreshPrimaryDestinations() } func showStartupErrorIfNeeded() { @@ -57,33 +65,50 @@ final class AppContext { } func reloadAfterServerChange() { - let replacements: [(Int, UIViewController)] = [ - (0, HomeViewController(context: self)), - (1, repositoryRoot(mode: .issues)), - (2, repositoryRoot(mode: .commits)), - (3, PullsViewController(context: self)), - (4, repositoryRoot(mode: .milestones)), - ] - for (index, root) in replacements { - navigationControllers[index].setViewControllers([root], animated: false) - } + rebuildTabs(preservingHomeStack: false) WidgetCenter.shared.reloadAllTimelines() } @discardableResult func route(widgetURL: URL) -> Bool { guard widgetURL.scheme == "gotcha" else { return false } - let index: Int switch widgetURL.host { - case "home": index = 0 - case "pulls": index = 3 + case "home": + tabs.selectedIndex = 0 + navigationControllers[0].popToRootViewController(animated: false) + case "pulls": + if let index = primaryDestinations.firstIndex(of: .pullRequests).map({ $0 + 1 }) { + tabs.selectedIndex = index + navigationControllers[index].popToRootViewController(animated: false) + } else { + tabs.selectedIndex = 0 + navigationControllers[0].pushViewController( + PullsViewController(context: self), + animated: false + ) + } default: return false } - tabs.selectedIndex = index - navigationControllers[index].popToRootViewController(animated: false) return true } + var primaryDestinations: [PrimaryDestination] { + core.settings().primaryDestinations + } + + var secondaryDestinations: [PrimaryDestination] { + PrimaryDestination.allCases.filter { !primaryDestinations.contains($0) } + } + + func show(_ destination: PrimaryDestination, from navigation: UINavigationController?) { + if let index = primaryDestinations.firstIndex(of: destination).map({ $0 + 1 }) { + tabs.selectedIndex = index + navigationControllers[index].popToRootViewController(animated: false) + } else { + navigation?.pushViewController(destinationController(destination), animated: true) + } + } + func didAddServer(index: UInt32) throws { try selectServer(index: index) } @@ -154,7 +179,7 @@ final class AppContext { do { try selectServer(index: UInt32(index)) } catch { - tabs.present(errorAlert(error.localizedDescription), animated: true) + tabs.present(errorAlert(errorMessage(error)), animated: true) return } } @@ -216,4 +241,54 @@ final class AppContext { } return RepositoriesViewController(context: self, mode: mode) } + + private func destinationController(_ destination: PrimaryDestination) -> UIViewController { + switch destination { + case .issues: return repositoryRoot(mode: .issues) + case .repositories: return repositoryRoot(mode: .commits) + case .pullRequests: return PullsViewController(context: self) + case .milestones: return repositoryRoot(mode: .milestones) + case .actions: return repositoryRoot(mode: .actions) + case .serverActivity: return ServerActivityViewController(context: self) + } + } + + private func destinationItem(_ destination: PrimaryDestination) -> (String, String, String) { + switch destination { + case .issues: return ("Issues", "exclamationmark.circle", "exclamationmark.circle.fill") + case .repositories: return ("Repos", "books.vertical", "books.vertical.fill") + case .pullRequests: return ("PRs", "arrow.triangle.pull", "arrow.triangle.pull") + case .milestones: return ("Milestones", "flag", "flag.fill") + case .actions: return ("Actions", "play.square.stack", "play.square.stack.fill") + case .serverActivity: return ("Activity", "person.3", "person.3.fill") + } + } +} + +extension PrimaryDestination: CaseIterable { + public static var allCases: [PrimaryDestination] { + [.issues, .repositories, .pullRequests, .milestones, .actions, .serverActivity] + } + + var title: String { + switch self { + case .issues: return "Issues" + case .repositories: return "Repositories" + case .pullRequests: return "Pull Requests" + case .milestones: return "Milestones" + case .actions: return "Actions" + case .serverActivity: return "Server Activity" + } + } + + var symbolName: String { + switch self { + case .issues: return "exclamationmark.circle" + case .repositories: return "books.vertical" + case .pullRequests: return "arrow.triangle.pull" + case .milestones: return "flag" + case .actions: return "play.square.stack" + case .serverActivity: return "person.3" + } + } } diff --git a/ios/Sources/HomeScreen.swift b/ios/Sources/HomeScreen.swift index 9b3ce3a..64039b5 100644 --- a/ios/Sources/HomeScreen.swift +++ b/ios/Sources/HomeScreen.swift @@ -2,25 +2,37 @@ import UIKit @MainActor final class HomeViewController: RefreshingTableViewController { - private enum ActivityFilter: Int { - case all - case issues - case pulls - - var coreValue: HomeActivityFilter { - switch self { - case .all: return .all - case .issues: return .issues - case .pulls: return .pullRequests - } - } + private enum Timeline: Int { + case user + case allUsers + case notifications } private let context: AppContext private var page: HomePage? - private var filter = ActivityFilter.all - private var nextPage: UInt32? - private var activities: [ActivityRow] { page?.activities ?? [] } + private var timeline = Timeline.user + private var userNextPage: UInt32? + private var serverActivities: [ActivityRow] = [] + private var serverNextPage: UInt32? + private let notificationStatusControl = UISegmentedControl(items: ["Open", "Closed"]) + private lazy var notificationHeaderView: UIView = { + let header = UIView() + notificationStatusControl.translatesAutoresizingMaskIntoConstraints = false + header.addSubview(notificationStatusControl) + NSLayoutConstraint.activate([ + notificationStatusControl.centerXAnchor.constraint(equalTo: header.centerXAnchor), + notificationStatusControl.centerYAnchor.constraint(equalTo: header.centerYAnchor), + ]) + return header + }() + private var notificationStatus = NotificationStatus.open + private var notificationRows: [NotificationRow] = [] + private var notificationPage: UInt32 = 0 + private var notificationsHaveMore = false + + private var activities: [ActivityRow] { + timeline == .allUsers ? serverActivities : page?.activities ?? [] + } init(context: AppContext) { self.context = context @@ -33,6 +45,14 @@ final class HomeViewController: RefreshingTableViewController { override func viewDidLoad() { super.viewDidLoad() + tableView.register(NotificationCell.self, forCellReuseIdentifier: "notification") + notificationStatusControl.selectedSegmentIndex = 0 + notificationStatusControl.accessibilityLabel = "Notification status" + notificationStatusControl.addTarget( + self, + action: #selector(notificationStatusChanged), + for: .valueChanged + ) let servers = UIBarButtonItem( image: context.symbol("server.rack"), primaryAction: UIAction { [weak self] _ in @@ -61,8 +81,11 @@ final class HomeViewController: RefreshingTableViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - guard page == nil else { return } - loadContent(refreshing: false) + if page == nil { + loadContent(refreshing: false) + } else if timeline == .notifications { + loadNotifications(page: 1, refreshing: false) + } } override func loadContent(refreshing: Bool) { @@ -74,15 +97,31 @@ final class HomeViewController: RefreshingTableViewController { refreshControl?.endRefreshing() return } - loadPage(1, refreshing: refreshing) + switch timeline { + case .user: + loadUserPage(1, refreshing: refreshing) + case .allUsers: + loadServerActivityPage(1, refreshing: refreshing) + case .notifications: + loadNotifications(page: 1, refreshing: refreshing) + } } override func loadMoreContent() { - guard let nextPage else { return } - loadPage(nextPage, refreshing: false) + switch timeline { + case .user: + guard let userNextPage else { return } + loadUserPage(userNextPage, refreshing: false) + case .allUsers: + guard let serverNextPage else { return } + loadServerActivityPage(serverNextPage, refreshing: false) + case .notifications: + guard notificationsHaveMore else { return } + loadNotifications(page: notificationPage + 1, refreshing: false) + } } - private func loadPage(_ requestedPage: UInt32, refreshing: Bool) { + private func loadUserPage(_ requestedPage: UInt32, refreshing: Bool) { if requestedPage == 1 { resetPagination() beginLoading(refreshing: refreshing) @@ -92,62 +131,106 @@ final class HomeViewController: RefreshingTableViewController { do { let result = try await context.core.home( page: requestedPage, - filter: filter.coreValue + filter: .all ) + guard !Task.isCancelled, timeline == .user else { return } if requestedPage == 1 { page = result } else { page?.activities.append(contentsOf: result.activities) page?.nextPage = result.nextPage } - nextPage = result.nextPage + userNextPage = result.nextPage finishPagination(hasMore: result.nextPage != nil) - title = page?.serverName - if requestedPage == 1 { tableView.tableHeaderView = page.map { page in - HeatmapView( - page: page, - selectedFilter: filter.rawValue, - onFilter: { [weak self] index in - guard let self, let filter = ActivityFilter(rawValue: index) else { return } - guard filter != self.filter else { return } - self.filter = filter - self.loadPage(1, refreshing: false) - }, - onNotifications: { [weak self] in - guard let self else { return } - self.navigationController?.pushViewController( - NotificationsViewController(context: self.context), - animated: true - ) - }, - onServerActivity: { [weak self] in - guard let self else { return } - self.navigationController?.pushViewController( - ServerActivityViewController(context: self.context), - animated: true - ) - } - ) - } } - updateActivities() + setPanelTitle(self, "Home", server: page?.serverName) + if requestedPage == 1 { refreshPrimaryDestinations() } + updateRows() } catch { - if !Task.isCancelled { + if !Task.isCancelled, timeline == .user { show(error: error) failPagination() } } - if requestedPage == 1 { endLoading() } + if requestedPage == 1, timeline == .user { endLoading() } + } + } + + private func loadServerActivityPage(_ requestedPage: UInt32, refreshing: Bool) { + if requestedPage == 1 { + resetPagination() + beginLoading(refreshing: refreshing) + } + loadingTask?.cancel() + loadingTask = Task { + do { + let result = try await context.core.serverActivity(page: requestedPage) + guard !Task.isCancelled, timeline == .allUsers else { return } + if requestedPage == 1 { + serverActivities = result.rows + } else { + serverActivities.append(contentsOf: result.rows) + } + serverNextPage = result.hasMore ? requestedPage + 1 : nil + finishPagination(hasMore: result.hasMore) + updateRows() + } catch { + if !Task.isCancelled, timeline == .allUsers { + show(error: error) + failPagination() + } + } + if requestedPage == 1, timeline == .allUsers { endLoading() } + } + } + + private func loadNotifications(page requestedPage: UInt32, refreshing: Bool) { + if requestedPage == 1 { + resetPagination() + beginLoading(refreshing: refreshing) + } + loadingTask?.cancel() + loadingTask = Task { + do { + let result = try await context.core.notifications( + status: notificationStatus, + page: requestedPage + ) + guard !Task.isCancelled, timeline == .notifications else { return } + if requestedPage == 1 { + notificationRows = result.rows + } else { + notificationRows.append(contentsOf: result.rows) + } + notificationPage = requestedPage + notificationsHaveMore = result.hasMore + finishPagination(hasMore: result.hasMore) + updateRows() + } catch { + if !Task.isCancelled, timeline == .notifications { + show(error: error) + failPagination() + } + } + if requestedPage == 1, timeline == .notifications { endLoading() } } } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - activities.count + timeline == .notifications ? notificationRows.count : activities.count } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { + if timeline == .notifications { + let cell = tableView.dequeueReusableCell( + withIdentifier: "notification", + for: indexPath + ) as! NotificationCell + cell.configure(notificationRows[indexPath.row]) + return cell + } let cell = tableView.dequeueReusableCell(withIdentifier: "activity") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "activity") let row = activities[indexPath.row] @@ -162,22 +245,98 @@ final class HomeViewController: RefreshingTableViewController { } override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - 92 + timeline == .notifications ? UITableView.automaticDimension : 92 + } + + override func tableView( + _ tableView: UITableView, + viewForHeaderInSection section: Int + ) -> UIView? { + guard timeline == .notifications else { return nil } + return notificationHeaderView + } + + override func tableView( + _ tableView: UITableView, + heightForHeaderInSection section: Int + ) -> CGFloat { + timeline == .notifications ? 44 : .leastNormalMagnitude } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - context.route(activities[indexPath.row]) + if timeline == .notifications { + openNotification(notificationRows[indexPath.row]) + } else { + context.route(activities[indexPath.row]) + } } - private func updateActivities() { + private func updateRows() { tableView.reloadData() - tableView.backgroundView = activities.isEmpty - ? EmptyBackgroundView( - title: "No matching activity", - detail: "This server has no recent activity of the selected type." + let isEmpty = timeline == .notifications ? notificationRows.isEmpty : activities.isEmpty + guard isEmpty else { + tableView.backgroundView = nil + return + } + let message: (String, String) = switch timeline { + case .user: + ("No activity", "This user has no recent activity.") + case .allUsers: + ("No server activity", "No activity is visible to this server account.") + case .notifications: + notificationStatus == .open + ? ("No open notifications", "This server has no open notifications.") + : ("No closed notifications", "This server has no closed notifications.") + } + tableView.backgroundView = EmptyBackgroundView(title: message.0, detail: message.1) + } + + private func selectTimeline(_ rawValue: Int) { + guard let selected = Timeline(rawValue: rawValue), selected != timeline else { return } + timeline = selected + setPanelTitle(self, "Home", server: page?.serverName) + refreshPrimaryDestinations() + tableView.reloadData() + loadContent(refreshing: false) + } + + @objc private func notificationStatusChanged() { + notificationStatus = notificationStatusControl.selectedSegmentIndex == 0 ? .open : .closed + loadNotifications(page: 1, refreshing: false) + } + + private func openNotification(_ row: NotificationRow) { + guard row.target != .none else { return } + guard row.unread else { + context.route(row) + return + } + loadingTask?.cancel() + loadingTask = Task { + do { + try await context.core.markNotificationRead(serverId: row.serverId, id: row.id) + guard !Task.isCancelled else { return } + context.route(row) + } catch { + if !Task.isCancelled { show(error: error) } + } + } + } + + func refreshPrimaryDestinations() { + tableView.tableHeaderView = page.map { page in + HeatmapView( + page: page, + selectedTimeline: timeline.rawValue, + destinations: context.secondaryDestinations.filter { $0 != .serverActivity }, + onTimeline: { [weak self] timeline in self?.selectTimeline(timeline) }, + onDestination: { [weak self] destination in + guard let self else { return } + self.context.show(destination, from: self.navigationController) + } ) - : nil + } } } @@ -197,13 +356,14 @@ func activitySymbolName(for icon: ActivityIcon) -> String { final class HeatmapView: UIView { private let cells: [HeatCell] private let calendar = Calendar(identifier: .gregorian) + private let controlsScroller = UIScrollView() init( page: HomePage, - selectedFilter: Int, - onFilter: @escaping (Int) -> Void, - onNotifications: @escaping () -> Void, - onServerActivity: @escaping () -> Void + selectedTimeline: Int, + destinations: [PrimaryDestination], + onTimeline: @escaping (Int) -> Void, + onDestination: @escaping (PrimaryDestination) -> Void ) { cells = page.heatCells super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 180)) @@ -218,16 +378,14 @@ final class HeatmapView: UIView { total.font = .preferredFont(forTextStyle: .caption1) total.textColor = .tertiaryLabel addSubview(total) - let filters = UIStackView() - filters.axis = .horizontal - filters.spacing = 4 - filters.translatesAutoresizingMaskIntoConstraints = false - let filterItems = [ - ("clock", "clock.fill", "All activity"), - ("exclamationmark.circle", "exclamationmark.circle.fill", "Issues"), - ("arrow.triangle.pull", "arrow.triangle.pull", "Pull requests"), + let timelinesView = UIStackView() + timelinesView.axis = .horizontal + let timelineItems = [ + ("person.crop.circle", "person.crop.circle.fill", "Your activity"), + ("person.3", "person.3.fill", "All users activity"), + ("bell", "bell.fill", "Notifications"), ] - for (index, item) in filterItems.enumerated() { + for (index, item) in timelineItems.enumerated() { let button = UIButton(type: .custom, primaryAction: UIAction { action in guard let button = action.sender as? UIButton, @@ -238,47 +396,77 @@ final class HeatmapView: UIView { item.tintColor = item.isSelected ? .tintColor : .secondaryLabel item.accessibilityTraits = item.isSelected ? [.button, .selected] : .button } - onFilter(button.tag) + onTimeline(button.tag) }) button.tag = index button.setImage(UIImage(systemName: item.0), for: .normal) button.setImage(UIImage(systemName: item.1), for: .selected) - button.isSelected = index == selectedFilter + button.isSelected = index == selectedTimeline button.tintColor = button.isSelected ? .tintColor : .secondaryLabel button.accessibilityLabel = item.2 button.accessibilityTraits = button.isSelected ? [.button, .selected] : .button button.widthAnchor.constraint(equalToConstant: 44).isActive = true - filters.addArrangedSubview(button) + timelinesView.addArrangedSubview(button) } - let serverActivity = UIButton( - type: .custom, - primaryAction: UIAction { _ in onServerActivity() } - ) - serverActivity.setImage(UIImage(systemName: "person.3"), for: .normal) - serverActivity.tintColor = .secondaryLabel - serverActivity.accessibilityLabel = "Server Activity" - serverActivity.widthAnchor.constraint(equalToConstant: 44).isActive = true - filters.addArrangedSubview(serverActivity) - let notifications = UIButton( - type: .custom, - primaryAction: UIAction { _ in onNotifications() } - ) - notifications.setImage(UIImage(systemName: "bell"), for: .normal) - notifications.tintColor = .secondaryLabel - notifications.accessibilityLabel = "Notifications" - notifications.widthAnchor.constraint(equalToConstant: 44).isActive = true - filters.addArrangedSubview(notifications) - addSubview(filters) + + let destinationsView = UIStackView() + destinationsView.axis = .horizontal + for destination in destinations { + let button = UIButton( + type: .custom, + primaryAction: UIAction { _ in onDestination(destination) } + ) + let symbol = destination == .milestones ? "flag.fill" : destination.symbolName + button.setImage(UIImage(systemName: symbol), for: .normal) + button.tintColor = .secondaryLabel + button.accessibilityLabel = destination.title + button.accessibilityHint = "Opens from Home" + button.widthAnchor.constraint(equalToConstant: 44).isActive = true + destinationsView.addArrangedSubview(button) + } + + let controls = UIStackView(arrangedSubviews: [timelinesView]) + controls.axis = .horizontal + controls.alignment = .center + controls.translatesAutoresizingMaskIntoConstraints = false + if !destinations.isEmpty { + controls.addArrangedSubview(destinationsView) + } + controlsScroller.showsHorizontalScrollIndicator = false + controlsScroller.translatesAutoresizingMaskIntoConstraints = false + controlsScroller.addSubview(controls) + addSubview(controlsScroller) NSLayoutConstraint.activate([ - filters.centerXAnchor.constraint(equalTo: centerXAnchor), - filters.topAnchor.constraint(equalTo: topAnchor, constant: 132), - filters.heightAnchor.constraint(equalToConstant: 44), + controlsScroller.leadingAnchor.constraint(equalTo: leadingAnchor), + controlsScroller.trailingAnchor.constraint(equalTo: trailingAnchor), + controlsScroller.topAnchor.constraint(equalTo: topAnchor, constant: 132), + controlsScroller.heightAnchor.constraint(equalToConstant: 44), + controls.leadingAnchor.constraint( + equalTo: controlsScroller.contentLayoutGuide.leadingAnchor, + constant: 8 + ), + controls.trailingAnchor.constraint( + equalTo: controlsScroller.contentLayoutGuide.trailingAnchor, + constant: -8 + ), + controls.topAnchor.constraint(equalTo: controlsScroller.contentLayoutGuide.topAnchor), + controls.bottomAnchor.constraint( + equalTo: controlsScroller.contentLayoutGuide.bottomAnchor + ), + controls.heightAnchor.constraint(equalTo: controlsScroller.frameLayoutGuide.heightAnchor), ]) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + override func layoutSubviews() { + super.layoutSubviews() + let inset = max(0, (controlsScroller.bounds.width - controlsScroller.contentSize.width) / 2) + controlsScroller.contentInset.left = inset + controlsScroller.contentInset.right = inset + } + override func draw(_ rect: CGRect) { guard let first = cells.first, diff --git a/ios/Sources/NavigationSettingsViewController.swift b/ios/Sources/NavigationSettingsViewController.swift new file mode 100644 index 0000000..baf9288 --- /dev/null +++ b/ios/Sources/NavigationSettingsViewController.swift @@ -0,0 +1,128 @@ +import UIKit + +@MainActor +final class NavigationSettingsViewController: UITableViewController { + private let context: AppContext + private var selected: [PrimaryDestination] + + init(context: AppContext) { + self.context = context + selected = context.primaryDestinations + super.init(style: .insetGrouped) + title = "Primary Navigation" + navigationItem.rightBarButtonItem = editButtonItem + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + private var available: [PrimaryDestination] { + PrimaryDestination.allCases.filter { !selected.contains($0) } + } + + override func numberOfSections(in tableView: UITableView) -> Int { 2 } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + section == 0 ? selected.count : available.count + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + section == 0 ? "Shown After Home" : "Available on Home" + } + + override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + section == 0 + ? "Choose up to four destinations. Tap Edit to reorder them. Home is always first." + : "Destinations not in the tab bar remain available as buttons on Home." + } + + override func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let destination = indexPath.section == 0 ? selected[indexPath.row] : available[indexPath.row] + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + var content = cell.defaultContentConfiguration() + content.text = destination.title + content.image = UIImage(systemName: destination.symbolName) + content.imageProperties.tintColor = .tintColor + if indexPath.section == 1 && selected.count == 4 { + content.textProperties.color = .secondaryLabel + content.imageProperties.tintColor = .tertiaryLabel + cell.selectionStyle = .none + } else if indexPath.section == 1 { + let add = UIImageView(image: UIImage(systemName: "plus.circle.fill")) + add.tintColor = .tintColor + cell.accessoryView = add + cell.accessibilityHint = "Adds this destination to the tab bar" + } + cell.contentConfiguration = content + return cell + } + + override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { + indexPath.section == 0 + } + + override func tableView( + _ tableView: UITableView, + moveRowAt sourceIndexPath: IndexPath, + to destinationIndexPath: IndexPath + ) { + guard sourceIndexPath.section == 0, destinationIndexPath.section == 0 else { + tableView.reloadData() + return + } + let destination = selected.remove(at: sourceIndexPath.row) + selected.insert(destination, at: destinationIndexPath.row) + save() + } + + override func tableView( + _ tableView: UITableView, + targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, + toProposedIndexPath proposedDestinationIndexPath: IndexPath + ) -> IndexPath { + guard proposedDestinationIndexPath.section == 0 else { + return IndexPath(row: max(selected.count - 1, 0), section: 0) + } + return proposedDestinationIndexPath + } + + override func tableView( + _ tableView: UITableView, + editingStyleForRowAt indexPath: IndexPath + ) -> UITableViewCell.EditingStyle { + indexPath.section == 0 ? .delete : .none + } + + override func tableView( + _ tableView: UITableView, + commit editingStyle: UITableViewCell.EditingStyle, + forRowAt indexPath: IndexPath + ) { + guard editingStyle == .delete, indexPath.section == 0 else { return } + selected.remove(at: indexPath.row) + save() + tableView.reloadData() + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard indexPath.section == 1, selected.count < 4 else { return } + selected.append(available[indexPath.row]) + save() + tableView.reloadData() + } + + private func save() { + do { + try context.core.setPrimaryDestinations(destinations: selected) + context.applyPrimaryDestinations() + } catch { + selected = context.primaryDestinations + tableView.reloadData() + show(error: error) + } + } +} diff --git a/ios/Sources/NotificationsScreen.swift b/ios/Sources/NotificationsScreen.swift index b2b44ad..8d132ba 100644 --- a/ios/Sources/NotificationsScreen.swift +++ b/ios/Sources/NotificationsScreen.swift @@ -72,7 +72,7 @@ final class NotificationsViewController: RefreshingTableViewController { init(context: AppContext) { self.context = context super.init() - title = "Notifications" + setPanelTitle(self, "Notifications", server: context.core.activeServerName()) } @available(*, unavailable) diff --git a/ios/Sources/PullScreens.swift b/ios/Sources/PullScreens.swift index 6f05562..2628e68 100644 --- a/ios/Sources/PullScreens.swift +++ b/ios/Sources/PullScreens.swift @@ -62,7 +62,7 @@ final class PullsViewController: RefreshingTableViewController { init(context: AppContext) { self.context = context super.init() - title = "Pull Requests" + setPanelTitle(self, "Pull Requests", server: context.core.activeServerName()) } @available(*, unavailable) @@ -75,20 +75,28 @@ final class PullsViewController: RefreshingTableViewController { tableView.register(PullCell.self, forCellReuseIdentifier: "pull") tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 116 - navigationItem.leftBarButtonItem = UIBarButtonItem( - image: context.symbol("server.rack"), - primaryAction: UIAction { [weak self] _ in - guard let self else { return } - self.navigationController?.pushViewController( - ServersViewController(context: self.context), - animated: true - ) - } - ) updateFilterMenu() loadContent(refreshing: false) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + if navigationController?.viewControllers.first === self { + navigationItem.leftBarButtonItem = UIBarButtonItem( + image: context.symbol("server.rack"), + primaryAction: UIAction { [weak self] _ in + guard let self else { return } + self.navigationController?.pushViewController( + ServersViewController(context: self.context), + animated: true + ) + } + ) + } else { + navigationItem.leftBarButtonItem = nil + } + } + override func loadContent(refreshing: Bool) { guard context.core.activeServerIndex() != nil else { tableView.backgroundView = EmptyBackgroundView( diff --git a/ios/Sources/RepositoryListScreen.swift b/ios/Sources/RepositoryListScreen.swift index 6e4e8e4..ba8de75 100644 --- a/ios/Sources/RepositoryListScreen.swift +++ b/ios/Sources/RepositoryListScreen.swift @@ -7,11 +7,20 @@ final class RepositoriesViewController: RefreshingTableViewController { private var rows: [RepositoryRow] = [] private var currentPage: UInt32 = 0 + private var panelTitle: String { + switch mode { + case .issues: return "Issues" + case .commits: return "Repositories" + case .milestones: return "Milestones" + case .actions: return "Actions" + } + } + init(context: AppContext, mode: RepositoryPane) { self.context = context self.mode = mode super.init() - title = context.core.activeServerName() ?? "Repositories" + setPanelTitle(self, panelTitle, server: context.core.activeServerName()) } @available(*, unavailable) @@ -19,19 +28,27 @@ final class RepositoriesViewController: RefreshingTableViewController { override func viewDidLoad() { super.viewDidLoad() - navigationItem.leftBarButtonItem = UIBarButtonItem( - image: context.symbol("server.rack"), - primaryAction: UIAction { [weak self] _ in - guard let self else { return } - self.navigationController?.pushViewController( - ServersViewController(context: self.context), - animated: true - ) - } - ) loadContent(refreshing: false) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + if navigationController?.viewControllers.first === self { + navigationItem.leftBarButtonItem = UIBarButtonItem( + image: context.symbol("server.rack"), + primaryAction: UIAction { [weak self] _ in + guard let self else { return } + self.navigationController?.pushViewController( + ServersViewController(context: self.context), + animated: true + ) + } + ) + } else { + navigationItem.leftBarButtonItem = nil + } + } + override func loadContent(refreshing: Bool) { loadPage(1, refreshing: refreshing) } @@ -118,6 +135,13 @@ final class RepositoriesViewController: RefreshingTableViewController { owner: row.owner, repository: row.name ) + case .actions: + destination = ActionsViewController( + context: context, + owner: row.owner, + repository: row.name, + defaultBranch: row.defaultBranch + ) } navigationController?.pushViewController(destination, animated: true) } diff --git a/ios/Sources/ServerActivityScreen.swift b/ios/Sources/ServerActivityScreen.swift index 5eda52d..e7bd166 100644 --- a/ios/Sources/ServerActivityScreen.swift +++ b/ios/Sources/ServerActivityScreen.swift @@ -10,7 +10,7 @@ final class ServerActivityViewController: RefreshingTableViewController { init(context: AppContext) { self.context = context super.init() - title = "Server Activity" + setPanelTitle(self, "Server Activity", server: context.core.activeServerName()) } @available(*, unavailable) diff --git a/ios/Sources/SettingsViewController.swift b/ios/Sources/SettingsViewController.swift index 4262f7a..d9dadee 100644 --- a/ios/Sources/SettingsViewController.swift +++ b/ios/Sources/SettingsViewController.swift @@ -29,25 +29,29 @@ final class SettingsViewController: UITableViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) notificationSwitch.isOn = context.core.settings().notificationsEnabled + tableView.reloadSections(IndexSet(integer: 1), with: .none) Task { notificationStatus = await context.notifications.authorizationDescription() updateNotificationSettingsCell() } } - override func numberOfSections(in tableView: UITableView) -> Int { 2 } + override func numberOfSections(in tableView: UITableView) -> Int { 3 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - section == 0 ? 1 : 2 + section == 2 ? 2 : 1 } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - section == 0 ? "Appearance" : "Notifications" + ["Appearance", "Navigation", "Notifications"][section] } override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { if section == 0 { return "Follow iOS automatically or choose a fixed appearance." } + if section == 1 { + return "Home is always present. Choose and order up to four additional destinations." + } return "Gotcha checks periodically for server notifications. iOS decides when background refresh runs; delivery style, sounds, Focus, and summaries remain under your control in iOS Settings." } @@ -55,6 +59,15 @@ final class SettingsViewController: UITableViewController { _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { + if indexPath.section == 1 { + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + var content = cell.defaultContentConfiguration() + content.text = "Primary Destinations" + content.secondaryText = "\(context.primaryDestinations.count) selected" + cell.contentConfiguration = content + cell.accessoryType = .disclosureIndicator + return cell + } guard indexPath.section == 0 else { if indexPath.row == 0 { let cell = UITableViewCell(style: .default, reuseIdentifier: nil) @@ -87,8 +100,14 @@ final class SettingsViewController: UITableViewController { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - guard indexPath.section == 1, indexPath.row == 1 else { return } - context.notifications.openSystemSettings() + if indexPath.section == 1 { + navigationController?.pushViewController( + NavigationSettingsViewController(context: context), + animated: true + ) + } else if indexPath.section == 2, indexPath.row == 1 { + context.notifications.openSystemSettings() + } } @objc private func appearanceChanged() { @@ -119,7 +138,7 @@ final class SettingsViewController: UITableViewController { } private func updateNotificationSettingsCell() { - guard let cell = tableView.cellForRow(at: IndexPath(row: 1, section: 1)) else { return } + guard let cell = tableView.cellForRow(at: IndexPath(row: 1, section: 2)) else { return } var content = cell.defaultContentConfiguration() content.text = "Notification Settings" content.secondaryText = notificationStatus diff --git a/ios/Sources/Support.swift b/ios/Sources/Support.swift index e5e87a9..f4a1221 100644 --- a/ios/Sources/Support.swift +++ b/ios/Sources/Support.swift @@ -46,9 +46,14 @@ func errorAlert(_ message: String) -> UIAlertController { return alert } +func errorMessage(_ error: Error) -> String { + if case let GotchaError.Message(message) = error { return message } + return error.localizedDescription +} + extension UIViewController { func show(error: Error) { - present(errorAlert(error.localizedDescription), animated: true) + present(errorAlert(errorMessage(error)), animated: true) } func beginNavigationLoading(_ spinner: UIActivityIndicatorView) { @@ -392,3 +397,31 @@ func separator() -> UIView { line.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true return line } + +@MainActor +func setPanelTitle(_ viewController: UIViewController, _ title: String, server: String?) { + viewController.title = title + viewController.navigationItem.titleView = nil + if #available(iOS 26.0, *) { + viewController.navigationItem.subtitle = server.flatMap { $0.isEmpty ? nil : $0 } + return + } + guard let server, !server.isEmpty else { return } + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = .preferredFont(forTextStyle: .headline) + titleLabel.adjustsFontForContentSizeCategory = true + titleLabel.textAlignment = .center + let serverLabel = UILabel() + serverLabel.text = server + serverLabel.font = .preferredFont(forTextStyle: .caption1) + serverLabel.adjustsFontForContentSizeCategory = true + serverLabel.textColor = .secondaryLabel + serverLabel.textAlignment = .center + let labels = UIStackView(arrangedSubviews: [titleLabel, serverLabel]) + labels.axis = .vertical + labels.spacing = 0 + labels.alignment = .center + labels.accessibilityLabel = "\(title), \(server)" + viewController.navigationItem.titleView = labels +}