Add first-class Actions management (#63)

This commit is contained in:
Georg Bauer
2026-08-15 20:06:52 +02:00
parent f84ab774e6
commit 59bcddfadf
32 changed files with 4039 additions and 275 deletions

View File

@@ -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.

View File

@@ -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<Vec<models::ActionWorkflow>, 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<GiteaPage<models::ActionWorkflowRun>, 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<ActionRunDetails, String> {
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<ActionJobDetails, String> {
client(server)?
.action_job_details(&scope(owner, repository)?, run, job)
.await
.map_err(message)
}
use crate::{
domain::{
HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, IssueFilter,

View File

@@ -0,0 +1,75 @@
use crate::*;
#[uniffi::export(async_runtime = "tokio")]
impl GotchaCore {
pub async fn action_workflows(
&self,
owner: String,
repository: String,
) -> Result<Vec<ActionWorkflowRow>, 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<String>,
) -> 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<ActionRunListPage, GotchaError> {
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<ActionRunPage, GotchaError> {
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<ActionJobLogPage, GotchaError> {
let (owner, repository) = validate_repository(&owner, &repository)?;
Ok(action_job_log_page(
load_action_job_details(&self.server()?, owner, repository, run, job).await?,
))
}
}

View File

@@ -1,3 +1,4 @@
mod actions;
mod content;
mod issues;
mod milestones;

View File

@@ -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<PrimaryDestination>,
) -> 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());

View File

@@ -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<String, String>,
#[serde(default = "default_primary_destinations")]
pub primary_destinations: Vec<crate::PrimaryDestination>,
}
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<crate::PrimaryDestination> {
vec![
crate::PrimaryDestination::Issues,
crate::PrimaryDestination::Repositories,
crate::PrimaryDestination::PullRequests,
crate::PrimaryDestination::Milestones,
]
}
pub fn normalize_primary_destinations(destinations: &mut Vec<crate::PrimaryDestination>) -> 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,
]
);
}
}

View File

@@ -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<gotcha_gitea::Provider> 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<PrimaryDestination>,
}
#[derive(uniffi::Object)]

View File

@@ -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<ActionStepRow>,
}
#[derive(Clone, uniffi::Record)]
pub struct ActionRunListPage {
pub rows: Vec<ActionRunRow>,
pub has_more: bool,
}
#[derive(Clone, uniffi::Record)]
pub struct ActionRunPage {
pub run: ActionRunRow,
pub jobs: Vec<ActionJobRow>,
}
#[derive(Clone, uniffi::Record)]
pub struct ActionJobLogPage {
pub job: ActionJobRow,
pub text: String,
pub groups: Vec<ActionLogGroupRow>,
}
#[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<PullRow>,
}
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;

View File

@@ -0,0 +1,240 @@
use gotcha_gitea::{ActionJobDetails, ActionRunDetails, models};
use super::*;
pub fn action_workflow_rows(workflows: Vec<models::ActionWorkflow>) -> Vec<ActionWorkflowRow> {
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<ActionRunRow> {
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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.join(" · "),
}
})
.collect(),
}
}
fn duration_between(start: Option<&str>, end: Option<&str>) -> Option<String> {
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);
}
}

View File

@@ -18,6 +18,7 @@ pub fn repository_rows(
name: repository.name,
owner: repository.owner,
description: repository.description,
default_branch: repository.default_branch,
})
.collect()
}

View File

@@ -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<Preferences,
if !matches!(preferences.pull_status.as_str(), "open" | "closed") {
preferences.pull_status = open_status();
}
let navigation_migrated = normalize_primary_destinations(&mut preferences.primary_destinations);
let mut credentials_migrated = false;
for server in &mut preferences.servers {
if server.credential_account.is_empty() {
@@ -90,7 +91,7 @@ pub fn load_preferences(storage_directory: Option<&str>) -> Result<Preferences,
server.token = String::from_utf8(load_server_token(server)?)
.map_err(|_| format!("The token for {} is not valid text.", server.name))?;
}
if storage_migrated || favorites_migrated || credentials_migrated {
if storage_migrated || favorites_migrated || navigation_migrated || credentials_migrated {
save_preferences(&preferences)?;
}
Ok(preferences)
@@ -304,6 +305,7 @@ mod tests {
assert_eq!(
favorites,
[
"actions|https://gitea.example.com|octo/demo".to_string(),
"commits|https://gitea.example.com|octo/demo".to_string(),
"issues|https://gitea.example.com|octo/demo".to_string(),
"milestones|https://gitea.example.com|octo/demo".to_string(),

View File

@@ -1,8 +1,9 @@
use std::error::Error;
use gotcha_gitea::{
ActionJobLog, ActionRunDetails, Client,
ActionJobLog, ActionRunDetails, ActionRunQuery, Client,
models::{ActionWorkflow, ActionWorkflowJob, ActionWorkflowRun},
parse_action_inputs,
};
use crate::{
@@ -16,11 +17,14 @@ Usage: gotcha action workflow|run SUBCOMMAND
Workflow subcommands:
workflow list [OWNER/REPOSITORY]
List workflows
workflow dispatch WORKFLOW REF [OWNER/REPOSITORY]
workflow dispatch WORKFLOW REF [OWNER/REPOSITORY] [--input KEY=VALUE]…
Dispatch a workflow on a Git reference
Run subcommands:
run list [OWNER/REPOSITORY] List workflow runs
run list [OWNER/REPOSITORY] [--page N] [--limit N]
[--status STATUS] [--event EVENT] [--branch BRANCH]
[--actor LOGIN] [--sha SHA]
List workflow runs
run show ID [OWNER/REPOSITORY] Show a run and its jobs
run logs ID [OWNER/REPOSITORY] Print every job log for a run";
@@ -29,13 +33,14 @@ Usage: gotcha action workflow SUBCOMMAND
Subcommands:
list [OWNER/REPOSITORY]
dispatch WORKFLOW REF [OWNER/REPOSITORY]";
dispatch WORKFLOW REF [OWNER/REPOSITORY] [--input KEY=VALUE]…";
const RUN_HELP: &str = "\
Usage: gotcha action run SUBCOMMAND
Subcommands:
list [OWNER/REPOSITORY]
list [OWNER/REPOSITORY] [--page N] [--limit N] [--status STATUS]
[--event EVENT] [--branch BRANCH] [--actor LOGIN] [--sha SHA]
show ID [OWNER/REPOSITORY]
logs ID [OWNER/REPOSITORY]";
@@ -59,33 +64,24 @@ pub async fn run(
.await?,
);
}
[domain, group, action, workflow, reference]
if domain == "action" && group == "workflow" && action == "dispatch" =>
{
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<String, String>,
) -> Result<(), Box<dyn Error>> {
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<String, String>,
}
fn dispatch_arguments(
arguments: &[String],
selection: &Selection,
) -> Result<DispatchArguments, Box<dyn Error>> {
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<dyn Error>> {
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<i32, Box<dyn Error>> {
value
.parse::<i32>()
.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"));
}
}

View File

@@ -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<String>,
pub branch: Option<String>,
pub status: Option<String>,
pub actor: Option<String>,
pub head_sha: Option<String>,
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<ActionLogGroup>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ActionLogGroup {
pub name: String,
pub text: String,
pub duration_seconds: Option<u64>,
}
impl Client {
pub async fn action_workflows(
&self,
@@ -35,18 +76,28 @@ impl Client {
repository: &RepositoryId,
workflow: &str,
reference: &str,
inputs: &BTreeMap<String, String>,
) -> 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::<HashMap<_, _>>()
});
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<Vec<models::ActionWorkflowRun>> {
query: &ActionRunQuery,
) -> Result<Page<models::ActionWorkflowRun>> {
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<Vec<ActionJobLog>> {
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<String> {
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<models::ActionWorkflowJob> {
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<models::ActionWorkflowRun> {
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<ActionJobDetails> {
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<Vec<models::ActionWorkflowJob>> {
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<BTreeMap<String, String>> {
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<ActionLogGroup> {
group_action_log_with_steps(log, &[])
}
pub fn group_action_log_with_steps(log: &str, steps: &[String]) -> Vec<ActionLogGroup> {
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<ActionLogGroup>, name: String, lines: Vec<String>) {
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::<Vec<_>>());
return;
}
groups.push(ActionLogGroup {
name,
text: lines.join("\n"),
duration_seconds,
});
}
}
fn action_workflow_step_names(yaml: &[u8], job: &str) -> Vec<String> {
let Ok(value) = serde_yaml::from_slice::<serde_yaml::Value>(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<u64> {
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<u64> {
crate::parse_api_timestamp(line).and_then(|value| value.try_into().ok())
}
fn action_run_id(run: i64) -> Result<i32> {
@@ -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::<usize>().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::<Vec<_>>(),
["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::<Vec<_>>(),
[
"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));
}
}

View File

@@ -197,6 +197,14 @@ pub fn parse_api_date(value: &str) -> Option<i64> {
(civil_from_days(days) == (year, month, day)).then_some(days * 86_400)
}
pub fn parse_api_timestamp(value: &str) -> Option<i64> {
let date = parse_api_date(value)?;
let hour = value.get(11..13)?.parse::<i64>().ok()?;
let minute = value.get(14..16)?.parse::<i64>().ok()?;
let second = value.get(17..19)?.parse::<i64>().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);
}
}

View File

@@ -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};

View File

@@ -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<Vec<u8>> {
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,

View File

@@ -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 "
)
);
}

View File

@@ -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 · 15 switch panes · s servers · , preferences",
"{contributions} contributions in the available heatmap range\nFilter: {:?}\n\n[v] cycle activity filter · 16 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<Screen, String> {
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<Screen, String> {
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<Screen, String> {
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::<Vec<_>>()
.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<Screen, String> {
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",
}
}

View File

@@ -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<Field>) -> Self {
let cursor = fields
.first()

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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 = "<group>"; };
6A45865CA6E36C30A9EBC75C /* NavigationSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationSettingsViewController.swift; sourceTree = "<group>"; };
722405F999984C7CBE838711 /* IssueEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueEditorViewController.swift; sourceTree = "<group>"; };
7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitScreens.swift; sourceTree = "<group>"; };
77AE1D594C38D2BDAA2E86AD /* WidgetIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetIntents.swift; sourceTree = "<group>"; };
@@ -85,6 +88,7 @@
8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentEditorViewController.swift; sourceTree = "<group>"; };
99B8279189276A084B69D7D0 /* PullScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullScreens.swift; sourceTree = "<group>"; };
9C0921C76676A14A024BA417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
ABB8F58445A6BDB7FDE8B9CA /* ActionsScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionsScreens.swift; sourceTree = "<group>"; };
ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueScreens.swift; sourceTree = "<group>"; };
BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCoordinator.swift; sourceTree = "<group>"; };
C028DA45E7CB9E1CD56754AC /* ServerActivityScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityScreen.swift; sourceTree = "<group>"; };
@@ -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 */,

View File

@@ -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<Void, Never>?
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<Void, Never>?
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<Void, Never>?
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"
}
}

View File

@@ -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"
}
}
}

View File

@@ -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,

View File

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

View File

@@ -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)

View File

@@ -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(

View File

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

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}