From 0a38dbe9f21ee2523367d936cb4b577c70da3f60 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Fri, 31 Jul 2026 18:19:02 +0200 Subject: [PATCH] Scope repository favorites by pane --- TESTING.md | 8 ++- crates/app/src/lib.rs | 35 +++++++++++-- crates/app/src/storage.rs | 63 +++++++++++++++++++++-- ios/Generated/gotcha_core.swift | 90 ++++++++++++++++++++++++++++++--- ios/Generated/gotcha_coreFFI.h | 4 +- ios/Sources/AppContext.swift | 2 +- ios/Sources/ListScreens.swift | 18 +++---- 7 files changed, 187 insertions(+), 33 deletions(-) diff --git a/TESTING.md b/TESTING.md index 7572e01..337fb4f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -146,7 +146,8 @@ xcrun simctl launch booted de.rfc1437.gotcha - [ ] The repository list shows name, description, language, open count, update date, and current favorite state. -- [ ] Toggle a favorite and confirm it remains after refresh and relaunch. +- [ ] Toggle a favorite and confirm it remains after refresh and relaunch but + does not change the same repository's favorite in Repos or Milestones. - [ ] Open a repository; the issue list defaults to the saved Open/Closed filter. - [ ] Repository issue rows and issue rows inside a milestone show a green open or purple closed icon beside the title; mixed milestone results use the @@ -197,7 +198,8 @@ xcrun simctl launch booted de.rfc1437.gotcha ## Repositories and commits -- [ ] The repository list and favorite behavior match the Issues tab. +- [ ] The repository list and favorite behavior match the Issues tab. Toggle a + favorite and confirm it persists without affecting Issues or Milestones. - [ ] Open a repository; commit history initially selects **All**, visibly shows All in the navigation bar, and includes commits from multiple branches. - [ ] Open the branch menu; All is checked. Select a branch and verify the label, @@ -273,6 +275,8 @@ xcrun simctl launch booted de.rfc1437.gotcha - [ ] Milestones uses the server → repository → milestone flow and includes both open and closed milestones. +- [ ] Toggle a repository favorite and confirm it persists after refresh and + relaunch without affecting the same repository in Issues or Repos. - [ ] Each milestone shows its title, description, state, due date, issue counts, and a green/amber closed/open progress bar. - [ ] Open a milestone and verify every assigned issue and pull request is diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index adc2c35..b5729b4 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -15,6 +15,25 @@ use storage::*; uniffi::setup_scaffolding!(); +#[derive(Clone, Copy, Debug, uniffi::Enum)] +pub enum RepositoryPane { + Issues, + Commits, + Milestones, +} + +impl RepositoryPane { + const ALL: [Self; 3] = [Self::Issues, Self::Commits, Self::Milestones]; + + const fn key(self) -> &'static str { + match self { + Self::Issues => "issues", + Self::Commits => "commits", + Self::Milestones => "milestones", + } + } +} + #[derive(Debug, Error, uniffi::Error)] pub enum GotchaError { #[error("{message}")] @@ -184,7 +203,11 @@ impl GotchaCore { )) } - pub async fn repositories(&self, page: u32) -> Result { + pub async fn repositories( + &self, + page: u32, + pane: RepositoryPane, + ) -> Result { let server = self.server()?; let page_number = valid_page(page)?; let repositories = load_repositories(&server, page_number).await?; @@ -202,7 +225,7 @@ impl GotchaCore { } } Ok(RepositoryListPage { - rows: self.repository_rows(&state), + rows: self.repository_rows(&state, pane), has_more: repositories.has_more, }) } @@ -211,18 +234,19 @@ impl GotchaCore { &self, owner: String, repository: String, + pane: RepositoryPane, ) -> Result, GotchaError> { let mut state = self.state.lock().unwrap(); let server = state .active_server .and_then(|index| state.preferences.servers.get(index)) .ok_or("Select a server first.")?; - let key = favorite_key(&server.url, &owner, &repository); + let key = favorite_key(pane, &server.url, &owner, &repository); if !state.preferences.favorites.remove(&key) { state.preferences.favorites.insert(key); } save_preferences(&state.preferences)?; - Ok(self.repository_rows(&state)) + Ok(self.repository_rows(&state, pane)) } pub async fn issues( @@ -755,7 +779,7 @@ impl GotchaCore { .ok_or_else(|| "Select a server first.".to_string().into()) } - fn repository_rows(&self, state: &State) -> Vec { + fn repository_rows(&self, state: &State, pane: RepositoryPane) -> Vec { let server_url = state .active_server .and_then(|index| state.preferences.servers.get(index)) @@ -763,6 +787,7 @@ impl GotchaCore { .unwrap_or_default(); repository_rows(&state.repositories, |repository| { state.preferences.favorites.contains(&favorite_key( + pane, server_url, &repository.owner, &repository.name, diff --git a/crates/app/src/storage.rs b/crates/app/src/storage.rs index 4ea9483..26e7134 100644 --- a/crates/app/src/storage.rs +++ b/crates/app/src/storage.rs @@ -3,10 +3,17 @@ use std::{env, fs, path::PathBuf}; use gotcha_gitea::Client; use security_framework::passwords::{get_generic_password, set_generic_password}; -use crate::domain::{Preferences, Server, open_status}; +use crate::{ + RepositoryPane, + domain::{Preferences, Server, open_status}, +}; -pub fn favorite_key(server: &str, owner: &str, repository: &str) -> String { - repository_key(server, owner, repository) +pub fn favorite_key(pane: RepositoryPane, server: &str, owner: &str, repository: &str) -> String { + format!( + "{}|{}", + pane.key(), + repository_key(server, owner, repository) + ) } pub fn repository_key(server: &str, owner: &str, repository: &str) -> String { @@ -39,6 +46,7 @@ pub fn load_preferences() -> Result { let mut preferences: Preferences = serde_json::from_slice(&fs::read(&path).map_err(|error| error.to_string())?) .map_err(|error| format!("Cannot read {}: {error}", path.display()))?; + let favorites_migrated = migrate_favorites(&mut preferences.favorites); if !matches!(preferences.issue_status.as_str(), "open" | "closed") { preferences.issue_status = open_status(); } @@ -52,9 +60,29 @@ pub fn load_preferences() -> Result { ) .map_err(|_| format!("The token for {} is not valid text.", server.name))?; } + if favorites_migrated { + save_preferences(&preferences)?; + } Ok(preferences) } +fn migrate_favorites(favorites: &mut std::collections::BTreeSet) -> bool { + let legacy: Vec<_> = favorites + .iter() + .filter(|favorite| { + !RepositoryPane::ALL + .iter() + .any(|pane| favorite.starts_with(&format!("{}|", pane.key()))) + }) + .cloned() + .collect(); + for favorite in &legacy { + favorites.remove(favorite); + favorites.extend(RepositoryPane::ALL.map(|pane| format!("{}|{favorite}", pane.key()))); + } + !legacy.is_empty() +} + pub fn save_preferences(preferences: &Preferences) -> Result<(), String> { let path = preferences_path()?; let parent = path.parent().ok_or("Invalid app data directory.")?; @@ -96,8 +124,13 @@ mod tests { assert!(validate_server("", "https://gitea.example.com", "secret").is_err()); assert!(validate_server("Work", "file:///tmp/gitea", "secret").is_err()); assert_eq!( - favorite_key("https://gitea.example.com", "octo", "demo"), - "https://gitea.example.com|octo/demo" + favorite_key( + RepositoryPane::Issues, + "https://gitea.example.com", + "octo", + "demo" + ), + "issues|https://gitea.example.com|octo/demo" ); let preferences: Preferences = serde_json::from_str( r#"{"issue_filters":{"server|octo/demo":{"milestone":"v1","labels":["bug"]}},"pull_filters":{"server":{"milestone":"v2"}}}"#, @@ -136,4 +169,24 @@ mod tests { ); assert_eq!(crate::domain::AppearanceMode::from_index(3), None); } + + #[test] + fn migrates_global_favorites_to_each_repository_pane() { + let mut favorites = ["https://gitea.example.com|octo/demo".to_string()] + .into_iter() + .collect(); + + assert!(migrate_favorites(&mut favorites)); + assert_eq!( + favorites, + [ + "commits|https://gitea.example.com|octo/demo".to_string(), + "issues|https://gitea.example.com|octo/demo".to_string(), + "milestones|https://gitea.example.com|octo/demo".to_string(), + ] + .into_iter() + .collect() + ); + assert!(!migrate_favorites(&mut favorites)); + } } diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index 28b1f91..ba25237 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -647,7 +647,7 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func pulls(page: UInt32) async throws -> PullListPage - func repositories(page: UInt32) async throws -> RepositoryListPage + func repositories(page: UInt32, pane: RepositoryPane) async throws -> RepositoryListPage func repositoryContents(owner: String, repository: String, path: String) async throws -> [RepositoryContentRow] @@ -677,7 +677,7 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func startupError() -> String? - func toggleFavorite(owner: String, repository: String) throws -> [RepositoryRow] + func toggleFavorite(owner: String, repository: String, pane: RepositoryPane) throws -> [RepositoryRow] } open class GotchaCore: GotchaCoreProtocol, @unchecked Sendable { @@ -1053,12 +1053,12 @@ open func pulls(page: UInt32)async throws -> PullListPage { ) } -open func repositories(page: UInt32)async throws -> RepositoryListPage { +open func repositories(page: UInt32, pane: RepositoryPane)async throws -> RepositoryListPage { return try await uniffiRustCallAsync( rustFutureFunc: { uniffi_gotcha_core_fn_method_gotchacore_repositories( - self.uniffiCloneHandle(),FfiConverterUInt32.lower(page) + self.uniffiCloneHandle(),FfiConverterUInt32.lower(page),FfiConverterTypeRepositoryPane_lower(pane) ) }, pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, @@ -1233,13 +1233,14 @@ open func startupError() -> String? { }) } -open func toggleFavorite(owner: String, repository: String)throws -> [RepositoryRow] { +open func toggleFavorite(owner: String, repository: String, pane: RepositoryPane)throws -> [RepositoryRow] { return try FfiConverterSequenceTypeRepositoryRow.lift(try rustCallWithError(FfiConverterTypeGotchaError_lift) { uniffiCallStatus in uniffi_gotcha_core_fn_method_gotchacore_toggle_favorite( self.uniffiCloneHandle(), FfiConverterString.lower(owner), - FfiConverterString.lower(repository),uniffiCallStatus + FfiConverterString.lower(repository), + FfiConverterTypeRepositoryPane_lower(pane),uniffiCallStatus ) }) } @@ -3443,6 +3444,79 @@ public func FfiConverterTypeHomeActivityFilter_lower(_ value: HomeActivityFilter } + + +public enum RepositoryPane: Equatable, Hashable { + + case issues + case commits + case milestones + + + + + +} + +#if compiler(>=6) +extension RepositoryPane: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeRepositoryPane: FfiConverterRustBuffer { + typealias SwiftType = RepositoryPane + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> RepositoryPane { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .issues + + case 2: return .commits + + case 3: return .milestones + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: RepositoryPane, into buf: inout [UInt8]) { + switch value { + + + case .issues: + writeInt(&buf, Int32(1)) + + + case .commits: + writeInt(&buf, Int32(2)) + + + case .milestones: + writeInt(&buf, Int32(3)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeRepositoryPane_lift(_ buf: RustBuffer) throws -> RepositoryPane { + return try FfiConverterTypeRepositoryPane.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeRepositoryPane_lower(_ value: RepositoryPane) -> RustBuffer { + return FfiConverterTypeRepositoryPane.lower(value) +} + + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -4094,7 +4168,7 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_pulls() != 37027) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_repositories() != 31164) { + if (uniffi_gotcha_core_checksum_method_gotchacore_repositories() != 4035) { return InitializationResult.apiChecksumMismatch } if (uniffi_gotcha_core_checksum_method_gotchacore_repository_contents() != 8454) { @@ -4139,7 +4213,7 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_startup_error() != 56765) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_toggle_favorite() != 26694) { + if (uniffi_gotcha_core_checksum_method_gotchacore_toggle_favorite() != 24958) { return InitializationResult.apiChecksumMismatch } if (uniffi_gotcha_core_checksum_constructor_gotchacore_new() != 35775) { diff --git a/ios/Generated/gotcha_coreFFI.h b/ios/Generated/gotcha_coreFFI.h index 37a40b3..2de98c2 100644 --- a/ios/Generated/gotcha_coreFFI.h +++ b/ios/Generated/gotcha_coreFFI.h @@ -371,7 +371,7 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_pulls(uint64_t ptr, uint32_t pa #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, uint32_t page +uint64_t uniffi_gotcha_core_fn_method_gotchacore_repositories(uint64_t ptr, uint32_t page, RustBuffer pane ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_CONTENTS @@ -446,7 +446,7 @@ RustBuffer uniffi_gotcha_core_fn_method_gotchacore_startup_error(uint64_t ptr, R #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_TOGGLE_FAVORITE #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_TOGGLE_FAVORITE -RustBuffer uniffi_gotcha_core_fn_method_gotchacore_toggle_favorite(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustCallStatus *_Nonnull out_status +RustBuffer uniffi_gotcha_core_fn_method_gotchacore_toggle_favorite(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer pane, RustCallStatus *_Nonnull out_status ); #endif #ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUSTBUFFER_ALLOC diff --git a/ios/Sources/AppContext.swift b/ios/Sources/AppContext.swift index abd139d..a086cb4 100644 --- a/ios/Sources/AppContext.swift +++ b/ios/Sources/AppContext.swift @@ -125,7 +125,7 @@ final class AppContext { UIImage(systemName: name) } - private func repositoryRoot(mode: RepositoryMode) -> UIViewController { + private func repositoryRoot(mode: RepositoryPane) -> UIViewController { if core.activeServerIndex() == nil { return ServersViewController(context: self) } diff --git a/ios/Sources/ListScreens.swift b/ios/Sources/ListScreens.swift index 92d0304..fdd60a4 100644 --- a/ios/Sources/ListScreens.swift +++ b/ios/Sources/ListScreens.swift @@ -1,11 +1,5 @@ import UIKit -enum RepositoryMode { - case issues - case commits - case milestones -} - @MainActor final class ServersViewController: UITableViewController { private let context: AppContext @@ -186,11 +180,11 @@ final class AddServerViewController: UITableViewController, UITextFieldDelegate @MainActor final class RepositoriesViewController: RefreshingTableViewController { private let context: AppContext - private let mode: RepositoryMode + private let mode: RepositoryPane private var rows: [RepositoryRow] = [] private var currentPage: UInt32 = 0 - init(context: AppContext, mode: RepositoryMode) { + init(context: AppContext, mode: RepositoryPane) { self.context = context self.mode = mode super.init() @@ -231,7 +225,7 @@ final class RepositoriesViewController: RefreshingTableViewController { loadingTask?.cancel() loadingTask = Task { do { - let result = try await context.core.repositories(page: page) + let result = try await context.core.repositories(page: page, pane: mode) rows = result.rows currentPage = page finishPagination(hasMore: result.hasMore) @@ -307,7 +301,11 @@ final class RepositoriesViewController: RefreshingTableViewController { private func toggleFavorite(_ row: RepositoryRow) { do { - rows = try context.core.toggleFavorite(owner: row.owner, repository: row.name) + rows = try context.core.toggleFavorite( + owner: row.owner, + repository: row.name, + pane: mode + ) tableView.reloadData() } catch { show(error: error)