Add first-class Actions management (#63)
This commit is contained in:
104
TESTING.md
104
TESTING.md
@@ -44,7 +44,8 @@ Run `cargo run -p gotcha-tui -- --server NAME` in a normal terminal and in a
|
|||||||
Herdr pane. Use a non-production account with at least two server profiles and
|
Herdr pane. Use a non-production account with at least two server profiles and
|
||||||
representative repositories.
|
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
|
the selected pane and row remain visibly distinct at narrow and wide
|
||||||
terminal sizes.
|
terminal sizes.
|
||||||
- [ ] `j`/`k`, arrows, `g`/`G`, Page Up/Down, Enter, and Backspace provide
|
- [ ] `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.
|
added and the current user's comments edited.
|
||||||
- [ ] Milestones can be created, edited, closed/reopened, and deleted after
|
- [ ] Milestones can be created, edited, closed/reopened, and deleted after
|
||||||
confirmation; milestone details route to their issues and pulls.
|
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,
|
- [ ] Server profiles can be added, authenticated, selected, edited, renamed,
|
||||||
and deleted. Blank token on edit preserves the existing token; removing
|
and deleted. Blank token on edit preserves the existing token; removing
|
||||||
the last server leaves the server manager available.
|
the last server leaves the server manager available.
|
||||||
@@ -89,6 +98,17 @@ representative repositories.
|
|||||||
`target/release/gotcha-tui`, and the installed executable starts from
|
`target/release/gotcha-tui`, and the installed executable starts from
|
||||||
`PATH`.
|
`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:
|
Build and install the simulator app:
|
||||||
|
|
||||||
```sh
|
```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.
|
The native refresh control appears, reloads data, and disappears.
|
||||||
- [ ] On accounts/repositories with more than 30 results, Home activity,
|
- [ ] On accounts/repositories with more than 30 results, Home activity,
|
||||||
repositories, issues, pull requests, milestones, milestone contents,
|
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**
|
**Pull up or tap to load more**
|
||||||
footer. Pull upward past the bottom and verify the footer changes to a
|
footer. Pull upward past the bottom and verify the footer changes to a
|
||||||
native loading indicator, the next page appends without duplicates, and
|
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.
|
without clipping either edge.
|
||||||
- [ ] Activity rows have the correct icon, repository, summary, and date.
|
- [ ] Activity rows have the correct icon, repository, summary, and date.
|
||||||
- [ ] The compact icon row below the activity graph sits flat on the normal
|
- [ ] 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
|
background with no border or pill. **Your activity** is selected by
|
||||||
issue and pull-request icons in turn; each shows only matching recent
|
default with a filled accent-colored icon. Switch to **All users** and
|
||||||
activity or the native empty state, then the clock restores all rows.
|
**Notifications**; only the current timeline uses the accent color and
|
||||||
The people and bell icons remain separate, accessible Server Activity
|
selected accessibility trait, and returning to **Your activity** restores
|
||||||
and Notifications destinations.
|
the user's unfiltered feed. Rapid switching never replaces the selected
|
||||||
- [ ] Tap **Server Activity**. The pushed native list combines activity from
|
timeline with a cancelled result.
|
||||||
every user visible to the selected server account in newest-first order,
|
- [ ] The timeline mini-icons come first, followed immediately by every
|
||||||
identifies each actor, supports pull-to-refresh and pagination without
|
non-timeline destination omitted from the tab bar. Each destination opens
|
||||||
duplicates, and uses a native empty state when no activity is visible.
|
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;
|
Tap another user's repository, issue, pull request, and commit events;
|
||||||
each opens the corresponding native detail screen and Back returns to
|
each opens the corresponding native detail screen and Back returns to the
|
||||||
Server Activity.
|
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
|
- [ ] Pull-request activity includes older creation and close events beyond the
|
||||||
first activity-feed page without requiring a manual pull-up; the empty
|
first activity-feed page without requiring a manual pull-up; the empty
|
||||||
state appears only after every available page has been checked.
|
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.
|
Back button and left-edge interactive swipe return directly to Home.
|
||||||
- [ ] Non-linkable server activity does not navigate or appear tappable.
|
- [ ] 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
|
## Notifications
|
||||||
|
|
||||||
- [ ] Launch a fresh install. Gotcha does not request notification permission
|
- [ ] 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
|
the app switch persists across relaunches, and iOS Settings remains the
|
||||||
source of truth for alerts, sounds, Focus, and scheduled summaries.
|
source of truth for alerts, sounds, Focus, and scheduled summaries.
|
||||||
Turning the app switch off cancels pending alerts and background refresh.
|
Turning the app switch off cancels pending alerts and background refresh.
|
||||||
- [ ] Tap the Home bell. Open and Closed each show the matching server
|
- [ ] Select the Home **Notifications** timeline. The inline Open/Closed control
|
||||||
notification threads with a type icon, title, repository, date, Dynamic
|
shows matching server notification threads with a type icon, title,
|
||||||
Type layout, and accessible Open/Closed value. Pull to refresh, switch
|
repository, date, Dynamic Type layout, and accessible Open/Closed value.
|
||||||
status repeatedly, and load a list longer than one page without duplicate
|
Pull to refresh, switch status repeatedly, and load a list longer than one
|
||||||
or stale rows.
|
page without duplicate or stale rows.
|
||||||
- [ ] Tap open and closed issue, pull-request, commit, and repository
|
- [ ] Tap open and closed issue, pull-request, commit, and repository
|
||||||
notifications. Each opens the native destination on the Home navigation
|
notifications. Each opens the native destination on the Home navigation
|
||||||
stack; opening an unread thread marks it read, Back returns to the
|
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
|
- [ ] After the first poll establishes a cursor, create or receive another
|
||||||
server notification. Launch the Debug simulator build with
|
server notification. Launch the Debug simulator build with
|
||||||
`xcrun simctl launch booted de.rfc1437.gotcha --validate-background-notifications`,
|
`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, accessibility, and lifecycle
|
||||||
|
|
||||||
- [ ] Settings opens from the gear button on Home and is not a tab.
|
- [ ] 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
|
- [ ] Appearance changes between Auto, Light, and Dark immediately; Auto follows
|
||||||
the simulator system appearance.
|
the simulator system appearance.
|
||||||
- [ ] Icon and appearance settings remain selected after relaunch.
|
- [ ] Icon and appearance settings remain selected after relaunch.
|
||||||
|
|||||||
@@ -1,7 +1,82 @@
|
|||||||
use gotcha_gitea::{
|
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::{
|
use crate::{
|
||||||
domain::{
|
domain::{
|
||||||
HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, IssueFilter,
|
HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, IssueFilter,
|
||||||
|
|||||||
75
crates/app/src/core/actions.rs
Normal file
75
crates/app/src/core/actions.rs
Normal 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?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod actions;
|
||||||
mod content;
|
mod content;
|
||||||
mod issues;
|
mod issues;
|
||||||
mod milestones;
|
mod milestones;
|
||||||
|
|||||||
@@ -229,9 +229,25 @@ impl GotchaCore {
|
|||||||
pull_status: state.preferences.pull_status.clone(),
|
pull_status: state.preferences.pull_status.clone(),
|
||||||
appearance: state.preferences.appearance.index() as u32,
|
appearance: state.preferences.appearance.index() as u32,
|
||||||
notifications_enabled: state.preferences.notifications_enabled,
|
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> {
|
pub fn set_issue_status(&self, status: String) -> Result<(), GotchaError> {
|
||||||
if !matches!(status.as_str(), "open" | "closed") {
|
if !matches!(status.as_str(), "open" | "closed") {
|
||||||
return Err("Unsupported issue status.".into());
|
return Err("Unsupported issue status.".into());
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::{
|
|||||||
|
|
||||||
pub use gotcha_gitea::{
|
pub use gotcha_gitea::{
|
||||||
HistoryCommit, HomeData, IssueDetails, IssueEditorData, MilestoneDetails, Page, PullDetails,
|
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};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -74,6 +74,8 @@ pub struct Preferences {
|
|||||||
pub notifications_enabled: bool,
|
pub notifications_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub notification_cursors: BTreeMap<String, String>,
|
pub notification_cursors: BTreeMap<String, String>,
|
||||||
|
#[serde(default = "default_primary_destinations")]
|
||||||
|
pub primary_destinations: Vec<crate::PrimaryDestination>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Preferences {
|
impl Default for Preferences {
|
||||||
@@ -90,10 +92,32 @@ impl Default for Preferences {
|
|||||||
appearance: AppearanceMode::default(),
|
appearance: AppearanceMode::default(),
|
||||||
notifications_enabled: false,
|
notifications_enabled: false,
|
||||||
notification_cursors: BTreeMap::new(),
|
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)]
|
#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
pub struct IssueFilter {
|
pub struct IssueFilter {
|
||||||
#[serde(default)]
|
#[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,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
@@ -14,11 +15,23 @@ use storage::*;
|
|||||||
|
|
||||||
uniffi::setup_scaffolding!();
|
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)]
|
#[derive(Clone, Copy, Debug, uniffi::Enum)]
|
||||||
pub enum RepositoryPane {
|
pub enum RepositoryPane {
|
||||||
Issues,
|
Issues,
|
||||||
Commits,
|
Commits,
|
||||||
Milestones,
|
Milestones,
|
||||||
|
Actions,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, uniffi::Enum)]
|
#[derive(Clone, Copy, Debug, uniffi::Enum)]
|
||||||
@@ -46,13 +59,14 @@ impl From<gotcha_gitea::Provider> for ServerProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RepositoryPane {
|
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 {
|
const fn key(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Issues => "issues",
|
Self::Issues => "issues",
|
||||||
Self::Commits => "commits",
|
Self::Commits => "commits",
|
||||||
Self::Milestones => "milestones",
|
Self::Milestones => "milestones",
|
||||||
|
Self::Actions => "actions",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +95,7 @@ pub struct Settings {
|
|||||||
pub pull_status: String,
|
pub pull_status: String,
|
||||||
pub appearance: u32,
|
pub appearance: u32,
|
||||||
pub notifications_enabled: bool,
|
pub notifications_enabled: bool,
|
||||||
|
pub primary_destinations: Vec<PrimaryDestination>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(uniffi::Object)]
|
#[derive(uniffi::Object)]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use gotcha_gitea::{
|
|||||||
use crate::domain::{
|
use crate::domain::{
|
||||||
HistoryCommit, HomeData, IssueDetails, IssueEditorData, IssueFilter, MilestoneDetails,
|
HistoryCommit, HomeData, IssueDetails, IssueEditorData, IssueFilter, MilestoneDetails,
|
||||||
PullDetails, PullFilter, RepositoryData, civil_from_days, days_from_civil, parse_api_date,
|
PullDetails, PullFilter, RepositoryData, civil_from_days, days_from_civil, parse_api_date,
|
||||||
|
parse_api_timestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
@@ -35,6 +36,84 @@ pub struct RepositoryRow {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
pub meta: String,
|
pub meta: String,
|
||||||
pub favorite: bool,
|
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)]
|
#[derive(Clone, uniffi::Record)]
|
||||||
@@ -404,6 +483,7 @@ pub struct WidgetPullPage {
|
|||||||
pub rows: Vec<PullRow>,
|
pub rows: Vec<PullRow>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod actions;
|
||||||
mod details;
|
mod details;
|
||||||
mod files;
|
mod files;
|
||||||
mod helpers;
|
mod helpers;
|
||||||
@@ -411,6 +491,7 @@ mod home;
|
|||||||
mod lists;
|
mod lists;
|
||||||
mod notifications;
|
mod notifications;
|
||||||
|
|
||||||
|
pub use actions::*;
|
||||||
pub use details::*;
|
pub use details::*;
|
||||||
pub use files::*;
|
pub use files::*;
|
||||||
pub use helpers::compact_date;
|
pub use helpers::compact_date;
|
||||||
|
|||||||
240
crates/app/src/presentation/actions.rs
Normal file
240
crates/app/src/presentation/actions.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ pub fn repository_rows(
|
|||||||
name: repository.name,
|
name: repository.name,
|
||||||
owner: repository.owner,
|
owner: repository.owner,
|
||||||
description: repository.description,
|
description: repository.description,
|
||||||
|
default_branch: repository.default_branch,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use security_framework::passwords::{
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
RepositoryPane,
|
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 {
|
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") {
|
if !matches!(preferences.pull_status.as_str(), "open" | "closed") {
|
||||||
preferences.pull_status = open_status();
|
preferences.pull_status = open_status();
|
||||||
}
|
}
|
||||||
|
let navigation_migrated = normalize_primary_destinations(&mut preferences.primary_destinations);
|
||||||
let mut credentials_migrated = false;
|
let mut credentials_migrated = false;
|
||||||
for server in &mut preferences.servers {
|
for server in &mut preferences.servers {
|
||||||
if server.credential_account.is_empty() {
|
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)?)
|
server.token = String::from_utf8(load_server_token(server)?)
|
||||||
.map_err(|_| format!("The token for {} is not valid text.", server.name))?;
|
.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)?;
|
save_preferences(&preferences)?;
|
||||||
}
|
}
|
||||||
Ok(preferences)
|
Ok(preferences)
|
||||||
@@ -304,6 +305,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
favorites,
|
favorites,
|
||||||
[
|
[
|
||||||
|
"actions|https://gitea.example.com|octo/demo".to_string(),
|
||||||
"commits|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(),
|
"issues|https://gitea.example.com|octo/demo".to_string(),
|
||||||
"milestones|https://gitea.example.com|octo/demo".to_string(),
|
"milestones|https://gitea.example.com|octo/demo".to_string(),
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
|
||||||
use gotcha_gitea::{
|
use gotcha_gitea::{
|
||||||
ActionJobLog, ActionRunDetails, Client,
|
ActionJobLog, ActionRunDetails, ActionRunQuery, Client,
|
||||||
models::{ActionWorkflow, ActionWorkflowJob, ActionWorkflowRun},
|
models::{ActionWorkflow, ActionWorkflowJob, ActionWorkflowRun},
|
||||||
|
parse_action_inputs,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -16,11 +17,14 @@ Usage: gotcha action workflow|run SUBCOMMAND
|
|||||||
Workflow subcommands:
|
Workflow subcommands:
|
||||||
workflow list [OWNER/REPOSITORY]
|
workflow list [OWNER/REPOSITORY]
|
||||||
List workflows
|
List workflows
|
||||||
workflow dispatch WORKFLOW REF [OWNER/REPOSITORY]
|
workflow dispatch WORKFLOW REF [OWNER/REPOSITORY] [--input KEY=VALUE]…
|
||||||
Dispatch a workflow on a Git reference
|
Dispatch a workflow on a Git reference
|
||||||
|
|
||||||
Run subcommands:
|
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 show ID [OWNER/REPOSITORY] Show a run and its jobs
|
||||||
run logs ID [OWNER/REPOSITORY] Print every job log for a run";
|
run logs ID [OWNER/REPOSITORY] Print every job log for a run";
|
||||||
|
|
||||||
@@ -29,13 +33,14 @@ Usage: gotcha action workflow SUBCOMMAND
|
|||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
list [OWNER/REPOSITORY]
|
list [OWNER/REPOSITORY]
|
||||||
dispatch WORKFLOW REF [OWNER/REPOSITORY]";
|
dispatch WORKFLOW REF [OWNER/REPOSITORY] [--input KEY=VALUE]…";
|
||||||
|
|
||||||
const RUN_HELP: &str = "\
|
const RUN_HELP: &str = "\
|
||||||
Usage: gotcha action run SUBCOMMAND
|
Usage: gotcha action run SUBCOMMAND
|
||||||
|
|
||||||
Subcommands:
|
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]
|
show ID [OWNER/REPOSITORY]
|
||||||
logs ID [OWNER/REPOSITORY]";
|
logs ID [OWNER/REPOSITORY]";
|
||||||
|
|
||||||
@@ -59,33 +64,24 @@ pub async fn run(
|
|||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
[domain, group, action, workflow, reference]
|
[domain, group, action, arguments @ ..]
|
||||||
if domain == "action" && group == "workflow" && action == "dispatch" =>
|
|
||||||
{
|
|
||||||
dispatch(client, &scope(selection, None)?, workflow, reference).await?;
|
|
||||||
}
|
|
||||||
[domain, group, action, workflow, reference, repository]
|
|
||||||
if domain == "action" && group == "workflow" && action == "dispatch" =>
|
if domain == "action" && group == "workflow" && action == "dispatch" =>
|
||||||
{
|
{
|
||||||
|
let arguments = dispatch_arguments(arguments, selection)?;
|
||||||
dispatch(
|
dispatch(
|
||||||
client,
|
client,
|
||||||
&scope(selection, Some(repository))?,
|
&arguments.repository,
|
||||||
workflow,
|
&arguments.workflow,
|
||||||
reference,
|
&arguments.reference,
|
||||||
|
&arguments.inputs,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
[domain, group, action] if domain == "action" && group == "run" && action == "list" => {
|
[domain, group, action, arguments @ ..]
|
||||||
print_runs(&client.action_runs(&scope(selection, None)?).await?);
|
|
||||||
}
|
|
||||||
[domain, group, action, repository]
|
|
||||||
if domain == "action" && group == "run" && action == "list" =>
|
if domain == "action" && group == "run" && action == "list" =>
|
||||||
{
|
{
|
||||||
print_runs(
|
let (repository, query) = run_list_arguments(arguments, selection)?;
|
||||||
&client
|
print_runs(&client.action_runs(&repository, &query).await?.items);
|
||||||
.action_runs(&scope(selection, Some(repository))?)
|
|
||||||
.await?,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
[domain, group, action, id] if domain == "action" && group == "run" && action == "show" => {
|
[domain, group, action, id] if domain == "action" && group == "run" && action == "show" => {
|
||||||
print_run(
|
print_run(
|
||||||
@@ -161,14 +157,96 @@ async fn dispatch(
|
|||||||
repository: &RepositoryScope,
|
repository: &RepositoryScope,
|
||||||
workflow: &str,
|
workflow: &str,
|
||||||
reference: &str,
|
reference: &str,
|
||||||
|
inputs: &std::collections::BTreeMap<String, String>,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
client
|
client
|
||||||
.dispatch_action_workflow(repository, workflow, reference)
|
.dispatch_action_workflow(repository, workflow, reference, inputs)
|
||||||
.await?;
|
.await?;
|
||||||
println!("Dispatched workflow {workflow} on {reference}.");
|
println!("Dispatched workflow {workflow} on {reference}.");
|
||||||
Ok(())
|
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]) {
|
fn print_workflows(workflows: &[ActionWorkflow]) {
|
||||||
print_table(
|
print_table(
|
||||||
&[("ID", 40), ("STATE", 12), ("NAME", 50), ("PATH", 70)],
|
&[("ID", 40), ("STATE", 12), ("NAME", 50), ("PATH", 70)],
|
||||||
@@ -299,4 +377,39 @@ mod tests {
|
|||||||
.contains("logs ID")
|
.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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,35 @@
|
|||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
|
||||||
use reqwest::header::ACCEPT;
|
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;
|
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)]
|
#[derive(Debug)]
|
||||||
pub struct ActionRunDetails {
|
pub struct ActionRunDetails {
|
||||||
pub run: models::ActionWorkflowRun,
|
pub run: models::ActionWorkflowRun,
|
||||||
@@ -15,6 +42,20 @@ pub struct ActionJobLog {
|
|||||||
pub text: String,
|
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 {
|
impl Client {
|
||||||
pub async fn action_workflows(
|
pub async fn action_workflows(
|
||||||
&self,
|
&self,
|
||||||
@@ -35,18 +76,28 @@ impl Client {
|
|||||||
repository: &RepositoryId,
|
repository: &RepositoryId,
|
||||||
workflow: &str,
|
workflow: &str,
|
||||||
reference: &str,
|
reference: &str,
|
||||||
|
inputs: &BTreeMap<String, String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
let workflow = workflow.trim();
|
||||||
|
let reference = reference.trim();
|
||||||
if workflow.is_empty() || reference.is_empty() {
|
if workflow.is_empty() || reference.is_empty() {
|
||||||
return Err(Error::InvalidInput(
|
return Err(Error::InvalidInput(
|
||||||
"workflow and reference must not be empty".into(),
|
"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(
|
apis::repository_api::actions_dispatch_workflow(
|
||||||
&self.configuration(),
|
&self.configuration(),
|
||||||
&repository.owner,
|
&repository.owner,
|
||||||
&repository.repository,
|
&repository.repository,
|
||||||
workflow,
|
workflow,
|
||||||
Some(models::CreateActionWorkflowDispatch::new(reference.into())),
|
Some(dispatch),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(Error::generated)
|
.map_err(Error::generated)
|
||||||
@@ -55,21 +106,23 @@ impl Client {
|
|||||||
pub async fn action_runs(
|
pub async fn action_runs(
|
||||||
&self,
|
&self,
|
||||||
repository: &RepositoryId,
|
repository: &RepositoryId,
|
||||||
) -> Result<Vec<models::ActionWorkflowRun>> {
|
query: &ActionRunQuery,
|
||||||
|
) -> Result<Page<models::ActionWorkflowRun>> {
|
||||||
|
validate_page(query.page, query.limit)?;
|
||||||
apis::repository_api::get_workflow_runs(
|
apis::repository_api::get_workflow_runs(
|
||||||
&self.configuration(),
|
&self.configuration(),
|
||||||
&repository.owner,
|
&repository.owner,
|
||||||
&repository.repository,
|
&repository.repository,
|
||||||
None,
|
query.event.as_deref(),
|
||||||
None,
|
query.branch.as_deref(),
|
||||||
None,
|
query.status.as_deref(),
|
||||||
None,
|
query.actor.as_deref(),
|
||||||
None,
|
query.head_sha.as_deref(),
|
||||||
Some(1),
|
Some(query.page),
|
||||||
Some(100),
|
Some(query.limit),
|
||||||
)
|
)
|
||||||
.await
|
.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)
|
.map_err(Error::generated)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,24 +144,9 @@ impl Client {
|
|||||||
.await
|
.await
|
||||||
.map_err(Error::generated)
|
.map_err(Error::generated)
|
||||||
},
|
},
|
||||||
async {
|
async { self.action_jobs(repository, run_id).await }
|
||||||
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)
|
|
||||||
}
|
|
||||||
)?;
|
)?;
|
||||||
Ok(ActionRunDetails {
|
Ok(ActionRunDetails { run, jobs })
|
||||||
run,
|
|
||||||
jobs: jobs.jobs.unwrap_or_default(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn action_run_logs(
|
pub async fn action_run_logs(
|
||||||
@@ -117,41 +155,274 @@ impl Client {
|
|||||||
run: i64,
|
run: i64,
|
||||||
) -> Result<Vec<ActionJobLog>> {
|
) -> Result<Vec<ActionJobLog>> {
|
||||||
let run = action_run_id(run)?;
|
let run = action_run_id(run)?;
|
||||||
let jobs = apis::repository_api::list_workflow_run_jobs(
|
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 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(),
|
&self.configuration(),
|
||||||
&repository.owner,
|
&repository.owner,
|
||||||
&repository.repository,
|
&repository.repository,
|
||||||
run,
|
run,
|
||||||
None,
|
None,
|
||||||
Some(1),
|
Some(page),
|
||||||
Some(100),
|
Some(100),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(Error::generated)?
|
.map_err(Error::generated)?
|
||||||
.jobs
|
.jobs
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let mut logs = Vec::with_capacity(jobs.len());
|
let complete = batch.len() < 100;
|
||||||
for job in jobs {
|
jobs.append(&mut batch);
|
||||||
let id = job
|
if complete {
|
||||||
.id
|
return Ok(jobs);
|
||||||
.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?;
|
|
||||||
logs.push(ActionJobLog { job, text });
|
|
||||||
}
|
}
|
||||||
Ok(logs)
|
|
||||||
}
|
}
|
||||||
|
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> {
|
fn action_run_id(run: i64) -> Result<i32> {
|
||||||
@@ -170,6 +441,31 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::Provider;
|
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]
|
#[tokio::test]
|
||||||
async fn gets_every_job_log_for_a_run() {
|
async fn gets_every_job_log_for_a_run() {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
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].starts_with("GET /api/v1/repos/hugo/Gotcha/actions/jobs/139/logs "));
|
||||||
assert!(requests[1].contains("accept: text/plain"));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
(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) {
|
pub fn civil_from_days(days: i64) -> (i64, i64, i64) {
|
||||||
let days = days + 719_468;
|
let days = days + 719_468;
|
||||||
let era = days.div_euclid(146_097);
|
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();
|
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_date(leap_day), "2024-02-29T00:00:00Z");
|
||||||
assert_eq!(api_timestamp(leap_day + 45_296), "2024-02-29T12:34:56Z");
|
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_date("2023-02-29T00:00:00Z"), None);
|
||||||
|
assert_eq!(parse_api_timestamp("2024-02-29T25:00:00Z"), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,16 @@ pub mod notifications;
|
|||||||
mod pulls;
|
mod pulls;
|
||||||
mod repositories;
|
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 activity::{ActivityFilter, ServerActivityPager};
|
||||||
pub use config::{Config, Selection, ServerProfile, TuiPreferences, server_url};
|
pub use config::{Config, Selection, ServerProfile, TuiPreferences, server_url};
|
||||||
pub use domain::{
|
pub use domain::{
|
||||||
CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft,
|
CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft,
|
||||||
IssueEditorData, IssueQuery, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryId,
|
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 issues::comment_can_edit;
|
||||||
pub use pulls::{PullFileSource, pull_file_source, pull_state};
|
pub use pulls::{PullFileSource, pull_file_source, pull_state};
|
||||||
|
|||||||
@@ -128,6 +128,31 @@ impl Client {
|
|||||||
.map_err(Into::into)
|
.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(
|
pub async fn branch_history(
|
||||||
&self,
|
&self,
|
||||||
repository: &RepositoryId,
|
repository: &RepositoryId,
|
||||||
|
|||||||
@@ -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 "
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use std::{
|
|||||||
|
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
|
||||||
use gotcha_gitea::{
|
use gotcha_gitea::{
|
||||||
ActivityFilter, Client, Config, HistoryCommit, IssueDraft, IssueQuery, MilestoneDraft,
|
ActionRunQuery, ActivityFilter, Client, Config, HistoryCommit, IssueDraft, IssueQuery,
|
||||||
Provider, RepositoryId, Selection, models,
|
MilestoneDraft, Provider, RepositoryId, Selection, models, parse_action_inputs,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::editor::{Editor, EditorAction, EditorEvent};
|
use crate::editor::{Editor, EditorAction, EditorEvent};
|
||||||
@@ -21,15 +21,17 @@ pub enum Tab {
|
|||||||
Repositories,
|
Repositories,
|
||||||
Pulls,
|
Pulls,
|
||||||
Milestones,
|
Milestones,
|
||||||
|
Actions,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tab {
|
impl Tab {
|
||||||
pub const ALL: [Self; 5] = [
|
pub const ALL: [Self; 6] = [
|
||||||
Self::Home,
|
Self::Home,
|
||||||
Self::Issues,
|
Self::Issues,
|
||||||
Self::Repositories,
|
Self::Repositories,
|
||||||
Self::Pulls,
|
Self::Pulls,
|
||||||
Self::Milestones,
|
Self::Milestones,
|
||||||
|
Self::Actions,
|
||||||
];
|
];
|
||||||
|
|
||||||
pub const fn title(self) -> &'static str {
|
pub const fn title(self) -> &'static str {
|
||||||
@@ -39,6 +41,7 @@ impl Tab {
|
|||||||
Self::Repositories => "3 Repos",
|
Self::Repositories => "3 Repos",
|
||||||
Self::Pulls => "4 PRs",
|
Self::Pulls => "4 PRs",
|
||||||
Self::Milestones => "5 Milestones",
|
Self::Milestones => "5 Milestones",
|
||||||
|
Self::Actions => "6 Actions",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,6 +51,7 @@ pub enum RepositoryDestination {
|
|||||||
Issues,
|
Issues,
|
||||||
Commits,
|
Commits,
|
||||||
Milestones,
|
Milestones,
|
||||||
|
Actions,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
@@ -66,6 +70,10 @@ pub enum ScreenKind {
|
|||||||
Pull(RepositoryId, i64),
|
Pull(RepositoryId, i64),
|
||||||
Milestones(RepositoryId),
|
Milestones(RepositoryId),
|
||||||
Milestone(RepositoryId, i64),
|
Milestone(RepositoryId, i64),
|
||||||
|
ActionWorkflows(RepositoryId),
|
||||||
|
ActionRuns(RepositoryId),
|
||||||
|
ActionRun(RepositoryId, i64),
|
||||||
|
ActionLog(RepositoryId, i64, i64, String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScreenKind {
|
impl ScreenKind {
|
||||||
@@ -80,6 +88,9 @@ impl ScreenKind {
|
|||||||
| Self::Files(_, _)
|
| Self::Files(_, _)
|
||||||
| Self::Pulls
|
| Self::Pulls
|
||||||
| Self::Milestones(_)
|
| Self::Milestones(_)
|
||||||
|
| Self::ActionWorkflows(_)
|
||||||
|
| Self::ActionRuns(_)
|
||||||
|
| Self::ActionRun(_, _)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,6 +107,10 @@ pub enum Target {
|
|||||||
Content(RepositoryId, String, bool),
|
Content(RepositoryId, String, bool),
|
||||||
Comment(RepositoryId, i64, i64, String, bool),
|
Comment(RepositoryId, i64, i64, String, bool),
|
||||||
Text(String, String),
|
Text(String, String),
|
||||||
|
ActionRuns(RepositoryId),
|
||||||
|
ActionWorkflow(RepositoryId, String, String),
|
||||||
|
ActionRun(RepositoryId, i64),
|
||||||
|
ActionJob(RepositoryId, i64, i64, String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -354,6 +369,7 @@ impl App {
|
|||||||
KeyCode::Char('3') => self.switch_tab(Tab::Repositories).await,
|
KeyCode::Char('3') => self.switch_tab(Tab::Repositories).await,
|
||||||
KeyCode::Char('4') => self.switch_tab(Tab::Pulls).await,
|
KeyCode::Char('4') => self.switch_tab(Tab::Pulls).await,
|
||||||
KeyCode::Char('5') => self.switch_tab(Tab::Milestones).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('j') | KeyCode::Down => self.move_selection(1),
|
||||||
KeyCode::Char('k') | KeyCode::Up => self.move_selection(-1),
|
KeyCode::Char('k') | KeyCode::Up => self.move_selection(-1),
|
||||||
KeyCode::Char('g') => self.screen.select(0),
|
KeyCode::Char('g') => self.screen.select(0),
|
||||||
@@ -552,6 +568,7 @@ impl App {
|
|||||||
RepositoryDestination::Issues => ScreenKind::Issues(repository),
|
RepositoryDestination::Issues => ScreenKind::Issues(repository),
|
||||||
RepositoryDestination::Commits => ScreenKind::Commits(repository),
|
RepositoryDestination::Commits => ScreenKind::Commits(repository),
|
||||||
RepositoryDestination::Milestones => ScreenKind::Milestones(repository),
|
RepositoryDestination::Milestones => ScreenKind::Milestones(repository),
|
||||||
|
RepositoryDestination::Actions => ScreenKind::ActionWorkflows(repository),
|
||||||
};
|
};
|
||||||
self.open(kind).await;
|
self.open(kind).await;
|
||||||
}
|
}
|
||||||
@@ -579,6 +596,19 @@ impl App {
|
|||||||
Target::Text(title, text) => {
|
Target::Text(title, text) => {
|
||||||
self.open(ScreenKind::Text(title, text)).await;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
EditorAction::ActionDispatch {
|
||||||
|
repository,
|
||||||
|
workflow,
|
||||||
|
} => {
|
||||||
|
self.save_action_dispatch(&repository, &workflow, &values)
|
||||||
|
.await
|
||||||
|
}
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
@@ -1100,6 +1137,20 @@ impl App {
|
|||||||
.map_err(|error| error.to_string())
|
.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(
|
async fn save_server(
|
||||||
&mut self,
|
&mut self,
|
||||||
original_name: Option<&str>,
|
original_name: Option<&str>,
|
||||||
@@ -1168,6 +1219,12 @@ impl App {
|
|||||||
ScreenKind::Milestone(repository, id) => {
|
ScreenKind::Milestone(repository, id) => {
|
||||||
self.load_milestone(repository, id, page).await
|
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.page = page;
|
||||||
screen.has_more = data.next_page.is_some();
|
screen.has_more = data.next_page.is_some();
|
||||||
screen.detail = format!(
|
screen.detail = format!(
|
||||||
"{contributions} contributions in the available heatmap range\nFilter: {:?}\n\n[v] cycle activity filter · 1–5 switch panes · s servers · , preferences",
|
"{contributions} contributions in the available heatmap range\nFilter: {:?}\n\n[v] cycle activity filter · 1–6 switch panes · s servers · , preferences",
|
||||||
self.activity_filter
|
self.activity_filter
|
||||||
);
|
);
|
||||||
screen.items = data
|
screen.items = data
|
||||||
@@ -1341,6 +1398,7 @@ impl App {
|
|||||||
RepositoryDestination::Issues => "Issue repositories",
|
RepositoryDestination::Issues => "Issue repositories",
|
||||||
RepositoryDestination::Commits => "Repositories",
|
RepositoryDestination::Commits => "Repositories",
|
||||||
RepositoryDestination::Milestones => "Milestone repositories",
|
RepositoryDestination::Milestones => "Milestone repositories",
|
||||||
|
RepositoryDestination::Actions => "Actions repositories",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
screen.page = page;
|
screen.page = page;
|
||||||
@@ -1772,6 +1830,275 @@ impl App {
|
|||||||
.collect();
|
.collect();
|
||||||
Ok(screen)
|
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(
|
fn list_item_at(
|
||||||
@@ -1829,6 +2156,7 @@ fn root_kind(tab: Tab) -> ScreenKind {
|
|||||||
Tab::Repositories => ScreenKind::Repositories(RepositoryDestination::Commits),
|
Tab::Repositories => ScreenKind::Repositories(RepositoryDestination::Commits),
|
||||||
Tab::Pulls => ScreenKind::Pulls,
|
Tab::Pulls => ScreenKind::Pulls,
|
||||||
Tab::Milestones => ScreenKind::Repositories(RepositoryDestination::Milestones),
|
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::Issues => "issues",
|
||||||
RepositoryDestination::Commits => "commits",
|
RepositoryDestination::Commits => "commits",
|
||||||
RepositoryDestination::Milestones => "milestones",
|
RepositoryDestination::Milestones => "milestones",
|
||||||
|
RepositoryDestination::Actions => "actions",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ pub enum EditorAction {
|
|||||||
Settings,
|
Settings,
|
||||||
IssueFilter,
|
IssueFilter,
|
||||||
PullFilter,
|
PullFilter,
|
||||||
|
ActionDispatch {
|
||||||
|
repository: RepositoryId,
|
||||||
|
workflow: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[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 {
|
fn new(title: &str, action: EditorAction, fields: Vec<Field>) -> Self {
|
||||||
let cursor = fields
|
let cursor = fields
|
||||||
.first()
|
.first()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
uint64_t uniffi_gotcha_core_fn_constructor_gotchacore_new(RustBuffer storage_directory, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#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
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMIT_DETAILS
|
||||||
#define 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
|
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
|
void uniffi_gotcha_core_fn_method_gotchacore_set_notifications_enabled(uint64_t ptr, int8_t enabled, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#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
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_STATUS
|
||||||
#define 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
|
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
|
#ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUST_FUTURE_COMPLETE_VOID
|
||||||
#define 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
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMIT_DETAILS
|
#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
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_NOTIFICATIONS_ENABLED
|
||||||
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_notifications_enabled(void
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_STATUS
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_STATUS
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
4652515AE4CB10963D995143 /* CommitScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */; };
|
4652515AE4CB10963D995143 /* CommitScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */; };
|
||||||
6DAA1D3230197F537D70735E /* NotificationsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE88A28F99C6D0224865E04D /* NotificationsScreen.swift */; };
|
6DAA1D3230197F537D70735E /* NotificationsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE88A28F99C6D0224865E04D /* NotificationsScreen.swift */; };
|
||||||
74626C144E9214BF89F821D2 /* WidgetIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AE1D594C38D2BDAA2E86AD /* WidgetIntents.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 */; };
|
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, ); }; };
|
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 */; };
|
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 */; };
|
A2AF2C1F3C8B0716E1EF36B2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */; };
|
||||||
BDFE5D7A54541A109B35BF45 /* gotcha_core.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE12480725C13293FAFDDA52 /* gotcha_core.swift */; };
|
BDFE5D7A54541A109B35BF45 /* gotcha_core.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE12480725C13293FAFDDA52 /* gotcha_core.swift */; };
|
||||||
C33BA07C5F7DA72CBF72CEAE /* MilestoneScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64B529D84926EEEDC163A929 /* MilestoneScreens.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 */; };
|
CD50D82AC4B92576A2ADE163 /* CommitDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81A55AAC9DB99FEB79F56DAF /* CommitDetailScreen.swift */; };
|
||||||
D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722405F999984C7CBE838711 /* IssueEditorViewController.swift */; };
|
D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722405F999984C7CBE838711 /* IssueEditorViewController.swift */; };
|
||||||
D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE12480725C13293FAFDDA52 /* gotcha_core.swift */; };
|
D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE12480725C13293FAFDDA52 /* gotcha_core.swift */; };
|
||||||
@@ -77,6 +79,7 @@
|
|||||||
52ABD1B07CEEF00263038653 /* GotchaWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = GotchaWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
C028DA45E7CB9E1CD56754AC /* ServerActivityScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityScreen.swift; sourceTree = "<group>"; };
|
||||||
@@ -124,6 +128,7 @@
|
|||||||
710A50F51478401FC642E6E3 /* Sources */ = {
|
710A50F51478401FC642E6E3 /* Sources */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
ABB8F58445A6BDB7FDE8B9CA /* ActionsScreens.swift */,
|
||||||
F61515849F6AACD721FE915C /* AppContext.swift */,
|
F61515849F6AACD721FE915C /* AppContext.swift */,
|
||||||
9C0921C76676A14A024BA417 /* AppDelegate.swift */,
|
9C0921C76676A14A024BA417 /* AppDelegate.swift */,
|
||||||
8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */,
|
8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */,
|
||||||
@@ -136,6 +141,7 @@
|
|||||||
ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */,
|
ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */,
|
||||||
CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */,
|
CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */,
|
||||||
64B529D84926EEEDC163A929 /* MilestoneScreens.swift */,
|
64B529D84926EEEDC163A929 /* MilestoneScreens.swift */,
|
||||||
|
6A45865CA6E36C30A9EBC75C /* NavigationSettingsViewController.swift */,
|
||||||
BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */,
|
BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */,
|
||||||
DE88A28F99C6D0224865E04D /* NotificationsScreen.swift */,
|
DE88A28F99C6D0224865E04D /* NotificationsScreen.swift */,
|
||||||
99B8279189276A084B69D7D0 /* PullScreens.swift */,
|
99B8279189276A084B69D7D0 /* PullScreens.swift */,
|
||||||
@@ -348,6 +354,7 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
77576F6C61D65F12BB64A2E9 /* ActionsScreens.swift in Sources */,
|
||||||
EC7F8B4703DDE37A0B10CD9B /* AppContext.swift in Sources */,
|
EC7F8B4703DDE37A0B10CD9B /* AppContext.swift in Sources */,
|
||||||
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */,
|
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */,
|
||||||
381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */,
|
381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */,
|
||||||
@@ -360,6 +367,7 @@
|
|||||||
7BBE64F66374221F1743BC24 /* IssueScreens.swift in Sources */,
|
7BBE64F66374221F1743BC24 /* IssueScreens.swift in Sources */,
|
||||||
1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.swift in Sources */,
|
1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.swift in Sources */,
|
||||||
C33BA07C5F7DA72CBF72CEAE /* MilestoneScreens.swift in Sources */,
|
C33BA07C5F7DA72CBF72CEAE /* MilestoneScreens.swift in Sources */,
|
||||||
|
C4FA15BB48499D3EED99522E /* NavigationSettingsViewController.swift in Sources */,
|
||||||
7A511844F3CBA0603A894DDE /* NotificationCoordinator.swift in Sources */,
|
7A511844F3CBA0603A894DDE /* NotificationCoordinator.swift in Sources */,
|
||||||
6DAA1D3230197F537D70735E /* NotificationsScreen.swift in Sources */,
|
6DAA1D3230197F537D70735E /* NotificationsScreen.swift in Sources */,
|
||||||
33D3E65C9E50522B2039816A /* PullScreens.swift in Sources */,
|
33D3E65C9E50522B2039816A /* PullScreens.swift in Sources */,
|
||||||
|
|||||||
661
ios/Sources/ActionsScreens.swift
Normal file
661
ios/Sources/ActionsScreens.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,21 +19,20 @@ final class AppContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func makeRootController() -> UIViewController {
|
func makeRootController() -> UIViewController {
|
||||||
let roots: [UIViewController] = [
|
rebuildTabs(preservingHomeStack: false)
|
||||||
HomeViewController(context: self),
|
return tabs
|
||||||
repositoryRoot(mode: .issues),
|
}
|
||||||
repositoryRoot(mode: .commits),
|
|
||||||
PullsViewController(context: self),
|
func applyPrimaryDestinations() {
|
||||||
repositoryRoot(mode: .milestones),
|
rebuildTabs(preservingHomeStack: true)
|
||||||
]
|
}
|
||||||
let items = [
|
|
||||||
("Home", "house", "house.fill"),
|
private func rebuildTabs(preservingHomeStack: Bool) {
|
||||||
("Issues", "exclamationmark.circle", "exclamationmark.circle.fill"),
|
let destinations = core.settings().primaryDestinations
|
||||||
("Repos", "books.vertical", "books.vertical.fill"),
|
let home = preservingHomeStack ? navigationControllers.first : nil
|
||||||
("PRs", "arrow.triangle.pull", "arrow.triangle.pull"),
|
let roots = destinations.map(destinationController)
|
||||||
("Milestones", "flag", "flag.fill"),
|
let items = [("Home", "house", "house.fill")] + destinations.map(destinationItem)
|
||||||
]
|
let destinationNavigations = zip(roots, items.dropFirst()).map { root, item in
|
||||||
navigationControllers = zip(roots, items).map { root, item in
|
|
||||||
let navigation = UINavigationController(rootViewController: root)
|
let navigation = UINavigationController(rootViewController: root)
|
||||||
navigation.tabBarItem = UITabBarItem(
|
navigation.tabBarItem = UITabBarItem(
|
||||||
title: item.0,
|
title: item.0,
|
||||||
@@ -42,8 +41,17 @@ final class AppContext {
|
|||||||
)
|
)
|
||||||
return navigation
|
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
|
tabs.viewControllers = navigationControllers
|
||||||
return tabs
|
(homeNavigation.viewControllers.first as? HomeViewController)?.refreshPrimaryDestinations()
|
||||||
}
|
}
|
||||||
|
|
||||||
func showStartupErrorIfNeeded() {
|
func showStartupErrorIfNeeded() {
|
||||||
@@ -57,33 +65,50 @@ final class AppContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func reloadAfterServerChange() {
|
func reloadAfterServerChange() {
|
||||||
let replacements: [(Int, UIViewController)] = [
|
rebuildTabs(preservingHomeStack: false)
|
||||||
(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)
|
|
||||||
}
|
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func route(widgetURL: URL) -> Bool {
|
func route(widgetURL: URL) -> Bool {
|
||||||
guard widgetURL.scheme == "gotcha" else { return false }
|
guard widgetURL.scheme == "gotcha" else { return false }
|
||||||
let index: Int
|
|
||||||
switch widgetURL.host {
|
switch widgetURL.host {
|
||||||
case "home": index = 0
|
case "home":
|
||||||
case "pulls": index = 3
|
tabs.selectedIndex = 0
|
||||||
default: return false
|
navigationControllers[0].popToRootViewController(animated: false)
|
||||||
}
|
case "pulls":
|
||||||
|
if let index = primaryDestinations.firstIndex(of: .pullRequests).map({ $0 + 1 }) {
|
||||||
tabs.selectedIndex = index
|
tabs.selectedIndex = index
|
||||||
navigationControllers[index].popToRootViewController(animated: false)
|
navigationControllers[index].popToRootViewController(animated: false)
|
||||||
|
} else {
|
||||||
|
tabs.selectedIndex = 0
|
||||||
|
navigationControllers[0].pushViewController(
|
||||||
|
PullsViewController(context: self),
|
||||||
|
animated: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
return true
|
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 {
|
func didAddServer(index: UInt32) throws {
|
||||||
try selectServer(index: index)
|
try selectServer(index: index)
|
||||||
}
|
}
|
||||||
@@ -154,7 +179,7 @@ final class AppContext {
|
|||||||
do {
|
do {
|
||||||
try selectServer(index: UInt32(index))
|
try selectServer(index: UInt32(index))
|
||||||
} catch {
|
} catch {
|
||||||
tabs.present(errorAlert(error.localizedDescription), animated: true)
|
tabs.present(errorAlert(errorMessage(error)), animated: true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,4 +241,54 @@ final class AppContext {
|
|||||||
}
|
}
|
||||||
return RepositoriesViewController(context: self, mode: mode)
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,25 +2,37 @@ import UIKit
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class HomeViewController: RefreshingTableViewController {
|
final class HomeViewController: RefreshingTableViewController {
|
||||||
private enum ActivityFilter: Int {
|
private enum Timeline: Int {
|
||||||
case all
|
case user
|
||||||
case issues
|
case allUsers
|
||||||
case pulls
|
case notifications
|
||||||
|
|
||||||
var coreValue: HomeActivityFilter {
|
|
||||||
switch self {
|
|
||||||
case .all: return .all
|
|
||||||
case .issues: return .issues
|
|
||||||
case .pulls: return .pullRequests
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private let context: AppContext
|
private let context: AppContext
|
||||||
private var page: HomePage?
|
private var page: HomePage?
|
||||||
private var filter = ActivityFilter.all
|
private var timeline = Timeline.user
|
||||||
private var nextPage: UInt32?
|
private var userNextPage: UInt32?
|
||||||
private var activities: [ActivityRow] { page?.activities ?? [] }
|
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) {
|
init(context: AppContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
@@ -33,6 +45,14 @@ final class HomeViewController: RefreshingTableViewController {
|
|||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.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(
|
let servers = UIBarButtonItem(
|
||||||
image: context.symbol("server.rack"),
|
image: context.symbol("server.rack"),
|
||||||
primaryAction: UIAction { [weak self] _ in
|
primaryAction: UIAction { [weak self] _ in
|
||||||
@@ -61,8 +81,11 @@ final class HomeViewController: RefreshingTableViewController {
|
|||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
guard page == nil else { return }
|
if page == nil {
|
||||||
loadContent(refreshing: false)
|
loadContent(refreshing: false)
|
||||||
|
} else if timeline == .notifications {
|
||||||
|
loadNotifications(page: 1, refreshing: false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func loadContent(refreshing: Bool) {
|
override func loadContent(refreshing: Bool) {
|
||||||
@@ -74,15 +97,31 @@ final class HomeViewController: RefreshingTableViewController {
|
|||||||
refreshControl?.endRefreshing()
|
refreshControl?.endRefreshing()
|
||||||
return
|
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() {
|
override func loadMoreContent() {
|
||||||
guard let nextPage else { return }
|
switch timeline {
|
||||||
loadPage(nextPage, refreshing: false)
|
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 {
|
if requestedPage == 1 {
|
||||||
resetPagination()
|
resetPagination()
|
||||||
beginLoading(refreshing: refreshing)
|
beginLoading(refreshing: refreshing)
|
||||||
@@ -92,62 +131,106 @@ final class HomeViewController: RefreshingTableViewController {
|
|||||||
do {
|
do {
|
||||||
let result = try await context.core.home(
|
let result = try await context.core.home(
|
||||||
page: requestedPage,
|
page: requestedPage,
|
||||||
filter: filter.coreValue
|
filter: .all
|
||||||
)
|
)
|
||||||
|
guard !Task.isCancelled, timeline == .user else { return }
|
||||||
if requestedPage == 1 {
|
if requestedPage == 1 {
|
||||||
page = result
|
page = result
|
||||||
} else {
|
} else {
|
||||||
page?.activities.append(contentsOf: result.activities)
|
page?.activities.append(contentsOf: result.activities)
|
||||||
page?.nextPage = result.nextPage
|
page?.nextPage = result.nextPage
|
||||||
}
|
}
|
||||||
nextPage = result.nextPage
|
userNextPage = result.nextPage
|
||||||
finishPagination(hasMore: result.nextPage != nil)
|
finishPagination(hasMore: result.nextPage != nil)
|
||||||
title = page?.serverName
|
setPanelTitle(self, "Home", server: page?.serverName)
|
||||||
if requestedPage == 1 { tableView.tableHeaderView = page.map { page in
|
if requestedPage == 1 { refreshPrimaryDestinations() }
|
||||||
HeatmapView(
|
updateRows()
|
||||||
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()
|
|
||||||
} catch {
|
} catch {
|
||||||
if !Task.isCancelled {
|
if !Task.isCancelled, timeline == .user {
|
||||||
show(error: error)
|
show(error: error)
|
||||||
failPagination()
|
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 {
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
activities.count
|
timeline == .notifications ? notificationRows.count : activities.count
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tableView(
|
override func tableView(
|
||||||
_ tableView: UITableView,
|
_ tableView: UITableView,
|
||||||
cellForRowAt indexPath: IndexPath
|
cellForRowAt indexPath: IndexPath
|
||||||
) -> UITableViewCell {
|
) -> 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")
|
let cell = tableView.dequeueReusableCell(withIdentifier: "activity")
|
||||||
?? UITableViewCell(style: .subtitle, reuseIdentifier: "activity")
|
?? UITableViewCell(style: .subtitle, reuseIdentifier: "activity")
|
||||||
let row = activities[indexPath.row]
|
let row = activities[indexPath.row]
|
||||||
@@ -162,22 +245,98 @@ final class HomeViewController: RefreshingTableViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
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) {
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
if timeline == .notifications {
|
||||||
|
openNotification(notificationRows[indexPath.row])
|
||||||
|
} else {
|
||||||
context.route(activities[indexPath.row])
|
context.route(activities[indexPath.row])
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func updateActivities() {
|
private func updateRows() {
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
tableView.backgroundView = activities.isEmpty
|
let isEmpty = timeline == .notifications ? notificationRows.isEmpty : activities.isEmpty
|
||||||
? EmptyBackgroundView(
|
guard isEmpty else {
|
||||||
title: "No matching activity",
|
tableView.backgroundView = nil
|
||||||
detail: "This server has no recent activity of the selected type."
|
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 {
|
final class HeatmapView: UIView {
|
||||||
private let cells: [HeatCell]
|
private let cells: [HeatCell]
|
||||||
private let calendar = Calendar(identifier: .gregorian)
|
private let calendar = Calendar(identifier: .gregorian)
|
||||||
|
private let controlsScroller = UIScrollView()
|
||||||
|
|
||||||
init(
|
init(
|
||||||
page: HomePage,
|
page: HomePage,
|
||||||
selectedFilter: Int,
|
selectedTimeline: Int,
|
||||||
onFilter: @escaping (Int) -> Void,
|
destinations: [PrimaryDestination],
|
||||||
onNotifications: @escaping () -> Void,
|
onTimeline: @escaping (Int) -> Void,
|
||||||
onServerActivity: @escaping () -> Void
|
onDestination: @escaping (PrimaryDestination) -> Void
|
||||||
) {
|
) {
|
||||||
cells = page.heatCells
|
cells = page.heatCells
|
||||||
super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 180))
|
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.font = .preferredFont(forTextStyle: .caption1)
|
||||||
total.textColor = .tertiaryLabel
|
total.textColor = .tertiaryLabel
|
||||||
addSubview(total)
|
addSubview(total)
|
||||||
let filters = UIStackView()
|
let timelinesView = UIStackView()
|
||||||
filters.axis = .horizontal
|
timelinesView.axis = .horizontal
|
||||||
filters.spacing = 4
|
let timelineItems = [
|
||||||
filters.translatesAutoresizingMaskIntoConstraints = false
|
("person.crop.circle", "person.crop.circle.fill", "Your activity"),
|
||||||
let filterItems = [
|
("person.3", "person.3.fill", "All users activity"),
|
||||||
("clock", "clock.fill", "All activity"),
|
("bell", "bell.fill", "Notifications"),
|
||||||
("exclamationmark.circle", "exclamationmark.circle.fill", "Issues"),
|
|
||||||
("arrow.triangle.pull", "arrow.triangle.pull", "Pull requests"),
|
|
||||||
]
|
]
|
||||||
for (index, item) in filterItems.enumerated() {
|
for (index, item) in timelineItems.enumerated() {
|
||||||
let button = UIButton(type: .custom, primaryAction: UIAction { action in
|
let button = UIButton(type: .custom, primaryAction: UIAction { action in
|
||||||
guard
|
guard
|
||||||
let button = action.sender as? UIButton,
|
let button = action.sender as? UIButton,
|
||||||
@@ -238,47 +396,77 @@ final class HeatmapView: UIView {
|
|||||||
item.tintColor = item.isSelected ? .tintColor : .secondaryLabel
|
item.tintColor = item.isSelected ? .tintColor : .secondaryLabel
|
||||||
item.accessibilityTraits = item.isSelected ? [.button, .selected] : .button
|
item.accessibilityTraits = item.isSelected ? [.button, .selected] : .button
|
||||||
}
|
}
|
||||||
onFilter(button.tag)
|
onTimeline(button.tag)
|
||||||
})
|
})
|
||||||
button.tag = index
|
button.tag = index
|
||||||
button.setImage(UIImage(systemName: item.0), for: .normal)
|
button.setImage(UIImage(systemName: item.0), for: .normal)
|
||||||
button.setImage(UIImage(systemName: item.1), for: .selected)
|
button.setImage(UIImage(systemName: item.1), for: .selected)
|
||||||
button.isSelected = index == selectedFilter
|
button.isSelected = index == selectedTimeline
|
||||||
button.tintColor = button.isSelected ? .tintColor : .secondaryLabel
|
button.tintColor = button.isSelected ? .tintColor : .secondaryLabel
|
||||||
button.accessibilityLabel = item.2
|
button.accessibilityLabel = item.2
|
||||||
button.accessibilityTraits = button.isSelected ? [.button, .selected] : .button
|
button.accessibilityTraits = button.isSelected ? [.button, .selected] : .button
|
||||||
button.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
button.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
||||||
filters.addArrangedSubview(button)
|
timelinesView.addArrangedSubview(button)
|
||||||
}
|
}
|
||||||
let serverActivity = UIButton(
|
|
||||||
|
let destinationsView = UIStackView()
|
||||||
|
destinationsView.axis = .horizontal
|
||||||
|
for destination in destinations {
|
||||||
|
let button = UIButton(
|
||||||
type: .custom,
|
type: .custom,
|
||||||
primaryAction: UIAction { _ in onServerActivity() }
|
primaryAction: UIAction { _ in onDestination(destination) }
|
||||||
)
|
)
|
||||||
serverActivity.setImage(UIImage(systemName: "person.3"), for: .normal)
|
let symbol = destination == .milestones ? "flag.fill" : destination.symbolName
|
||||||
serverActivity.tintColor = .secondaryLabel
|
button.setImage(UIImage(systemName: symbol), for: .normal)
|
||||||
serverActivity.accessibilityLabel = "Server Activity"
|
button.tintColor = .secondaryLabel
|
||||||
serverActivity.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
button.accessibilityLabel = destination.title
|
||||||
filters.addArrangedSubview(serverActivity)
|
button.accessibilityHint = "Opens from Home"
|
||||||
let notifications = UIButton(
|
button.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
||||||
type: .custom,
|
destinationsView.addArrangedSubview(button)
|
||||||
primaryAction: UIAction { _ in onNotifications() }
|
}
|
||||||
)
|
|
||||||
notifications.setImage(UIImage(systemName: "bell"), for: .normal)
|
let controls = UIStackView(arrangedSubviews: [timelinesView])
|
||||||
notifications.tintColor = .secondaryLabel
|
controls.axis = .horizontal
|
||||||
notifications.accessibilityLabel = "Notifications"
|
controls.alignment = .center
|
||||||
notifications.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
controls.translatesAutoresizingMaskIntoConstraints = false
|
||||||
filters.addArrangedSubview(notifications)
|
if !destinations.isEmpty {
|
||||||
addSubview(filters)
|
controls.addArrangedSubview(destinationsView)
|
||||||
|
}
|
||||||
|
controlsScroller.showsHorizontalScrollIndicator = false
|
||||||
|
controlsScroller.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
controlsScroller.addSubview(controls)
|
||||||
|
addSubview(controlsScroller)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
filters.centerXAnchor.constraint(equalTo: centerXAnchor),
|
controlsScroller.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
filters.topAnchor.constraint(equalTo: topAnchor, constant: 132),
|
controlsScroller.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
filters.heightAnchor.constraint(equalToConstant: 44),
|
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)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
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) {
|
override func draw(_ rect: CGRect) {
|
||||||
guard
|
guard
|
||||||
let first = cells.first,
|
let first = cells.first,
|
||||||
|
|||||||
128
ios/Sources/NavigationSettingsViewController.swift
Normal file
128
ios/Sources/NavigationSettingsViewController.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,7 +72,7 @@ final class NotificationsViewController: RefreshingTableViewController {
|
|||||||
init(context: AppContext) {
|
init(context: AppContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
super.init()
|
super.init()
|
||||||
title = "Notifications"
|
setPanelTitle(self, "Notifications", server: context.core.activeServerName())
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ final class PullsViewController: RefreshingTableViewController {
|
|||||||
init(context: AppContext) {
|
init(context: AppContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
super.init()
|
super.init()
|
||||||
title = "Pull Requests"
|
setPanelTitle(self, "Pull Requests", server: context.core.activeServerName())
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
@@ -75,6 +75,13 @@ final class PullsViewController: RefreshingTableViewController {
|
|||||||
tableView.register(PullCell.self, forCellReuseIdentifier: "pull")
|
tableView.register(PullCell.self, forCellReuseIdentifier: "pull")
|
||||||
tableView.rowHeight = UITableView.automaticDimension
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
tableView.estimatedRowHeight = 116
|
tableView.estimatedRowHeight = 116
|
||||||
|
updateFilterMenu()
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
if navigationController?.viewControllers.first === self {
|
||||||
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||||
image: context.symbol("server.rack"),
|
image: context.symbol("server.rack"),
|
||||||
primaryAction: UIAction { [weak self] _ in
|
primaryAction: UIAction { [weak self] _ in
|
||||||
@@ -85,8 +92,9 @@ final class PullsViewController: RefreshingTableViewController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
updateFilterMenu()
|
} else {
|
||||||
loadContent(refreshing: false)
|
navigationItem.leftBarButtonItem = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func loadContent(refreshing: Bool) {
|
override func loadContent(refreshing: Bool) {
|
||||||
|
|||||||
@@ -7,11 +7,20 @@ final class RepositoriesViewController: RefreshingTableViewController {
|
|||||||
private var rows: [RepositoryRow] = []
|
private var rows: [RepositoryRow] = []
|
||||||
private var currentPage: UInt32 = 0
|
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) {
|
init(context: AppContext, mode: RepositoryPane) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
super.init()
|
super.init()
|
||||||
title = context.core.activeServerName() ?? "Repositories"
|
setPanelTitle(self, panelTitle, server: context.core.activeServerName())
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
@@ -19,6 +28,12 @@ final class RepositoriesViewController: RefreshingTableViewController {
|
|||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
if navigationController?.viewControllers.first === self {
|
||||||
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||||
image: context.symbol("server.rack"),
|
image: context.symbol("server.rack"),
|
||||||
primaryAction: UIAction { [weak self] _ in
|
primaryAction: UIAction { [weak self] _ in
|
||||||
@@ -29,7 +44,9 @@ final class RepositoriesViewController: RefreshingTableViewController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
loadContent(refreshing: false)
|
} else {
|
||||||
|
navigationItem.leftBarButtonItem = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func loadContent(refreshing: Bool) {
|
override func loadContent(refreshing: Bool) {
|
||||||
@@ -118,6 +135,13 @@ final class RepositoriesViewController: RefreshingTableViewController {
|
|||||||
owner: row.owner,
|
owner: row.owner,
|
||||||
repository: row.name
|
repository: row.name
|
||||||
)
|
)
|
||||||
|
case .actions:
|
||||||
|
destination = ActionsViewController(
|
||||||
|
context: context,
|
||||||
|
owner: row.owner,
|
||||||
|
repository: row.name,
|
||||||
|
defaultBranch: row.defaultBranch
|
||||||
|
)
|
||||||
}
|
}
|
||||||
navigationController?.pushViewController(destination, animated: true)
|
navigationController?.pushViewController(destination, animated: true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ final class ServerActivityViewController: RefreshingTableViewController {
|
|||||||
init(context: AppContext) {
|
init(context: AppContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
super.init()
|
super.init()
|
||||||
title = "Server Activity"
|
setPanelTitle(self, "Server Activity", server: context.core.activeServerName())
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
|
|||||||
@@ -29,25 +29,29 @@ final class SettingsViewController: UITableViewController {
|
|||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
notificationSwitch.isOn = context.core.settings().notificationsEnabled
|
notificationSwitch.isOn = context.core.settings().notificationsEnabled
|
||||||
|
tableView.reloadSections(IndexSet(integer: 1), with: .none)
|
||||||
Task {
|
Task {
|
||||||
notificationStatus = await context.notifications.authorizationDescription()
|
notificationStatus = await context.notifications.authorizationDescription()
|
||||||
updateNotificationSettingsCell()
|
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 {
|
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? {
|
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? {
|
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||||||
if section == 0 {
|
if section == 0 {
|
||||||
return "Follow iOS automatically or choose a fixed appearance."
|
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."
|
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,
|
_ tableView: UITableView,
|
||||||
cellForRowAt indexPath: IndexPath
|
cellForRowAt indexPath: IndexPath
|
||||||
) -> UITableViewCell {
|
) -> 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 {
|
guard indexPath.section == 0 else {
|
||||||
if indexPath.row == 0 {
|
if indexPath.row == 0 {
|
||||||
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
||||||
@@ -87,9 +100,15 @@ final class SettingsViewController: UITableViewController {
|
|||||||
|
|
||||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
guard indexPath.section == 1, indexPath.row == 1 else { return }
|
if indexPath.section == 1 {
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
NavigationSettingsViewController(context: context),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
} else if indexPath.section == 2, indexPath.row == 1 {
|
||||||
context.notifications.openSystemSettings()
|
context.notifications.openSystemSettings()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func appearanceChanged() {
|
@objc private func appearanceChanged() {
|
||||||
do {
|
do {
|
||||||
@@ -119,7 +138,7 @@ final class SettingsViewController: UITableViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateNotificationSettingsCell() {
|
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()
|
var content = cell.defaultContentConfiguration()
|
||||||
content.text = "Notification Settings"
|
content.text = "Notification Settings"
|
||||||
content.secondaryText = notificationStatus
|
content.secondaryText = notificationStatus
|
||||||
|
|||||||
@@ -46,9 +46,14 @@ func errorAlert(_ message: String) -> UIAlertController {
|
|||||||
return alert
|
return alert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func errorMessage(_ error: Error) -> String {
|
||||||
|
if case let GotchaError.Message(message) = error { return message }
|
||||||
|
return error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
extension UIViewController {
|
extension UIViewController {
|
||||||
func show(error: Error) {
|
func show(error: Error) {
|
||||||
present(errorAlert(error.localizedDescription), animated: true)
|
present(errorAlert(errorMessage(error)), animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func beginNavigationLoading(_ spinner: UIActivityIndicatorView) {
|
func beginNavigationLoading(_ spinner: UIActivityIndicatorView) {
|
||||||
@@ -392,3 +397,31 @@ func separator() -> UIView {
|
|||||||
line.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
|
line.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
|
||||||
return line
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user