diff --git a/TESTING.md b/TESTING.md index da4ee2c..776c1a8 100644 --- a/TESTING.md +++ b/TESTING.md @@ -99,6 +99,20 @@ xcrun simctl launch booted de.rfc1437.gotcha - [ ] Pull past the top of Home, repository lists, issue lists, pull lists, commit history, changed files, issue details, pull details, and diffs. The native refresh control appears, reloads data, and disappears. +- [ ] On accounts/repositories with more than 30 results, Home activity, + repositories, issues, pull requests, milestones, milestone contents, + commit history, and pull-request files initially show one page plus a + **Pull up or tap to load more** + footer. Pull upward past the bottom and verify the footer changes to a + native loading indicator, the next page appends without duplicates, and + the footer disappears after the final page. +- [ ] Tap the load-more footer as an accessibility alternative and repeat while + VoiceOver is running. It announces loading, ignores repeated activation + while a request is active, preserves the current scroll position, and + allows retry after an error. +- [ ] Pull down to refresh after loading multiple pages. The content resets to + the new first page, filtering/branch changes also reset pagination, and + switching History/Files never shows a stale load-more footer. - [ ] Tap a row, scroll its detail, and use both the navigation-bar Back button and the left-edge interactive swipe to return. - [ ] From a list scrolled well away from the top, open a detail and go Back. diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs index 0f509b4..2f39825 100644 --- a/crates/app/src/api.rs +++ b/crates/app/src/api.rs @@ -10,12 +10,12 @@ use crate::{ diff, domain::{ HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, MilestoneDetails, - PullDetails, RepositoryData, Server, api_date, + PAGE_SIZE, Page, PullDetails, RepositoryData, Server, api_date, }, presentation::compact_date, }; -pub async fn load_repositories(server: &Server) -> Result, String> { +pub async fn load_repositories(server: &Server, page: i32) -> Result, String> { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let configuration = client.configuration(); @@ -24,36 +24,41 @@ pub async fn load_repositories(server: &Server) -> Result, S .await .map_err(|error| error.to_string())?; let login = user.login.unwrap_or_default(); - let repositories = apis::user_api::user_current_list_repos(&configuration, Some(1), Some(100)) - .await - .map_err(|error| error.to_string())?; - Ok(repositories - .into_iter() - .filter(|repository| { - repository - .owner - .as_ref() - .and_then(|owner| owner.login.as_deref()) - == Some(login.as_str()) - }) - .filter_map(|repository| { - Some(RepositoryData { - owner: repository.owner?.login?, - name: repository.name?, - description: repository - .description - .filter(|text| !text.is_empty()) - .unwrap_or_else(|| "No description".into()), - language: repository - .language - .filter(|text| !text.is_empty()) - .unwrap_or_else(|| "Unknown language".into()), - open_issues: repository.open_issues_count.unwrap_or_default(), - updated: compact_date(repository.updated_at.as_deref()), - default_branch: repository.default_branch.unwrap_or_else(|| "main".into()), + let repositories = + apis::user_api::user_current_list_repos(&configuration, Some(page), Some(PAGE_SIZE)) + .await + .map_err(|error| error.to_string())?; + let has_more = repositories.len() == PAGE_SIZE as usize; + Ok(Page { + items: repositories + .into_iter() + .filter(|repository| { + repository + .owner + .as_ref() + .and_then(|owner| owner.login.as_deref()) + == Some(login.as_str()) }) - }) - .collect()) + .filter_map(|repository| { + Some(RepositoryData { + owner: repository.owner?.login?, + name: repository.name?, + description: repository + .description + .filter(|text| !text.is_empty()) + .unwrap_or_else(|| "No description".into()), + language: repository + .language + .filter(|text| !text.is_empty()) + .unwrap_or_else(|| "Unknown language".into()), + open_issues: repository.open_issues_count.unwrap_or_default(), + updated: compact_date(repository.updated_at.as_deref()), + default_branch: repository.default_branch.unwrap_or_else(|| "main".into()), + }) + }) + .collect(), + has_more, + }) } pub async fn load_issues( @@ -63,7 +68,8 @@ pub async fn load_issues( status: &str, labels: &[String], milestone: &str, -) -> Result, String> { + page: i32, +) -> Result, String> { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let labels = (!labels.is_empty()).then(|| labels.join(",")); @@ -81,10 +87,33 @@ pub async fn load_issues( None, None, None, - Some(1), - Some(100), + Some(page), + Some(PAGE_SIZE), ) .await + .map(Page::from_items) + .map_err(|error| error.to_string()) +} + +pub async fn load_milestones_page( + server: &Server, + owner: &str, + repository: &str, + page: i32, +) -> Result, String> { + let client = + Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; + apis::issue_api::issue_get_milestones_list( + &client.configuration(), + owner, + repository, + Some("all"), + None, + Some(page), + Some(PAGE_SIZE), + ) + .await + .map(Page::from_items) .map_err(|error| error.to_string()) } @@ -151,6 +180,7 @@ pub async fn load_milestone( owner: &str, repository: &str, id: i64, + page: i32, ) -> Result { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; @@ -174,12 +204,14 @@ pub async fn load_milestone( None, None, None, - Some(1), - Some(100), + Some(page), + Some(PAGE_SIZE), ) }; let (issues, pulls) = tokio::join!(load("issues"), load("pulls")); + let issues = issues.map_err(|error| error.to_string())?; let mut pulls = pulls.map_err(|error| error.to_string())?; + let has_more = issues.len() == PAGE_SIZE as usize || pulls.len() == PAGE_SIZE as usize; for pull in &mut pulls { pull.repository.get_or_insert_with(|| { Box::new(models::RepositoryMeta { @@ -191,8 +223,9 @@ pub async fn load_milestone( } Ok(MilestoneDetails { milestone, - issues: issues.map_err(|error| error.to_string())?, + issues, pulls, + has_more, }) } @@ -200,7 +233,8 @@ pub async fn load_pulls( server: &Server, status: &str, milestone: &str, -) -> Result, String> { + page: i32, +) -> Result, String> { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let configuration = client.configuration(); @@ -210,42 +244,35 @@ pub async fn load_pulls( .map_err(|error| error.to_string())? .login .ok_or("The server account has no username.")?; - let mut pulls = Vec::new(); - for page in 1.. { - let batch = apis::issue_api::issue_search_issues( - &configuration, - Some(status), - None, - (!milestone.is_empty()).then_some(milestone), - None, - None, - Some("pulls"), - None, - None, - None, - None, - None, - None, - None, - Some(&owner), - None, - Some(page), - Some(100), - ) - .await - .map_err(|error| error.to_string())?; - if batch.is_empty() { - break; - } - pulls.extend(batch); - } - Ok(pulls) + apis::issue_api::issue_search_issues( + &configuration, + Some(status), + None, + (!milestone.is_empty()).then_some(milestone), + None, + None, + Some("pulls"), + None, + None, + None, + None, + None, + None, + None, + Some(&owner), + None, + Some(page), + Some(PAGE_SIZE), + ) + .await + .map(Page::from_items) + .map_err(|error| error.to_string()) } pub async fn load_pull_milestones(server: &Server) -> Result, String> { let (open, closed) = tokio::try_join!( - load_pulls(server, "open", ""), - load_pulls(server, "closed", "") + load_all_pulls(server, "open"), + load_all_pulls(server, "closed") )?; Ok(open .into_iter() @@ -256,6 +283,18 @@ pub async fn load_pull_milestones(server: &Server) -> Result, String .collect()) } +async fn load_all_pulls(server: &Server, status: &str) -> Result, String> { + let mut pulls = Vec::new(); + for page in 1.. { + let batch = load_pulls(server, status, "", page).await?; + pulls.extend(batch.items); + if !batch.has_more { + break; + } + } + Ok(pulls) +} + pub async fn load_branches( server: &Server, owner: &str, @@ -336,11 +375,14 @@ pub async fn load_branch_commits( owner: &str, repository: &str, branch: &str, -) -> Result, String> { - load_commits_for_ref(server, owner, repository, Some(branch)) + pages: u32, +) -> Result, String> { + load_commits_for_ref(server, owner, repository, Some(branch), pages) .await - .map(|commits| { - commits + .map(|page| Page { + has_more: page.has_more, + items: page + .items .into_iter() .map(|commit| HistoryCommit { commit, @@ -350,7 +392,7 @@ pub async fn load_branch_commits( connections: Vec::new(), refs: Vec::new(), }) - .collect() + .collect(), }) } @@ -359,13 +401,15 @@ pub async fn load_all_commits( owner: &str, repository: &str, branches: &[String], -) -> Result, String> { + pages: u32, +) -> Result, String> { let mut tasks = JoinSet::new(); for (lane, branch) in branches.iter().cloned().enumerate() { let (server, owner, repository) = (server.clone(), owner.to_string(), repository.to_string()); tasks.spawn(async move { - let commits = load_commits_for_ref(&server, &owner, &repository, Some(&branch)).await?; + let commits = + load_commits_for_ref(&server, &owner, &repository, Some(&branch), pages).await?; Ok::<_, String>((lane, branch, commits)) }); } @@ -375,11 +419,19 @@ pub async fn load_all_commits( histories.push(result.map_err(|error| error.to_string())??); } histories.sort_by_key(|(lane, _, _)| *lane); + let has_more = histories.iter().any(|(_, _, page)| page.has_more); + let histories = histories + .into_iter() + .map(|(lane, branch, page)| (lane, branch, page.items)) + .collect(); let pull_refs = load_pull_refs(server, owner, repository) .await .unwrap_or_default(); - Ok(build_graph(histories, pull_refs)) + Ok(Page { + items: build_graph(histories, pull_refs), + has_more, + }) } pub async fn load_pull_files( @@ -387,28 +439,21 @@ pub async fn load_pull_files( owner: &str, repository: &str, number: i64, -) -> Result, String> { - let mut files = Vec::new(); - for page in 1.. { - let batch = apis::repository_api::repo_get_pull_request_files( - configuration, - owner, - repository, - number, - None, - None, - Some(page), - Some(100), - ) - .await - .map_err(|error| error.to_string())?; - let done = batch.len() < 100; - files.extend(batch); - if done { - break; - } - } - Ok(files) + page: i32, +) -> Result, String> { + apis::repository_api::repo_get_pull_request_files( + configuration, + owner, + repository, + number, + None, + None, + Some(page), + Some(PAGE_SIZE), + ) + .await + .map(Page::from_items) + .map_err(|error| error.to_string()) } pub async fn load_issue( @@ -416,20 +461,37 @@ pub async fn load_issue( owner: &str, repository: &str, number: i64, + page: i32, ) -> Result { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let configuration = client.configuration(); let (issue, comments) = tokio::join!( apis::issue_api::issue_get_issue(&configuration, owner, repository, number), - apis::issue_api::issue_get_comments(&configuration, owner, repository, number, None, None,), + load_issue_comments(&configuration, owner, repository, number, page), ); Ok(IssueDetails { issue: issue.map_err(|error| error.to_string())?, - comments: comments.map_err(|error| error.to_string())?, + has_more: false, + comments: comments?, }) } +async fn load_issue_comments( + configuration: &apis::configuration::Configuration, + owner: &str, + repository: &str, + number: i64, + page: i32, +) -> Result, String> { + if page > 1 { + return Ok(Vec::new()); + } + apis::issue_api::issue_get_comments(configuration, owner, repository, number, None, None) + .await + .map_err(|error| error.to_string()) +} + pub async fn load_issue_editor( server: &Server, owner: &str, @@ -544,19 +606,22 @@ pub async fn load_pull( owner: &str, repository: &str, number: i64, + page: i32, ) -> Result { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let configuration = client.configuration(); let (pull, comments, files) = tokio::join!( apis::repository_api::repo_get_pull_request(&configuration, owner, repository, number,), - apis::issue_api::issue_get_comments(&configuration, owner, repository, number, None, None,), - load_pull_files(&configuration, owner, repository, number), + load_issue_comments(&configuration, owner, repository, number, page), + load_pull_files(&configuration, owner, repository, number, page), ); + let files = files?; Ok(PullDetails { pull: pull.map_err(|error| error.to_string())?, - comments: comments.map_err(|error| error.to_string())?, - files: files.map_err(|error| error.to_string())?, + has_more: files.has_more, + comments: comments?, + files: files.items, }) } @@ -821,12 +886,14 @@ async fn load_commits_for_ref( owner: &str, repository: &str, branch: Option<&str>, -) -> Result, String> { + pages: u32, +) -> Result, String> { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let configuration = client.configuration(); let mut commits = Vec::new(); - for page in 1.. { + let mut has_more = false; + for page in 1..=pages.max(1) { let batch = apis::repository_api::repo_get_all_commits( &configuration, owner, @@ -838,22 +905,25 @@ async fn load_commits_for_ref( None, None, Some(false), - Some(page), - Some(100), + Some(page as i32), + Some(PAGE_SIZE), None, ) .await .map_err(|error| error.to_string())?; - let done = batch.len() < 100; + has_more = batch.len() == PAGE_SIZE as usize; commits.extend(batch); - if done { + if !has_more { break; } } - Ok(commits) + Ok(Page { + items: commits, + has_more, + }) } -pub async fn load_home(server: &Server) -> Result { +pub async fn load_home(server: &Server, page: i32) -> Result { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let configuration = client.configuration(); @@ -863,34 +933,23 @@ pub async fn load_home(server: &Server) -> Result { .map_err(|error| error.to_string())? .login .ok_or("The server account has no username.")?; - let activity_configuration = configuration.clone(); - let activity_login = login.clone(); - let activities = async move { - let mut activities = Vec::new(); - for page in 1.. { - let batch = apis::user_api::user_list_activity_feeds( - &activity_configuration, - &activity_login, - Some(true), - None, - Some(page), - Some(100), - ) - .await - .map_err(|error| error.to_string())?; - if batch.is_empty() { - break; - } - activities.extend(batch); - } - Ok::<_, String>(activities) - }; + let activities = apis::user_api::user_list_activity_feeds( + &configuration, + &login, + Some(true), + None, + Some(page), + Some(PAGE_SIZE), + ); let (activities, heatmap) = tokio::join!( activities, apis::user_api::user_get_heatmap_data(&configuration, &login), ); Ok(HomeData { - activities: activities?, + has_more: activities + .as_ref() + .is_ok_and(|items| items.len() == PAGE_SIZE as usize), + activities: activities.map_err(|error| error.to_string())?, heatmap: heatmap.map_err(|error| error.to_string())?, }) } diff --git a/crates/app/src/domain.rs b/crates/app/src/domain.rs index a755032..15164e3 100644 --- a/crates/app/src/domain.rs +++ b/crates/app/src/domain.rs @@ -3,6 +3,22 @@ use std::collections::{BTreeMap, BTreeSet}; use gotcha_gitea::models; use serde::{Deserialize, Serialize}; +pub const PAGE_SIZE: i32 = 30; + +pub struct Page { + pub items: Vec, + pub has_more: bool, +} + +impl Page { + pub fn from_items(items: Vec) -> Self { + Self { + has_more: items.len() == PAGE_SIZE as usize, + items, + } + } +} + #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "lowercase")] pub enum AppearanceMode { @@ -132,11 +148,13 @@ pub struct State { pub struct HomeData { pub activities: Vec, pub heatmap: Vec, + pub has_more: bool, } pub struct IssueDetails { pub issue: models::Issue, pub comments: Vec, + pub has_more: bool, } pub struct IssueEditorData { @@ -157,12 +175,14 @@ pub struct MilestoneDetails { pub milestone: models::Milestone, pub issues: Vec, pub pulls: Vec, + pub has_more: bool, } pub struct PullDetails { pub pull: models::PullRequest, pub comments: Vec, pub files: Vec, + pub has_more: bool, } pub fn open_status() -> String { @@ -244,4 +264,10 @@ mod tests { assert_eq!(parse_api_date("2023-02-29T00:00:00Z"), None); assert_eq!(parse_api_date("not-a-date"), None); } + + #[test] + fn full_api_pages_expose_more_results() { + assert!(Page::from_items(vec![(); PAGE_SIZE as usize]).has_more); + assert!(!Page::from_items(vec![(); PAGE_SIZE as usize - 1]).has_more); + } } diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 9bfd006..7400dee 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -173,18 +173,36 @@ impl GotchaCore { save_preferences(&state.preferences).map_err(Into::into) } - pub async fn home(&self) -> Result { + pub async fn home(&self, page: u32) -> Result { let server = self.server()?; let name = server.name.clone(); - Ok(home_page(name, load_home(&server).await?)) + Ok(home_page( + name, + load_home(&server, valid_page(page)?).await?, + )) } - pub async fn repositories(&self) -> Result, GotchaError> { + pub async fn repositories(&self, page: u32) -> Result { let server = self.server()?; - let repositories = load_repositories(&server).await?; + let page_number = valid_page(page)?; + let repositories = load_repositories(&server, page_number).await?; let mut state = self.state.lock().unwrap(); - state.repositories = repositories; - Ok(self.repository_rows(&state)) + if page_number == 1 { + state.repositories.clear(); + } + for repository in repositories.items { + if let Some(existing) = state.repositories.iter_mut().find(|candidate| { + candidate.owner == repository.owner && candidate.name == repository.name + }) { + *existing = repository; + } else { + state.repositories.push(repository); + } + } + Ok(RepositoryListPage { + rows: self.repository_rows(&state), + has_more: repositories.has_more, + }) } pub fn toggle_favorite( @@ -209,7 +227,8 @@ impl GotchaCore { &self, owner: String, repository: String, - ) -> Result, GotchaError> { + page: u32, + ) -> Result { let server = self.server()?; let (status, filter) = { let state = self.state.lock().unwrap(); @@ -224,17 +243,20 @@ impl GotchaCore { ) }; let labels: Vec<_> = filter.labels.into_iter().collect(); - Ok(issue_rows( - &load_issues( - &server, - &owner, - &repository, - &status, - &labels, - &filter.milestone, - ) - .await?, - )) + let page = load_issues( + &server, + &owner, + &repository, + &status, + &labels, + &filter.milestone, + valid_page(page)?, + ) + .await?; + Ok(IssueListPage { + rows: issue_rows(&page.items), + has_more: page.has_more, + }) } pub async fn issue_filters( @@ -317,9 +339,17 @@ impl GotchaCore { owner: String, repository: String, number: i64, + page: u32, ) -> Result { Ok(issue_page( - load_issue(&self.server()?, &owner, &repository, number).await?, + load_issue( + &self.server()?, + &owner, + &repository, + number, + valid_page(page)?, + ) + .await?, )) } @@ -387,10 +417,14 @@ impl GotchaCore { &self, owner: String, repository: String, - ) -> Result, GotchaError> { - Ok(milestone_rows( - &load_milestones(&self.server()?, &owner, &repository).await?, - )) + page: u32, + ) -> Result { + let page = + load_milestones_page(&self.server()?, &owner, &repository, valid_page(page)?).await?; + Ok(MilestoneListPage { + rows: milestone_rows(&page.items), + has_more: page.has_more, + }) } pub async fn milestone( @@ -398,9 +432,10 @@ impl GotchaCore { owner: String, repository: String, id: i64, + page: u32, ) -> Result { Ok(milestone_page( - load_milestone(&self.server()?, &owner, &repository, id).await?, + load_milestone(&self.server()?, &owner, &repository, id, valid_page(page)?).await?, )) } @@ -449,7 +484,7 @@ impl GotchaCore { save_preferences(&state.preferences).map_err(Into::into) } - pub async fn pulls(&self) -> Result, GotchaError> { + pub async fn pulls(&self, page: u32) -> Result { let server = self.server()?; let (status, filter) = { let state = self.state.lock().unwrap(); @@ -463,9 +498,11 @@ impl GotchaCore { .unwrap_or_default(), ) }; - Ok(pull_rows( - &load_pulls(&server, &status, &filter.milestone).await?, - )) + let page = load_pulls(&server, &status, &filter.milestone, valid_page(page)?).await?; + Ok(PullListPage { + rows: pull_rows(&page.items), + has_more: page.has_more, + }) } pub async fn pull( @@ -473,9 +510,17 @@ impl GotchaCore { owner: String, repository: String, number: i64, + page: u32, ) -> Result { Ok(pull_page( - load_pull(&self.server()?, &owner, &repository, number).await?, + load_pull( + &self.server()?, + &owner, + &repository, + number, + valid_page(page)?, + ) + .await?, )) } @@ -484,6 +529,7 @@ impl GotchaCore { owner: String, repository: String, branch: Option, + pages: u32, ) -> Result { let server = self.server()?; let default_branch = self @@ -497,10 +543,17 @@ impl GotchaCore { .unwrap_or_else(|| "main".into()); let branches = load_branches(&server, &owner, &repository, &default_branch).await?; let commits = match branch.as_deref() { - Some(branch) => load_branch_commits(&server, &owner, &repository, branch).await?, - None => load_all_commits(&server, &owner, &repository, &branches).await?, + Some(branch) => { + load_branch_commits(&server, &owner, &repository, branch, pages.max(1)).await? + } + None => load_all_commits(&server, &owner, &repository, &branches, pages.max(1)).await?, }; - Ok(commit_page(branches, &commits, branch.is_none())) + Ok(commit_page( + branches, + &commits.items, + branch.is_none(), + commits.has_more, + )) } pub async fn repository_contents( @@ -574,6 +627,13 @@ fn validate_repository<'a>( Ok((owner, repository)) } +fn valid_page(page: u32) -> Result { + (page > 0) + .then(|| i32::try_from(page).ok()) + .flatten() + .ok_or_else(|| "Invalid page number.".into()) +} + impl GotchaCore { fn server(&self) -> Result { let state = self.state.lock().unwrap(); diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index f70bd1b..0c78901 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -23,6 +23,12 @@ pub struct RepositoryRow { pub favorite: bool, } +#[derive(Clone, uniffi::Record)] +pub struct RepositoryListPage { + pub rows: Vec, + pub has_more: bool, +} + #[derive(Clone, uniffi::Record)] pub struct LabelRow { pub name: String, @@ -40,6 +46,12 @@ pub struct IssueRow { pub labels: Vec, } +#[derive(Clone, uniffi::Record)] +pub struct IssueListPage { + pub rows: Vec, + pub has_more: bool, +} + #[derive(Clone, uniffi::Record)] pub struct PullRow { pub number: i64, @@ -65,6 +77,7 @@ pub struct IssuePage { pub milestone: String, pub body: String, pub comments: Vec, + pub has_more: bool, } #[derive(Clone, uniffi::Record)] @@ -115,6 +128,13 @@ pub struct MilestonePage { pub milestone: MilestoneRow, pub issues: Vec, pub pulls: Vec, + pub has_more: bool, +} + +#[derive(Clone, uniffi::Record)] +pub struct MilestoneListPage { + pub rows: Vec, + pub has_more: bool, } #[derive(Clone, uniffi::Record)] @@ -131,6 +151,13 @@ pub struct PullPage { pub files_ref: String, pub files: Vec, pub comments: Vec, + pub has_more: bool, +} + +#[derive(Clone, uniffi::Record)] +pub struct PullListPage { + pub rows: Vec, + pub has_more: bool, } #[derive(Clone, uniffi::Record)] @@ -149,6 +176,7 @@ pub struct CommitPage { pub branches: Vec, pub commits: Vec, pub lane_count: u32, + pub has_more: bool, } #[derive(Clone, uniffi::Record)] @@ -157,6 +185,12 @@ pub struct FileRow { pub status: String, } +#[derive(Clone, uniffi::Record)] +pub struct FileListPage { + pub rows: Vec, + pub has_more: bool, +} + #[derive(Clone, uniffi::Record)] pub struct RepositoryContentRow { pub name: String, @@ -216,6 +250,7 @@ pub struct HomePage { pub pull_activities: Vec, pub heat_cells: Vec, pub contribution_count: i64, + pub has_more: bool, } pub fn repository_rows( @@ -323,6 +358,7 @@ pub fn milestone_page(details: MilestoneDetails) -> MilestonePage { milestone: milestone_row(&details.milestone), issues: issue_rows(&details.issues), pulls: pull_rows(&details.pulls), + has_more: details.has_more, } } @@ -389,6 +425,7 @@ pub fn commit_page( branches: Vec, commits: &[HistoryCommit], show_graph: bool, + has_more: bool, ) -> CommitPage { let lane_count = if show_graph { commits @@ -454,6 +491,7 @@ pub fn commit_page( branches, commits, lane_count: lane_count as u32, + has_more, } } @@ -477,6 +515,7 @@ pub fn issue_page(details: IssueDetails) -> IssuePage { .filter(|body| !body.is_empty()) .unwrap_or_else(|| "No description provided.".into()), comments: details.comments.iter().map(comment_row).collect(), + has_more: details.has_more, } } @@ -589,6 +628,7 @@ pub fn pull_page(details: PullDetails) -> PullPage { files_ref: pull_files_ref(pull), files: details.files.iter().filter_map(file_row).collect(), comments: details.comments.iter().map(comment_row).collect(), + has_more: details.has_more, } } @@ -722,6 +762,7 @@ pub fn home_page(server_name: String, home: HomeData) -> HomePage { activities: home.activities.iter().map(activity_row).collect(), heat_cells, contribution_count, + has_more: home.has_more, } } @@ -1213,6 +1254,7 @@ mod tests { }) .collect(), heatmap: Vec::new(), + has_more: false, }, ); @@ -1229,6 +1271,7 @@ mod tests { ..Default::default() }, comments: Vec::new(), + has_more: false, }); assert_eq!(page.state, "closed"); @@ -1331,6 +1374,7 @@ mod tests { })), ..Default::default() }], + has_more: false, }); assert!(page.issues.is_empty()); diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index 5e473b6..364dff4 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -613,11 +613,11 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func commitFiles(owner: String, repository: String, sha: String) async throws -> [FileRow] - func commits(owner: String, repository: String, branch: String?) async throws -> CommitPage + func commits(owner: String, repository: String, branch: String?, pages: UInt32) async throws -> CommitPage - func home() async throws -> HomePage + func home(page: UInt32) async throws -> HomePage - func issue(owner: String, repository: String, number: Int64) async throws -> IssuePage + func issue(owner: String, repository: String, number: Int64, page: UInt32) async throws -> IssuePage func issueEditor(owner: String, repository: String, number: Int64?) async throws -> IssueEditorPage @@ -625,13 +625,13 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func issueFiltersActive(owner: String, repository: String) throws -> Bool - func issues(owner: String, repository: String) async throws -> [IssueRow] + func issues(owner: String, repository: String, page: UInt32) async throws -> IssueListPage - func milestone(owner: String, repository: String, id: Int64) async throws -> MilestonePage + func milestone(owner: String, repository: String, id: Int64, page: UInt32) async throws -> MilestonePage - func milestones(owner: String, repository: String) async throws -> [MilestoneRow] + func milestones(owner: String, repository: String, page: UInt32) async throws -> MilestoneListPage - func pull(owner: String, repository: String, number: Int64) async throws -> PullPage + func pull(owner: String, repository: String, number: Int64, page: UInt32) async throws -> PullPage func pullDiff(owner: String, repository: String, number: Int64, path: String) async throws -> DiffPage @@ -639,9 +639,9 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func pullFiltersActive() throws -> Bool - func pulls() async throws -> [PullRow] + func pulls(page: UInt32) async throws -> PullListPage - func repositories() async throws -> [RepositoryRow] + func repositories(page: UInt32) async throws -> RepositoryListPage func repositoryContents(owner: String, repository: String, path: String) async throws -> [RepositoryContentRow] @@ -797,12 +797,12 @@ open func commitFiles(owner: String, repository: String, sha: String)async throw ) } -open func commits(owner: String, repository: String, branch: String?)async throws -> CommitPage { +open func commits(owner: String, repository: String, branch: String?, pages: UInt32)async throws -> CommitPage { return try await uniffiRustCallAsync( rustFutureFunc: { uniffi_gotcha_core_fn_method_gotchacore_commits( - self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterOptionString.lower(branch) + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterOptionString.lower(branch),FfiConverterUInt32.lower(pages) ) }, pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, @@ -813,12 +813,12 @@ open func commits(owner: String, repository: String, branch: String?)async throw ) } -open func home()async throws -> HomePage { +open func home(page: UInt32)async throws -> HomePage { return try await uniffiRustCallAsync( rustFutureFunc: { uniffi_gotcha_core_fn_method_gotchacore_home( - self.uniffiCloneHandle() + self.uniffiCloneHandle(),FfiConverterUInt32.lower(page) ) }, pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, @@ -829,12 +829,12 @@ open func home()async throws -> HomePage { ) } -open func issue(owner: String, repository: String, number: Int64)async throws -> IssuePage { +open func issue(owner: String, repository: String, number: Int64, page: UInt32)async throws -> IssuePage { return try await uniffiRustCallAsync( rustFutureFunc: { uniffi_gotcha_core_fn_method_gotchacore_issue( - self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(number) + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(number),FfiConverterUInt32.lower(page) ) }, pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, @@ -888,28 +888,28 @@ open func issueFiltersActive(owner: String, repository: String)throws -> Bool }) } -open func issues(owner: String, repository: String)async throws -> [IssueRow] { +open func issues(owner: String, repository: String, page: UInt32)async throws -> IssueListPage { return try await uniffiRustCallAsync( rustFutureFunc: { uniffi_gotcha_core_fn_method_gotchacore_issues( - self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository) + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterUInt32.lower(page) ) }, pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, completeFunc: ffi_gotcha_core_rust_future_complete_rust_buffer, freeFunc: ffi_gotcha_core_rust_future_free_rust_buffer, - liftFunc: FfiConverterSequenceTypeIssueRow.lift, + liftFunc: FfiConverterTypeIssueListPage_lift, errorHandler: FfiConverterTypeGotchaError_lift ) } -open func milestone(owner: String, repository: String, id: Int64)async throws -> MilestonePage { +open func milestone(owner: String, repository: String, id: Int64, page: UInt32)async throws -> MilestonePage { return try await uniffiRustCallAsync( rustFutureFunc: { uniffi_gotcha_core_fn_method_gotchacore_milestone( - self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(id) + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(id),FfiConverterUInt32.lower(page) ) }, pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, @@ -920,28 +920,28 @@ open func milestone(owner: String, repository: String, id: Int64)async throws - ) } -open func milestones(owner: String, repository: String)async throws -> [MilestoneRow] { +open func milestones(owner: String, repository: String, page: UInt32)async throws -> MilestoneListPage { return try await uniffiRustCallAsync( rustFutureFunc: { uniffi_gotcha_core_fn_method_gotchacore_milestones( - self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository) + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterUInt32.lower(page) ) }, pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, completeFunc: ffi_gotcha_core_rust_future_complete_rust_buffer, freeFunc: ffi_gotcha_core_rust_future_free_rust_buffer, - liftFunc: FfiConverterSequenceTypeMilestoneRow.lift, + liftFunc: FfiConverterTypeMilestoneListPage_lift, errorHandler: FfiConverterTypeGotchaError_lift ) } -open func pull(owner: String, repository: String, number: Int64)async throws -> PullPage { +open func pull(owner: String, repository: String, number: Int64, page: UInt32)async throws -> PullPage { return try await uniffiRustCallAsync( rustFutureFunc: { uniffi_gotcha_core_fn_method_gotchacore_pull( - self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(number) + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(number),FfiConverterUInt32.lower(page) ) }, pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, @@ -993,34 +993,34 @@ open func pullFiltersActive()throws -> Bool { }) } -open func pulls()async throws -> [PullRow] { +open func pulls(page: UInt32)async throws -> PullListPage { return try await uniffiRustCallAsync( rustFutureFunc: { uniffi_gotcha_core_fn_method_gotchacore_pulls( - self.uniffiCloneHandle() + self.uniffiCloneHandle(),FfiConverterUInt32.lower(page) ) }, pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, completeFunc: ffi_gotcha_core_rust_future_complete_rust_buffer, freeFunc: ffi_gotcha_core_rust_future_free_rust_buffer, - liftFunc: FfiConverterSequenceTypePullRow.lift, + liftFunc: FfiConverterTypePullListPage_lift, errorHandler: FfiConverterTypeGotchaError_lift ) } -open func repositories()async throws -> [RepositoryRow] { +open func repositories(page: UInt32)async throws -> RepositoryListPage { return try await uniffiRustCallAsync( rustFutureFunc: { uniffi_gotcha_core_fn_method_gotchacore_repositories( - self.uniffiCloneHandle() + self.uniffiCloneHandle(),FfiConverterUInt32.lower(page) ) }, pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, completeFunc: ffi_gotcha_core_rust_future_complete_rust_buffer, freeFunc: ffi_gotcha_core_rust_future_free_rust_buffer, - liftFunc: FfiConverterSequenceTypeRepositoryRow.lift, + liftFunc: FfiConverterTypeRepositoryListPage_lift, errorHandler: FfiConverterTypeGotchaError_lift ) } @@ -1360,13 +1360,15 @@ public struct CommitPage: Equatable, Hashable { public var branches: [String] public var commits: [CommitRow] public var laneCount: UInt32 + public var hasMore: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(branches: [String], commits: [CommitRow], laneCount: UInt32) { + public init(branches: [String], commits: [CommitRow], laneCount: UInt32, hasMore: Bool) { self.branches = branches self.commits = commits self.laneCount = laneCount + self.hasMore = hasMore } @@ -1387,7 +1389,8 @@ public struct FfiConverterTypeCommitPage: FfiConverterRustBuffer { try CommitPage( branches: FfiConverterSequenceString.read(from: &buf), commits: FfiConverterSequenceTypeCommitRow.read(from: &buf), - laneCount: FfiConverterUInt32.read(from: &buf) + laneCount: FfiConverterUInt32.read(from: &buf), + hasMore: FfiConverterBool.read(from: &buf) ) } @@ -1395,6 +1398,7 @@ public struct FfiConverterTypeCommitPage: FfiConverterRustBuffer { FfiConverterSequenceString.write(value.branches, into: &buf) FfiConverterSequenceTypeCommitRow.write(value.commits, into: &buf) FfiConverterUInt32.write(value.laneCount, into: &buf) + FfiConverterBool.write(value.hasMore, into: &buf) } } @@ -1608,6 +1612,60 @@ public func FfiConverterTypeDiffPage_lower(_ value: DiffPage) -> RustBuffer { } +public struct FileListPage: Equatable, Hashable { + public var rows: [FileRow] + public var hasMore: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(rows: [FileRow], hasMore: Bool) { + self.rows = rows + self.hasMore = hasMore + } + + + + +} + +#if compiler(>=6) +extension FileListPage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeFileListPage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> FileListPage { + return + try FileListPage( + rows: FfiConverterSequenceTypeFileRow.read(from: &buf), + hasMore: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: FileListPage, into buf: inout [UInt8]) { + FfiConverterSequenceTypeFileRow.write(value.rows, into: &buf) + FfiConverterBool.write(value.hasMore, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeFileListPage_lift(_ buf: RustBuffer) throws -> FileListPage { + return try FfiConverterTypeFileListPage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeFileListPage_lower(_ value: FileListPage) -> RustBuffer { + return FfiConverterTypeFileListPage.lower(value) +} + + public struct FileRow: Equatable, Hashable { public var path: String public var status: String @@ -1723,16 +1781,18 @@ public struct HomePage: Equatable, Hashable { public var pullActivities: [ActivityRow] public var heatCells: [HeatCell] public var contributionCount: Int64 + public var hasMore: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(serverName: String, activities: [ActivityRow], issueActivities: [ActivityRow], pullActivities: [ActivityRow], heatCells: [HeatCell], contributionCount: Int64) { + public init(serverName: String, activities: [ActivityRow], issueActivities: [ActivityRow], pullActivities: [ActivityRow], heatCells: [HeatCell], contributionCount: Int64, hasMore: Bool) { self.serverName = serverName self.activities = activities self.issueActivities = issueActivities self.pullActivities = pullActivities self.heatCells = heatCells self.contributionCount = contributionCount + self.hasMore = hasMore } @@ -1756,7 +1816,8 @@ public struct FfiConverterTypeHomePage: FfiConverterRustBuffer { issueActivities: FfiConverterSequenceTypeActivityRow.read(from: &buf), pullActivities: FfiConverterSequenceTypeActivityRow.read(from: &buf), heatCells: FfiConverterSequenceTypeHeatCell.read(from: &buf), - contributionCount: FfiConverterInt64.read(from: &buf) + contributionCount: FfiConverterInt64.read(from: &buf), + hasMore: FfiConverterBool.read(from: &buf) ) } @@ -1767,6 +1828,7 @@ public struct FfiConverterTypeHomePage: FfiConverterRustBuffer { FfiConverterSequenceTypeActivityRow.write(value.pullActivities, into: &buf) FfiConverterSequenceTypeHeatCell.write(value.heatCells, into: &buf) FfiConverterInt64.write(value.contributionCount, into: &buf) + FfiConverterBool.write(value.hasMore, into: &buf) } } @@ -2034,6 +2096,60 @@ public func FfiConverterTypeIssueFilterOptions_lower(_ value: IssueFilterOptions } +public struct IssueListPage: Equatable, Hashable { + public var rows: [IssueRow] + public var hasMore: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(rows: [IssueRow], hasMore: Bool) { + self.rows = rows + self.hasMore = hasMore + } + + + + +} + +#if compiler(>=6) +extension IssueListPage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeIssueListPage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> IssueListPage { + return + try IssueListPage( + rows: FfiConverterSequenceTypeIssueRow.read(from: &buf), + hasMore: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: IssueListPage, into buf: inout [UInt8]) { + FfiConverterSequenceTypeIssueRow.write(value.rows, into: &buf) + FfiConverterBool.write(value.hasMore, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeIssueListPage_lift(_ buf: RustBuffer) throws -> IssueListPage { + return try FfiConverterTypeIssueListPage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeIssueListPage_lower(_ value: IssueListPage) -> RustBuffer { + return FfiConverterTypeIssueListPage.lower(value) +} + + public struct IssuePage: Equatable, Hashable { public var title: String public var state: String @@ -2041,16 +2157,18 @@ public struct IssuePage: Equatable, Hashable { public var milestone: String public var body: String public var comments: [CommentRow] + public var hasMore: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(title: String, state: String, meta: String, milestone: String, body: String, comments: [CommentRow]) { + public init(title: String, state: String, meta: String, milestone: String, body: String, comments: [CommentRow], hasMore: Bool) { self.title = title self.state = state self.meta = meta self.milestone = milestone self.body = body self.comments = comments + self.hasMore = hasMore } @@ -2074,7 +2192,8 @@ public struct FfiConverterTypeIssuePage: FfiConverterRustBuffer { meta: FfiConverterString.read(from: &buf), milestone: FfiConverterString.read(from: &buf), body: FfiConverterString.read(from: &buf), - comments: FfiConverterSequenceTypeCommentRow.read(from: &buf) + comments: FfiConverterSequenceTypeCommentRow.read(from: &buf), + hasMore: FfiConverterBool.read(from: &buf) ) } @@ -2085,6 +2204,7 @@ public struct FfiConverterTypeIssuePage: FfiConverterRustBuffer { FfiConverterString.write(value.milestone, into: &buf) FfiConverterString.write(value.body, into: &buf) FfiConverterSequenceTypeCommentRow.write(value.comments, into: &buf) + FfiConverterBool.write(value.hasMore, into: &buf) } } @@ -2232,17 +2352,73 @@ public func FfiConverterTypeLabelRow_lower(_ value: LabelRow) -> RustBuffer { } +public struct MilestoneListPage: Equatable, Hashable { + public var rows: [MilestoneRow] + public var hasMore: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(rows: [MilestoneRow], hasMore: Bool) { + self.rows = rows + self.hasMore = hasMore + } + + + + +} + +#if compiler(>=6) +extension MilestoneListPage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMilestoneListPage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MilestoneListPage { + return + try MilestoneListPage( + rows: FfiConverterSequenceTypeMilestoneRow.read(from: &buf), + hasMore: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: MilestoneListPage, into buf: inout [UInt8]) { + FfiConverterSequenceTypeMilestoneRow.write(value.rows, into: &buf) + FfiConverterBool.write(value.hasMore, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMilestoneListPage_lift(_ buf: RustBuffer) throws -> MilestoneListPage { + return try FfiConverterTypeMilestoneListPage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMilestoneListPage_lower(_ value: MilestoneListPage) -> RustBuffer { + return FfiConverterTypeMilestoneListPage.lower(value) +} + + public struct MilestonePage: Equatable, Hashable { public var milestone: MilestoneRow public var issues: [IssueRow] public var pulls: [PullRow] + public var hasMore: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(milestone: MilestoneRow, issues: [IssueRow], pulls: [PullRow]) { + public init(milestone: MilestoneRow, issues: [IssueRow], pulls: [PullRow], hasMore: Bool) { self.milestone = milestone self.issues = issues self.pulls = pulls + self.hasMore = hasMore } @@ -2263,7 +2439,8 @@ public struct FfiConverterTypeMilestonePage: FfiConverterRustBuffer { try MilestonePage( milestone: FfiConverterTypeMilestoneRow.read(from: &buf), issues: FfiConverterSequenceTypeIssueRow.read(from: &buf), - pulls: FfiConverterSequenceTypePullRow.read(from: &buf) + pulls: FfiConverterSequenceTypePullRow.read(from: &buf), + hasMore: FfiConverterBool.read(from: &buf) ) } @@ -2271,6 +2448,7 @@ public struct FfiConverterTypeMilestonePage: FfiConverterRustBuffer { FfiConverterTypeMilestoneRow.write(value.milestone, into: &buf) FfiConverterSequenceTypeIssueRow.write(value.issues, into: &buf) FfiConverterSequenceTypePullRow.write(value.pulls, into: &buf) + FfiConverterBool.write(value.hasMore, into: &buf) } } @@ -2418,6 +2596,60 @@ public func FfiConverterTypePullFilterOptions_lower(_ value: PullFilterOptions) } +public struct PullListPage: Equatable, Hashable { + public var rows: [PullRow] + public var hasMore: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(rows: [PullRow], hasMore: Bool) { + self.rows = rows + self.hasMore = hasMore + } + + + + +} + +#if compiler(>=6) +extension PullListPage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePullListPage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PullListPage { + return + try PullListPage( + rows: FfiConverterSequenceTypePullRow.read(from: &buf), + hasMore: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: PullListPage, into buf: inout [UInt8]) { + FfiConverterSequenceTypePullRow.write(value.rows, into: &buf) + FfiConverterBool.write(value.hasMore, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePullListPage_lift(_ buf: RustBuffer) throws -> PullListPage { + return try FfiConverterTypePullListPage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePullListPage_lower(_ value: PullListPage) -> RustBuffer { + return FfiConverterTypePullListPage.lower(value) +} + + public struct PullPage: Equatable, Hashable { public var title: String public var meta: String @@ -2425,16 +2657,18 @@ public struct PullPage: Equatable, Hashable { public var filesRef: String public var files: [FileRow] public var comments: [CommentRow] + public var hasMore: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(title: String, meta: String, body: String, filesRef: String, files: [FileRow], comments: [CommentRow]) { + public init(title: String, meta: String, body: String, filesRef: String, files: [FileRow], comments: [CommentRow], hasMore: Bool) { self.title = title self.meta = meta self.body = body self.filesRef = filesRef self.files = files self.comments = comments + self.hasMore = hasMore } @@ -2458,7 +2692,8 @@ public struct FfiConverterTypePullPage: FfiConverterRustBuffer { body: FfiConverterString.read(from: &buf), filesRef: FfiConverterString.read(from: &buf), files: FfiConverterSequenceTypeFileRow.read(from: &buf), - comments: FfiConverterSequenceTypeCommentRow.read(from: &buf) + comments: FfiConverterSequenceTypeCommentRow.read(from: &buf), + hasMore: FfiConverterBool.read(from: &buf) ) } @@ -2469,6 +2704,7 @@ public struct FfiConverterTypePullPage: FfiConverterRustBuffer { FfiConverterString.write(value.filesRef, into: &buf) FfiConverterSequenceTypeFileRow.write(value.files, into: &buf) FfiConverterSequenceTypeCommentRow.write(value.comments, into: &buf) + FfiConverterBool.write(value.hasMore, into: &buf) } } @@ -2686,6 +2922,60 @@ public func FfiConverterTypeRepositoryFilePage_lower(_ value: RepositoryFilePage } +public struct RepositoryListPage: Equatable, Hashable { + public var rows: [RepositoryRow] + public var hasMore: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(rows: [RepositoryRow], hasMore: Bool) { + self.rows = rows + self.hasMore = hasMore + } + + + + +} + +#if compiler(>=6) +extension RepositoryListPage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeRepositoryListPage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> RepositoryListPage { + return + try RepositoryListPage( + rows: FfiConverterSequenceTypeRepositoryRow.read(from: &buf), + hasMore: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: RepositoryListPage, into buf: inout [UInt8]) { + FfiConverterSequenceTypeRepositoryRow.write(value.rows, into: &buf) + FfiConverterBool.write(value.hasMore, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeRepositoryListPage_lift(_ buf: RustBuffer) throws -> RepositoryListPage { + return try FfiConverterTypeRepositoryListPage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeRepositoryListPage_lower(_ value: RepositoryListPage) -> RustBuffer { + return FfiConverterTypeRepositoryListPage.lower(value) +} + + public struct RepositoryRow: Equatable, Hashable { public var name: String public var owner: String @@ -3538,13 +3828,13 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_commit_files() != 43926) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_commits() != 472) { + if (uniffi_gotcha_core_checksum_method_gotchacore_commits() != 59625) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_home() != 5983) { + if (uniffi_gotcha_core_checksum_method_gotchacore_home() != 38990) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_issue() != 56735) { + if (uniffi_gotcha_core_checksum_method_gotchacore_issue() != 3108) { return InitializationResult.apiChecksumMismatch } if (uniffi_gotcha_core_checksum_method_gotchacore_issue_editor() != 10078) { @@ -3556,16 +3846,16 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_issue_filters_active() != 51470) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_issues() != 1316) { + if (uniffi_gotcha_core_checksum_method_gotchacore_issues() != 8566) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_milestone() != 26979) { + if (uniffi_gotcha_core_checksum_method_gotchacore_milestone() != 57186) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_milestones() != 27282) { + if (uniffi_gotcha_core_checksum_method_gotchacore_milestones() != 50431) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_pull() != 46939) { + if (uniffi_gotcha_core_checksum_method_gotchacore_pull() != 12919) { return InitializationResult.apiChecksumMismatch } if (uniffi_gotcha_core_checksum_method_gotchacore_pull_diff() != 61384) { @@ -3577,10 +3867,10 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_pull_filters_active() != 20260) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_pulls() != 13092) { + if (uniffi_gotcha_core_checksum_method_gotchacore_pulls() != 37027) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_repositories() != 12256) { + if (uniffi_gotcha_core_checksum_method_gotchacore_repositories() != 31164) { return InitializationResult.apiChecksumMismatch } if (uniffi_gotcha_core_checksum_method_gotchacore_repository_contents() != 8454) { diff --git a/ios/Generated/gotcha_coreFFI.h b/ios/Generated/gotcha_coreFFI.h index 9bc3d49..dc1a48c 100644 --- a/ios/Generated/gotcha_coreFFI.h +++ b/ios/Generated/gotcha_coreFFI.h @@ -286,17 +286,17 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_commit_files(uint64_t ptr, Rust #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMITS #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMITS -uint64_t uniffi_gotcha_core_fn_method_gotchacore_commits(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer branch +uint64_t uniffi_gotcha_core_fn_method_gotchacore_commits(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer branch, uint32_t pages ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME -uint64_t uniffi_gotcha_core_fn_method_gotchacore_home(uint64_t ptr +uint64_t uniffi_gotcha_core_fn_method_gotchacore_home(uint64_t ptr, uint32_t page ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE -uint64_t uniffi_gotcha_core_fn_method_gotchacore_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number +uint64_t uniffi_gotcha_core_fn_method_gotchacore_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, uint32_t page ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE_EDITOR @@ -316,22 +316,22 @@ int8_t uniffi_gotcha_core_fn_method_gotchacore_issue_filters_active(uint64_t ptr #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUES #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUES -uint64_t uniffi_gotcha_core_fn_method_gotchacore_issues(uint64_t ptr, RustBuffer owner, RustBuffer repository +uint64_t uniffi_gotcha_core_fn_method_gotchacore_issues(uint64_t ptr, RustBuffer owner, RustBuffer repository, uint32_t page ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MILESTONE #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MILESTONE -uint64_t uniffi_gotcha_core_fn_method_gotchacore_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t id +uint64_t uniffi_gotcha_core_fn_method_gotchacore_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t id, uint32_t page ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MILESTONES #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MILESTONES -uint64_t uniffi_gotcha_core_fn_method_gotchacore_milestones(uint64_t ptr, RustBuffer owner, RustBuffer repository +uint64_t uniffi_gotcha_core_fn_method_gotchacore_milestones(uint64_t ptr, RustBuffer owner, RustBuffer repository, uint32_t page ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL -uint64_t uniffi_gotcha_core_fn_method_gotchacore_pull(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number +uint64_t uniffi_gotcha_core_fn_method_gotchacore_pull(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, uint32_t page ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULL_DIFF @@ -351,12 +351,12 @@ int8_t uniffi_gotcha_core_fn_method_gotchacore_pull_filters_active(uint64_t ptr, #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULLS #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_PULLS -uint64_t uniffi_gotcha_core_fn_method_gotchacore_pulls(uint64_t ptr +uint64_t uniffi_gotcha_core_fn_method_gotchacore_pulls(uint64_t ptr, uint32_t page ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORIES #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORIES -uint64_t uniffi_gotcha_core_fn_method_gotchacore_repositories(uint64_t ptr +uint64_t uniffi_gotcha_core_fn_method_gotchacore_repositories(uint64_t ptr, uint32_t page ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_CONTENTS diff --git a/ios/Sources/ContentScreens.swift b/ios/Sources/ContentScreens.swift index eb84100..8c5f66a 100644 --- a/ios/Sources/ContentScreens.swift +++ b/ios/Sources/ContentScreens.swift @@ -8,6 +8,7 @@ final class IssuesViewController: RefreshingTableViewController { private var rows: [IssueRow] = [] private var filterOptions: IssueFilterOptions? private var filterTask: Task? + private var currentPage: UInt32 = 0 private lazy var filterButton = UIBarButtonItem( image: context.symbol("line.3.horizontal.decrease.circle") ) @@ -45,17 +46,38 @@ final class IssuesViewController: RefreshingTableViewController { } private func loadIssues(refreshing: Bool) { - beginLoading(refreshing: refreshing) + loadIssues(page: 1, refreshing: refreshing) + } + + override func loadMoreContent() { + loadIssues(page: currentPage + 1, refreshing: false) + } + + private func loadIssues(page: UInt32, refreshing: Bool) { + if page == 1 { + resetPagination() + beginLoading(refreshing: refreshing) + } loadingTask?.cancel() loadingTask = Task { do { - rows = try await context.core.issues(owner: owner, repository: repository) + let result = try await context.core.issues( + owner: owner, + repository: repository, + page: page + ) + if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) } + currentPage = page + finishPagination(hasMore: result.hasMore) tableView.reloadData() updateEmptyState() } catch { - if !Task.isCancelled { show(error: error) } + if !Task.isCancelled { + show(error: error) + failPagination() + } } - endLoading() + if page == 1 { endLoading() } } } @@ -335,6 +357,7 @@ final class MilestonesViewController: RefreshingTableViewController { private let owner: String private let repository: String private var rows: [MilestoneRow] = [] + private var currentPage: UInt32 = 0 init(context: AppContext, owner: String, repository: String) { self.context = context @@ -356,11 +379,29 @@ final class MilestonesViewController: RefreshingTableViewController { } override func loadContent(refreshing: Bool) { - beginLoading(refreshing: refreshing) + loadPage(1, refreshing: refreshing) + } + + override func loadMoreContent() { + loadPage(currentPage + 1, refreshing: false) + } + + private func loadPage(_ page: UInt32, refreshing: Bool) { + if page == 1 { + resetPagination() + beginLoading(refreshing: refreshing) + } loadingTask?.cancel() loadingTask = Task { do { - rows = try await context.core.milestones(owner: owner, repository: repository) + let result = try await context.core.milestones( + owner: owner, + repository: repository, + page: page + ) + if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) } + currentPage = page + finishPagination(hasMore: result.hasMore) tableView.reloadData() tableView.backgroundView = rows.isEmpty ? EmptyBackgroundView( @@ -369,9 +410,12 @@ final class MilestonesViewController: RefreshingTableViewController { ) : nil } catch { - if !Task.isCancelled { show(error: error) } + if !Task.isCancelled { + show(error: error) + failPagination() + } } - endLoading() + if page == 1 { endLoading() } } } @@ -412,6 +456,7 @@ final class MilestoneViewController: RefreshingTableViewController { private let repository: String private let id: Int64 private var page: MilestonePage? + private var currentPage: UInt32 = 0 init(context: AppContext, owner: String, repository: String, id: Int64) { self.context = context @@ -435,21 +480,45 @@ final class MilestoneViewController: RefreshingTableViewController { } override func loadContent(refreshing: Bool) { - beginLoading(refreshing: refreshing) + loadPage(1, refreshing: refreshing) + } + + override func loadMoreContent() { + loadPage(currentPage + 1, refreshing: false) + } + + private func loadPage(_ requestedPage: UInt32, refreshing: Bool) { + if requestedPage == 1 { + resetPagination() + beginLoading(refreshing: refreshing) + } loadingTask?.cancel() loadingTask = Task { do { - page = try await context.core.milestone( + let result = try await context.core.milestone( owner: owner, repository: repository, - id: id + id: id, + page: requestedPage ) + if requestedPage == 1 { + page = result + } else { + page?.issues.append(contentsOf: result.issues) + page?.pulls.append(contentsOf: result.pulls) + page?.hasMore = result.hasMore + } + currentPage = requestedPage + finishPagination(hasMore: result.hasMore) title = page?.milestone.title tableView.reloadData() } catch { - if !Task.isCancelled { show(error: error) } + if !Task.isCancelled { + show(error: error) + failPagination() + } } - endLoading() + if requestedPage == 1 { endLoading() } } } @@ -584,6 +653,7 @@ final class PullsViewController: RefreshingTableViewController { private var rows: [PullRow] = [] private var filterOptions: PullFilterOptions? private var filterTask: Task? + private var currentPage: UInt32 = 0 init(context: AppContext) { self.context = context @@ -626,11 +696,25 @@ final class PullsViewController: RefreshingTableViewController { } private func loadPulls(refreshing: Bool) { - beginLoading(refreshing: refreshing) + loadPulls(page: 1, refreshing: refreshing) + } + + override func loadMoreContent() { + loadPulls(page: currentPage + 1, refreshing: false) + } + + private func loadPulls(page: UInt32, refreshing: Bool) { + if page == 1 { + resetPagination() + beginLoading(refreshing: refreshing) + } loadingTask?.cancel() loadingTask = Task { do { - rows = try await context.core.pulls() + let result = try await context.core.pulls(page: page) + if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) } + currentPage = page + finishPagination(hasMore: result.hasMore) tableView.reloadData() let status = context.core.settings().pullStatus tableView.backgroundView = rows.isEmpty @@ -642,9 +726,12 @@ final class PullsViewController: RefreshingTableViewController { ) : nil } catch { - if !Task.isCancelled { show(error: error) } + if !Task.isCancelled { + show(error: error) + failPagination() + } } - endLoading() + if page == 1 { endLoading() } } } @@ -783,6 +870,7 @@ final class CommitsViewController: RefreshingTableViewController { private var contents: [RepositoryContentRow] = [] private var branch: String? private var mode = Mode.history + private var currentPage: UInt32 = 0 init(context: AppContext, owner: String, repository: String) { self.context = context @@ -808,7 +896,19 @@ final class CommitsViewController: RefreshingTableViewController { } override func loadContent(refreshing: Bool) { - beginLoading(refreshing: refreshing) + loadPage(1, refreshing: refreshing) + } + + override func loadMoreContent() { + guard mode == .history else { return } + loadPage(currentPage + 1, refreshing: false) + } + + private func loadPage(_ requestedPage: UInt32, refreshing: Bool) { + if requestedPage == 1 { + resetPagination() + beginLoading(refreshing: refreshing) + } loadingTask?.cancel() loadingTask = Task { do { @@ -817,8 +917,11 @@ final class CommitsViewController: RefreshingTableViewController { page = try await context.core.commits( owner: owner, repository: repository, - branch: branch + branch: branch, + pages: requestedPage ) + currentPage = requestedPage + finishPagination(hasMore: page?.hasMore ?? false) updateBranchMenu() case .files: contents = try await context.core.repositoryContents( @@ -830,9 +933,12 @@ final class CommitsViewController: RefreshingTableViewController { tableView.reloadData() updateEmptyView() } catch { - if !Task.isCancelled { show(error: error) } + if !Task.isCancelled { + show(error: error) + failPagination() + } } - endLoading() + if requestedPage == 1 { endLoading() } } } diff --git a/ios/Sources/DetailScreens.swift b/ios/Sources/DetailScreens.swift index a222cb1..f544528 100644 --- a/ios/Sources/DetailScreens.swift +++ b/ios/Sources/DetailScreens.swift @@ -5,12 +5,18 @@ import SwiftUI import UIKit @MainActor -class MarkdownPageViewController: UIViewController { +class MarkdownPageViewController: UIViewController, UIScrollViewDelegate { let context: AppContext let scrollView = UIScrollView() let stack = UIStackView() private let spinner = UIActivityIndicatorView(style: .medium) var loadingTask: Task? + private lazy var moreButton = UIButton( + configuration: .plain(), + primaryAction: UIAction { [weak self] _ in self?.requestMoreContent() } + ) + private var hasMoreContent = false + private var loadingMore = false init(context: AppContext) { self.context = context @@ -25,6 +31,7 @@ class MarkdownPageViewController: UIViewController { view.backgroundColor = .systemGroupedBackground scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.alwaysBounceVertical = true + scrollView.delegate = self scrollView.refreshControl = UIRefreshControl() scrollView.refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged) view.addSubview(scrollView) @@ -49,6 +56,8 @@ class MarkdownPageViewController: UIViewController { func loadContent(refreshing: Bool) {} + func loadMoreContent() {} + func beginLoading(refreshing: Bool) { if !refreshing { beginNavigationLoading(spinner) } } @@ -63,6 +72,54 @@ class MarkdownPageViewController: UIViewController { views.forEach(stack.addArrangedSubview) } + func resetPagination() { + hasMoreContent = false + loadingMore = false + stack.removeArrangedSubview(moreButton) + moreButton.removeFromSuperview() + } + + func finishPagination(hasMore: Bool) { + hasMoreContent = hasMore + loadingMore = false + guard hasMore else { return } + var configuration = moreButton.configuration + configuration?.title = "Pull up or tap to load more" + configuration?.showsActivityIndicator = false + moreButton.configuration = configuration + moreButton.accessibilityLabel = "Load more results" + if moreButton.superview !== stack { stack.addArrangedSubview(moreButton) } + if moreButton.constraints.isEmpty { + moreButton.heightAnchor.constraint(equalToConstant: 50).isActive = true + } + } + + func failPagination() { + loadingMore = false + finishPagination(hasMore: hasMoreContent) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard scrollView.isDragging, hasMoreContent, !loadingMore else { return } + let bottom = max( + -scrollView.adjustedContentInset.top, + scrollView.contentSize.height + + scrollView.adjustedContentInset.bottom + - scrollView.bounds.height + ) + if scrollView.contentOffset.y > bottom + 60 { requestMoreContent() } + } + + private func requestMoreContent() { + guard hasMoreContent, !loadingMore else { return } + loadingMore = true + var configuration = moreButton.configuration + configuration?.title = "Loading more…" + configuration?.showsActivityIndicator = true + moreButton.configuration = configuration + loadMoreContent() + } + @objc private func refreshRequested() { loadContent(refreshing: true) } @@ -73,6 +130,8 @@ final class IssueViewController: MarkdownPageViewController { private let owner: String private let repository: String private let number: Int64 + private var page: IssuePage? + private var currentPage: UInt32 = 0 init(context: AppContext, owner: String, repository: String, number: Int64) { self.owner = owner @@ -110,27 +169,52 @@ final class IssueViewController: MarkdownPageViewController { } override func loadContent(refreshing: Bool) { - beginLoading(refreshing: refreshing) + loadPage(1, refreshing: refreshing) + } + + override func loadMoreContent() { + loadPage(currentPage + 1, refreshing: false) + } + + private func loadPage(_ requestedPage: UInt32, refreshing: Bool) { + if requestedPage == 1 { + resetPagination() + beginLoading(refreshing: refreshing) + } loadingTask?.cancel() loadingTask = Task { do { - let page = try await context.core.issue( + let result = try await context.core.issue( owner: owner, repository: repository, - number: number + number: number, + page: requestedPage ) - replaceContent(detailViews( - title: page.title, - issueState: page.state, - meta: page.meta, - body: page.body, - comments: page.comments, - milestone: page.milestone - )) + if requestedPage == 1 { + page = result + } else { + page?.comments.append(contentsOf: result.comments) + page?.hasMore = result.hasMore + } + currentPage = requestedPage + if let page { + replaceContent(detailViews( + title: page.title, + issueState: page.state, + meta: page.meta, + body: page.body, + comments: page.comments, + milestone: page.milestone + )) + finishPagination(hasMore: page.hasMore) + } } catch { - if !Task.isCancelled { show(error: error) } + if !Task.isCancelled { + show(error: error) + failPagination() + } } - endLoading() + if requestedPage == 1 { endLoading() } } } } @@ -140,6 +224,8 @@ final class PullViewController: MarkdownPageViewController { private let owner: String private let repository: String private let number: Int64 + private var page: PullPage? + private var currentPage: UInt32 = 0 init(context: AppContext, owner: String, repository: String, number: Int64) { self.owner = owner @@ -158,24 +244,45 @@ final class PullViewController: MarkdownPageViewController { } override func loadContent(refreshing: Bool) { - beginLoading(refreshing: refreshing) + loadPage(1, refreshing: refreshing) + } + + override func loadMoreContent() { + loadPage(currentPage + 1, refreshing: false) + } + + private func loadPage(_ requestedPage: UInt32, refreshing: Bool) { + if requestedPage == 1 { + resetPagination() + beginLoading(refreshing: refreshing) + } loadingTask?.cancel() loadingTask = Task { do { - let page = try await context.core.pull( + let result = try await context.core.pull( owner: owner, repository: repository, - number: number + number: number, + page: requestedPage ) - var views = detailViews( - title: page.title, - meta: page.meta, - body: page.body, - comments: [] - ) - if !page.files.isEmpty { - views.append(sectionLabel(page.filesRef)) - views.append(contentsOf: page.files.map { file in + if requestedPage == 1 { + page = result + } else { + page?.files.append(contentsOf: result.files) + page?.comments.append(contentsOf: result.comments) + page?.hasMore = result.hasMore + } + currentPage = requestedPage + if let page { + var views = detailViews( + title: page.title, + meta: page.meta, + body: page.body, + comments: [] + ) + if !page.files.isEmpty { + views.append(sectionLabel(page.filesRef)) + views.append(contentsOf: page.files.map { file in detailButton(title: file.path, detail: file.status) { [weak self] in guard let self else { return } self.navigationController?.pushViewController( @@ -191,14 +298,19 @@ final class PullViewController: MarkdownPageViewController { animated: true ) } - }) + }) + } + views.append(contentsOf: commentViews(page.comments)) + replaceContent(views) + finishPagination(hasMore: page.hasMore) } - views.append(contentsOf: commentViews(page.comments)) - replaceContent(views) } catch { - if !Task.isCancelled { show(error: error) } + if !Task.isCancelled { + show(error: error) + failPagination() + } } - endLoading() + if requestedPage == 1 { endLoading() } } } } diff --git a/ios/Sources/ListScreens.swift b/ios/Sources/ListScreens.swift index 01026aa..2221763 100644 --- a/ios/Sources/ListScreens.swift +++ b/ios/Sources/ListScreens.swift @@ -188,6 +188,7 @@ final class RepositoriesViewController: RefreshingTableViewController { private let context: AppContext private let mode: RepositoryMode private var rows: [RepositoryRow] = [] + private var currentPage: UInt32 = 0 init(context: AppContext, mode: RepositoryMode) { self.context = context @@ -215,11 +216,25 @@ final class RepositoriesViewController: RefreshingTableViewController { } override func loadContent(refreshing: Bool) { - beginLoading(refreshing: refreshing) + loadPage(1, refreshing: refreshing) + } + + override func loadMoreContent() { + loadPage(currentPage + 1, refreshing: false) + } + + private func loadPage(_ page: UInt32, refreshing: Bool) { + if page == 1 { + resetPagination() + beginLoading(refreshing: refreshing) + } loadingTask?.cancel() loadingTask = Task { do { - rows = try await context.core.repositories() + let result = try await context.core.repositories(page: page) + rows = result.rows + currentPage = page + finishPagination(hasMore: result.hasMore) tableView.reloadData() tableView.backgroundView = rows.isEmpty ? EmptyBackgroundView( @@ -228,9 +243,12 @@ final class RepositoriesViewController: RefreshingTableViewController { ) : nil } catch { - if !Task.isCancelled { show(error: error) } + if !Task.isCancelled { + show(error: error) + failPagination() + } } - endLoading() + if page == 1 { endLoading() } } } @@ -308,6 +326,7 @@ final class HomeViewController: RefreshingTableViewController { private let context: AppContext private var page: HomePage? private var filter = ActivityFilter.all + private var currentPage: UInt32 = 0 private var activities: [ActivityRow] { guard let page else { return [] } @@ -358,24 +377,48 @@ final class HomeViewController: RefreshingTableViewController { refreshControl?.endRefreshing() return } - beginLoading(refreshing: refreshing) + loadPage(1, refreshing: refreshing) + } + + override func loadMoreContent() { + loadPage(currentPage + 1, refreshing: false) + } + + private func loadPage(_ requestedPage: UInt32, refreshing: Bool) { + if requestedPage == 1 { + resetPagination() + beginLoading(refreshing: refreshing) + } loadingTask?.cancel() loadingTask = Task { do { - page = try await context.core.home() + let result = try await context.core.home(page: requestedPage) + if requestedPage == 1 { + page = result + } else { + page?.activities.append(contentsOf: result.activities) + page?.issueActivities.append(contentsOf: result.issueActivities) + page?.pullActivities.append(contentsOf: result.pullActivities) + page?.hasMore = result.hasMore + } + currentPage = requestedPage + finishPagination(hasMore: result.hasMore) title = page?.serverName - tableView.tableHeaderView = page.map { page in + if requestedPage == 1 { tableView.tableHeaderView = page.map { page in HeatmapView(page: page, selectedFilter: filter.rawValue) { [weak self] index in guard let self, let filter = ActivityFilter(rawValue: index) else { return } self.filter = filter self.updateActivities() } - } + } } updateActivities() } catch { - if !Task.isCancelled { show(error: error) } + if !Task.isCancelled { + show(error: error) + failPagination() + } } - endLoading() + if requestedPage == 1 { endLoading() } } } diff --git a/ios/Sources/Support.swift b/ios/Sources/Support.swift index 53e1e97..fa63cfb 100644 --- a/ios/Sources/Support.swift +++ b/ios/Sources/Support.swift @@ -29,6 +29,12 @@ extension UIViewController { class RefreshingTableViewController: UITableViewController { private let spinner = UIActivityIndicatorView(style: .medium) var loadingTask: Task? + private lazy var moreButton = UIButton( + configuration: .plain(), + primaryAction: UIAction { [weak self] _ in self?.requestMoreContent() } + ) + private var hasMoreContent = false + private var loadingMore = false init() { super.init(style: .plain) @@ -46,6 +52,8 @@ class RefreshingTableViewController: UITableViewController { func loadContent(refreshing: Bool) {} + func loadMoreContent() {} + func beginLoading(refreshing: Bool) { if !refreshing { beginNavigationLoading(spinner) } } @@ -55,6 +63,58 @@ class RefreshingTableViewController: UITableViewController { refreshControl?.endRefreshing() } + func resetPagination() { + hasMoreContent = false + loadingMore = false + tableView.tableFooterView = nil + } + + func finishPagination(hasMore: Bool) { + hasMoreContent = hasMore + loadingMore = false + var configuration = moreButton.configuration + configuration?.title = "Pull up or tap to load more" + configuration?.showsActivityIndicator = false + moreButton.configuration = configuration + moreButton.accessibilityLabel = "Load more results" + tableView.tableFooterView = hasMore ? paginationFooter() : nil + } + + func failPagination() { + loadingMore = false + finishPagination(hasMore: hasMoreContent) + } + + override func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard scrollView.isDragging, hasMoreContent, !loadingMore else { return } + let bottom = max( + -scrollView.adjustedContentInset.top, + scrollView.contentSize.height + + scrollView.adjustedContentInset.bottom + - scrollView.bounds.height + ) + if scrollView.contentOffset.y > bottom + 60 { requestMoreContent() } + } + + private func requestMoreContent() { + guard hasMoreContent, !loadingMore else { return } + loadingMore = true + var configuration = moreButton.configuration + configuration?.title = "Loading more…" + configuration?.showsActivityIndicator = true + moreButton.configuration = configuration + moreButton.accessibilityLabel = "Loading more results" + loadMoreContent() + } + + private func paginationFooter() -> UIView { + let footer = UIView(frame: CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 56)) + moreButton.frame = footer.bounds + moreButton.autoresizingMask = [.flexibleWidth, .flexibleHeight] + footer.addSubview(moreButton) + return footer + } + @objc private func refreshRequested() { loadContent(refreshing: true) }