diff --git a/AGENTS.md b/AGENTS.md index 9bd63c1..a135eff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,34 @@ cargo test --workspace Do not weaken or skip these gates to make a commit pass. Fix the underlying warning, lint, formatting issue, or test failure. +## Rust and Swift ownership boundary + +Rust owns all application behavior. Put networking, authentication, input +validation, persistence, preference changes, filtering, sorting, aggregation, +parsing, content classification, display-data formatting, and navigation target +derivation in `crates/app`. Cover non-trivial behavior there with Rust tests and +expose view-ready records and operations through UniFFI. + +Swift is the native iOS presentation layer. Limit `ios/Sources` to UIKit and +SwiftUI lifecycle, view-controller navigation, native controls, layout, drawing, +fonts, colors, symbols, accessibility, task cancellation tied to view lifetime, +and Apple presentation integrations such as Quick Look. Swift may map +Rust-provided states to visual treatments and maintain transient control state; +it must not infer domain state from display strings, duplicate Rust +transformations, classify content, or implement persistence and API behavior. + +When changing iOS functionality: + +- Trace the complete flow before editing and implement behavior in Rust first. +- Prefer view-ready Rust records over exposing raw Gitea models or rebuilding + titles, summaries, selections, progress, and categories in Swift. +- Treat a pure Swift helper that does not require an Apple UI framework as a + boundary warning; move it to Rust unless it only calculates view geometry. +- Regenerate and commit the UniFFI Swift and C bindings whenever the exported + Rust interface changes. +- During review, inspect both sides of the bridge and reject new business logic + added to Swift merely because its caller is a view controller. + ## UI verification conventions `TESTING.md` is the source of truth for iOS visual verification and the release diff --git a/crates/app/src/domain.rs b/crates/app/src/domain.rs index 65b26d0..d568faa 100644 --- a/crates/app/src/domain.rs +++ b/crates/app/src/domain.rs @@ -80,6 +80,12 @@ pub struct IssueFilter { pub labels: BTreeSet, } +impl IssueFilter { + pub fn is_active(&self, status: &str) -> bool { + status != "open" || self != &Self::default() + } +} + #[derive(Clone)] pub struct RepositoryData { pub owner: String, @@ -132,3 +138,21 @@ pub struct PullDetails { pub fn open_status() -> String { "open".into() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn issue_filter_default_state_is_owned_by_rust() { + assert!(!IssueFilter::default().is_active("open")); + assert!(IssueFilter::default().is_active("closed")); + assert!( + IssueFilter { + milestone: "v1".into(), + ..Default::default() + } + .is_active("open") + ); + } +} diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 46b4def..8041fc4 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -259,6 +259,26 @@ impl GotchaCore { Ok(issue_filter_options(&labels, &milestones, &filter)) } + pub fn issue_filters_active( + &self, + owner: String, + repository: String, + ) -> Result { + let server = self.server()?; + let state = self.state.lock().unwrap(); + let filter = state + .preferences + .issue_filters + .get(&repository_key( + &server.url, + owner.trim(), + repository.trim(), + )) + .cloned() + .unwrap_or_default(); + Ok(filter.is_active(&state.preferences.issue_status)) + } + pub fn set_issue_filters( &self, owner: String, @@ -381,8 +401,9 @@ impl GotchaCore { owner: String, repository: String, path: String, - ) -> Result, GotchaError> { - Ok(load_repository_file(&self.server()?, &owner, &repository, &path).await?) + ) -> Result { + let data = load_repository_file(&self.server()?, &owner, &repository, &path).await?; + Ok(repository_file_page(&path, data)) } pub async fn commit_files( diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index 205a1c8..3f6b788 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -71,6 +71,7 @@ pub struct IssuePage { pub struct IssueFilterOptions { pub milestones: Vec, pub labels: Vec, + pub unavailable_labels: Vec, pub selected_milestone: String, pub selected_labels: Vec, } @@ -81,8 +82,9 @@ pub struct MilestoneRow { pub title: String, pub description: String, pub meta: String, - pub open_issues: i64, - pub closed_issues: i64, + pub has_issues: bool, + pub progress: f64, + pub progress_accessibility: String, } #[derive(Clone, uniffi::Record)] @@ -105,8 +107,7 @@ pub struct PullPage { pub struct CommitRow { pub sha: String, pub title: String, - pub meta: String, - pub refs: String, + pub detail: String, pub top_lanes: Vec, pub bottom_lanes: Vec, pub node_lane: Option, @@ -134,6 +135,15 @@ pub struct RepositoryContentRow { pub size: i64, } +#[derive(Clone, uniffi::Record)] +pub struct RepositoryFilePage { + pub name: String, + pub kind: String, + pub language: String, + pub text: String, + pub data: Vec, +} + #[derive(Clone, uniffi::Record)] pub struct DiffLine { pub old_number: String, @@ -164,17 +174,16 @@ pub struct ActivityRow { #[derive(Clone, uniffi::Record)] pub struct HeatCell { - pub week: u32, - pub day: u32, pub level: u32, pub timestamp: i64, - pub contributions: i64, } #[derive(Clone, uniffi::Record)] pub struct HomePage { pub server_name: String, pub activities: Vec, + pub issue_activities: Vec, + pub pull_activities: Vec, pub heat_cells: Vec, pub contribution_count: i64, } @@ -237,17 +246,28 @@ pub fn issue_filter_options( .iter() .filter_map(|label| label.name.clone()) .collect(); + let unavailable_labels = filter + .labels + .iter() + .filter(|label| !labels.contains(label)) + .cloned() + .collect(); + labels.extend(filter.labels.iter().cloned()); labels.sort_by_key(|label| label.to_lowercase()); labels.dedup(); let mut milestones: Vec<_> = milestones .iter() .filter_map(|milestone| milestone.title.clone()) .collect(); + if !filter.milestone.is_empty() { + milestones.push(filter.milestone.clone()); + } milestones.sort_by_key(|milestone| milestone.to_lowercase()); milestones.dedup(); IssueFilterOptions { milestones, labels, + unavailable_labels, selected_milestone: filter.milestone.clone(), selected_labels: filter.labels.iter().cloned().collect(), } @@ -299,10 +319,12 @@ pub fn pull_rows(pulls: &[models::Issue]) -> Vec { number: pull.number.unwrap_or_default(), owner: repository.owner.clone()?, repository: repository.name.clone()?, - title: pull - .title - .clone() - .unwrap_or_else(|| "Untitled pull request".into()), + title: format!( + "{} #{}\n{}", + repository.name.as_deref()?, + pull.number.unwrap_or_default(), + pull.title.as_deref().unwrap_or("Untitled pull request") + ), summary: pull .body .as_deref() @@ -367,8 +389,16 @@ pub fn commit_page( .map(|message| message.lines().next().unwrap_or(message)) .unwrap_or("Commit") .into(), - meta: format!("{author} · {}", compact_date(date)), - refs: row.refs.join(" · "), + detail: if row.refs.is_empty() { + format!("{author} · {}\n{}", compact_date(date), short_sha(sha)) + } else { + format!( + "{}\n{author} · {} · {}", + row.refs.join(" · "), + compact_date(date), + short_sha(sha) + ) + }, top_lanes: indices(&row.top_lanes), bottom_lanes: indices(&row.bottom_lanes), node_lane: row.node_lane.map(|lane| lane as u32), @@ -488,6 +518,66 @@ pub fn repository_content_rows( rows } +pub fn repository_file_page(path: &str, data: Vec) -> RepositoryFilePage { + let name = path.rsplit('/').next().unwrap_or("Preview").to_string(); + let extension = path.rsplit('.').next().unwrap_or_default().to_lowercase(); + let language = source_language(path).unwrap_or_default().to_string(); + let markdown = matches!(extension.as_str(), "md" | "markdown" | "mdown" | "mkd"); + let media = matches!( + extension.as_str(), + "apng" + | "avi" + | "bmp" + | "flac" + | "gif" + | "heic" + | "heif" + | "jpeg" + | "jpg" + | "m4a" + | "m4v" + | "mkv" + | "mov" + | "mp3" + | "mp4" + | "mpeg" + | "mpg" + | "ogg" + | "pdf" + | "png" + | "tif" + | "tiff" + | "wav" + | "webm" + | "webp" + ); + if media { + return RepositoryFilePage { + name, + kind: "preview".into(), + language, + text: String::new(), + data, + }; + } + match String::from_utf8(data) { + Ok(text) => RepositoryFilePage { + name, + kind: if markdown { "markdown" } else { "source" }.into(), + language, + text, + data: Vec::new(), + }, + Err(error) => RepositoryFilePage { + name, + kind: "preview".into(), + language, + text: String::new(), + data: error.into_bytes(), + }, + } +} + pub fn diff_page(path: &str, diff: crate::diff::Parsed) -> DiffPage { DiffPage { title: path.rsplit('/').next().unwrap_or(path).into(), @@ -509,12 +599,51 @@ pub fn home_page(server_name: String, home: HomeData) -> HomePage { let (heat_cells, contribution_count) = heat_cells(&home.heatmap); HomePage { server_name, + issue_activities: home + .activities + .iter() + .filter(|activity| is_issue_activity(activity)) + .map(activity_row) + .collect(), + pull_activities: home + .activities + .iter() + .filter(|activity| is_pull_activity(activity)) + .map(activity_row) + .collect(), activities: home.activities.iter().map(activity_row).collect(), heat_cells, contribution_count, } } +fn is_issue_activity(activity: &models::Activity) -> bool { + use models::activity::OpType; + + matches!( + activity.op_type, + Some(OpType::CreateIssue | OpType::CloseIssue | OpType::ReopenIssue | OpType::CommentIssue) + ) +} + +fn is_pull_activity(activity: &models::Activity) -> bool { + use models::activity::OpType; + + matches!( + activity.op_type, + Some( + OpType::CreatePullRequest + | OpType::MergePullRequest + | OpType::AutoMergePullRequest + | OpType::ClosePullRequest + | OpType::ReopenPullRequest + | OpType::CommentPull + | OpType::ApprovePullRequest + | OpType::RejectPullRequest + ) + ) +} + fn activity_row(activity: &models::Activity) -> ActivityRow { use models::activity::OpType; @@ -653,15 +782,20 @@ fn activity_detail(activity: &models::Activity) -> String { } fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec, i64) { - const DAYS: i64 = 9 * 31; let latest = data .iter() .filter_map(|entry| entry.timestamp) .max() .unwrap_or(0) / 86_400; - let start = latest - DAYS + 1; - let mut counts = vec![0_i64; DAYS as usize]; + let (year, month, _) = civil_from_days(latest); + let first_month = year * 12 + month - 1 - 8; + let start = days_from_civil( + first_month.div_euclid(12), + first_month.rem_euclid(12) + 1, + 1, + ); + let mut counts = vec![0_i64; (latest - start + 1) as usize]; for entry in data { let day = entry.timestamp.unwrap_or_default() / 86_400; if (start..=latest).contains(&day) { @@ -674,20 +808,42 @@ fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec, i64) { .into_iter() .enumerate() .map(|(index, count)| HeatCell { - week: (index / 7) as u32, - day: (index % 7) as u32, level: if count == 0 || maximum == 0 { 0 } else { ((count * 4 + maximum - 1) / maximum).clamp(1, 4) as u32 }, timestamp: (start + index as i64) * 86_400, - contributions: count, }) .collect(); (cells, contribution_count) } +fn civil_from_days(days: i64) -> (i64, i64, i64) { + let days = days + 719_468; + let era = days.div_euclid(146_097); + let day_of_era = days - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_prime = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_prime + 2) / 5 + 1; + let month = month_prime + if month_prime < 10 { 3 } else { -9 }; + year += i64::from(month <= 2); + (year, month, day) +} + +fn days_from_civil(mut year: i64, month: i64, day: i64) -> i64 { + year -= i64::from(month <= 2); + let era = year.div_euclid(400); + let year_of_era = year - era * 400; + let month_prime = month + if month > 2 { -3 } else { 9 }; + let day_of_year = (153 * month_prime + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + era * 146_097 + day_of_era - 719_468 +} + fn label_row(label: &models::Label) -> Option { let name = label.name.as_deref()?.trim(); let color = label.color.as_deref()?.trim().trim_start_matches('#'); @@ -777,6 +933,7 @@ fn issue_milestone(issue: &models::Issue) -> String { fn milestone_row(milestone: &models::Milestone) -> MilestoneRow { let open = milestone.open_issues.unwrap_or_default(); let closed = milestone.closed_issues.unwrap_or_default(); + let total = open + closed; let due = milestone .due_on .as_deref() @@ -798,8 +955,68 @@ fn milestone_row(milestone: &models::Milestone) -> MilestoneRow { milestone.state.as_deref().unwrap_or("unknown"), open + closed ), - open_issues: open, - closed_issues: closed, + has_issues: total > 0, + progress: if total == 0 { + 0.0 + } else { + closed as f64 / total as f64 + }, + progress_accessibility: format!("{closed} closed, {open} open"), + } +} + +fn short_sha(sha: &str) -> &str { + &sha[..sha.len().min(8)] +} + +fn source_language(path: &str) -> Option<&'static str> { + let filename = path.rsplit('/').next()?.to_lowercase(); + match filename.as_str() { + "cmakelists.txt" => return Some("cmake"), + "dockerfile" => return Some("dockerfile"), + "gemfile" | "podfile" => return Some("ruby"), + "makefile" => return Some("makefile"), + _ => {} + } + match filename.rsplit('.').next()? { + "asm" => Some("x86asm"), + "c" => Some("c"), + "cc" | "cpp" | "h" | "hpp" => Some("cpp"), + "clj" => Some("clojure"), + "cs" => Some("csharp"), + "css" => Some("css"), + "dart" => Some("dart"), + "ex" | "exs" => Some("elixir"), + "fs" => Some("fsharp"), + "go" => Some("go"), + "groovy" => Some("groovy"), + "hs" => Some("haskell"), + "html" | "plist" | "xml" => Some("xml"), + "java" => Some("java"), + "jl" => Some("julia"), + "js" | "jsx" => Some("javascript"), + "json" => Some("json"), + "kt" | "kts" => Some("kotlin"), + "lua" => Some("lua"), + "m" | "mm" => Some("objectivec"), + "md" | "markdown" | "mdown" | "mkd" => Some("markdown"), + "php" => Some("php"), + "pl" => Some("perl"), + "ps1" => Some("powershell"), + "py" => Some("python"), + "r" => Some("r"), + "rb" => Some("ruby"), + "rs" => Some("rust"), + "scala" => Some("scala"), + "scss" => Some("scss"), + "sh" => Some("bash"), + "sql" => Some("sql"), + "swift" => Some("swift"), + "toml" => Some("ini"), + "ts" | "tsx" => Some("typescript"), + "txt" => Some("plaintext"), + "yaml" | "yml" => Some("yaml"), + _ => None, } } @@ -853,24 +1070,19 @@ mod tests { fn maps_heat_levels_and_labels() { let heatmap = vec![ models::UserHeatmapData { - timestamp: Some(100 * 86_400), + timestamp: Some(20_393 * 86_400), contributions: Some(1), }, models::UserHeatmapData { - timestamp: Some(101 * 86_400), + timestamp: Some(20_665 * 86_400), contributions: Some(4), }, ]; let (cells, total) = heat_cells(&heatmap); - assert_eq!((cells.len(), total), (279, 5)); + assert_eq!((cells.len(), total), (273, 5)); assert_eq!( - ( - cells[277].level, - cells[277].timestamp, - cells[277].contributions, - cells[278].level, - ), - (1, 100 * 86_400, 1, 4) + (cells[0].level, cells[0].timestamp, cells[272].level,), + (1, 20_393 * 86_400, 4) ); let label = models::Label { @@ -890,10 +1102,8 @@ mod tests { ..Default::default() }; let milestone_row = milestone_rows(std::slice::from_ref(&milestone)).remove(0); - assert_eq!( - (milestone_row.open_issues, milestone_row.closed_issues), - (3, 2) - ); + assert_eq!(milestone_row.progress, 0.4); + assert_eq!(milestone_row.progress_accessibility, "2 closed, 3 open"); let issue = models::Issue { milestone: Some(Box::new(milestone)), ..Default::default() @@ -901,6 +1111,33 @@ mod tests { assert_eq!(issue_rows(&[issue])[0].milestone, "Version 1"); } + #[test] + fn categorizes_home_activity_before_crossing_the_bridge() { + use models::activity::OpType; + + let page = home_page( + "Gitea".into(), + HomeData { + activities: [ + OpType::CreateIssue, + OpType::CreatePullRequest, + OpType::CreateRepo, + ] + .into_iter() + .map(|op_type| models::Activity { + op_type: Some(op_type), + ..Default::default() + }) + .collect(), + heatmap: Vec::new(), + }, + ); + + assert_eq!(page.activities.len(), 3); + assert_eq!(page.issue_activities.len(), 1); + assert_eq!(page.pull_activities.len(), 1); + } + #[test] fn issue_page_exposes_state() { let page = issue_page(IssueDetails { @@ -917,8 +1154,8 @@ mod tests { #[test] fn builds_sorted_issue_filter_options() { let filter = IssueFilter { - milestone: "v2".into(), - labels: ["bug".into(), "critical".into()].into(), + milestone: "v3".into(), + labels: ["bug".into(), "retired".into()].into(), }; let options = issue_filter_options( &[ @@ -944,9 +1181,38 @@ mod tests { &filter, ); - assert_eq!(options.labels, ["bug", "critical"]); - assert_eq!(options.milestones, ["v1", "v2"]); - assert_eq!(options.selected_labels, ["bug", "critical"]); - assert_eq!(options.selected_milestone, "v2"); + assert_eq!(options.labels, ["bug", "critical", "retired"]); + assert_eq!(options.unavailable_labels, ["retired"]); + assert_eq!(options.milestones, ["v1", "v2", "v3"]); + assert_eq!(options.selected_labels, ["bug", "retired"]); + assert_eq!(options.selected_milestone, "v3"); + } + + #[test] + fn classifies_repository_files_in_rust() { + let markdown = repository_file_page("docs/README.md", b"# Hello".to_vec()); + assert_eq!( + ( + markdown.name.as_str(), + markdown.kind.as_str(), + markdown.language.as_str() + ), + ("README.md", "markdown", "markdown") + ); + assert_eq!(markdown.text, "# Hello"); + + let source = repository_file_page("Sources/App.swift", b"import UIKit".to_vec()); + assert_eq!( + (source.kind.as_str(), source.language.as_str()), + ("source", "swift") + ); + + let preview = repository_file_page("image.png", vec![0, 159]); + assert_eq!(preview.kind, "preview"); + assert_eq!(preview.data, [0, 159]); + + for path in ["manual.pdf", "sound.mp3", "movie.mp4"] { + assert_eq!(repository_file_page(path, Vec::new()).kind, "preview"); + } } } diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index 8837860..b1d9c7e 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -494,6 +494,22 @@ fileprivate struct FfiConverterInt64: FfiConverterPrimitive { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterDouble: FfiConverterPrimitive { + typealias FfiType = Double + typealias SwiftType = Double + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Double { + return try lift(readDouble(&buf)) + } + + public static func write(_ value: Double, into buf: inout [UInt8]) { + writeDouble(&buf, lower(value)) + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -605,6 +621,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func issueFilters(owner: String, repository: String) async throws -> IssueFilterOptions + func issueFiltersActive(owner: String, repository: String) throws -> Bool + func issues(owner: String, repository: String) async throws -> [IssueRow] func milestone(owner: String, repository: String, id: Int64) async throws -> MilestonePage @@ -621,7 +639,7 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func repositoryContents(owner: String, repository: String, path: String) async throws -> [RepositoryContentRow] - func repositoryFile(owner: String, repository: String, path: String) async throws -> Data + func repositoryFile(owner: String, repository: String, path: String) async throws -> RepositoryFilePage func selectServer(index: UInt32) throws @@ -819,10 +837,10 @@ open func issue(owner: String, repository: String, number: Int64)async throws - open func issueFilters(owner: String, repository: String)async throws -> IssueFilterOptions { return - try await uniffiRustCallAsync( + try await uniffiRustCallAsync( rustFutureFunc: { uniffi_gotcha_core_fn_method_gotchacore_issue_filters( - self.uniffiCloneHandle(), FfiConverterString.lower(owner), FfiConverterString.lower(repository) + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository) ) }, pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, @@ -833,6 +851,17 @@ open func issueFilters(owner: String, repository: String)async throws -> IssueF ) } +open func issueFiltersActive(owner: String, repository: String)throws -> Bool { + return try FfiConverterBool.lift(try rustCallWithError(FfiConverterTypeGotchaError_lift) { + uniffiCallStatus in + uniffi_gotcha_core_fn_method_gotchacore_issue_filters_active( + self.uniffiCloneHandle(), + FfiConverterString.lower(owner), + FfiConverterString.lower(repository),uniffiCallStatus + ) +}) +} + open func issues(owner: String, repository: String)async throws -> [IssueRow] { return try await uniffiRustCallAsync( @@ -961,7 +990,7 @@ open func repositoryContents(owner: String, repository: String, path: String)asy ) } -open func repositoryFile(owner: String, repository: String, path: String)async throws -> Data { +open func repositoryFile(owner: String, repository: String, path: String)async throws -> RepositoryFilePage { return try await uniffiRustCallAsync( rustFutureFunc: { @@ -972,7 +1001,7 @@ open func repositoryFile(owner: String, repository: String, path: String)async t 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: FfiConverterData.lift, + liftFunc: FfiConverterTypeRepositoryFilePage_lift, errorHandler: FfiConverterTypeGotchaError_lift ) } @@ -1007,11 +1036,11 @@ open func setAppearance(index: UInt32)throws {try rustCallWithError(FfiConvert open func setIssueFilters(owner: String, repository: String, milestone: String, labels: [String])throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { uniffiCallStatus in uniffi_gotcha_core_fn_method_gotchacore_set_issue_filters( - self.uniffiCloneHandle(), + self.uniffiCloneHandle(), FfiConverterString.lower(owner), FfiConverterString.lower(repository), FfiConverterString.lower(milestone), - FfiConverterSequenceString.lower(labels), uniffiCallStatus + FfiConverterSequenceString.lower(labels),uniffiCallStatus ) } } @@ -1312,8 +1341,7 @@ public func FfiConverterTypeCommitPage_lower(_ value: CommitPage) -> RustBuffer public struct CommitRow: Equatable, Hashable { public var sha: String public var title: String - public var meta: String - public var refs: String + public var detail: String public var topLanes: [UInt32] public var bottomLanes: [UInt32] public var nodeLane: UInt32? @@ -1321,11 +1349,10 @@ public struct CommitRow: Equatable, Hashable { // Default memberwise initializers are never public by default, so we // declare one manually. - public init(sha: String, title: String, meta: String, refs: String, topLanes: [UInt32], bottomLanes: [UInt32], nodeLane: UInt32?, connections: [UInt32]) { + public init(sha: String, title: String, detail: String, topLanes: [UInt32], bottomLanes: [UInt32], nodeLane: UInt32?, connections: [UInt32]) { self.sha = sha self.title = title - self.meta = meta - self.refs = refs + self.detail = detail self.topLanes = topLanes self.bottomLanes = bottomLanes self.nodeLane = nodeLane @@ -1350,8 +1377,7 @@ public struct FfiConverterTypeCommitRow: FfiConverterRustBuffer { try CommitRow( sha: FfiConverterString.read(from: &buf), title: FfiConverterString.read(from: &buf), - meta: FfiConverterString.read(from: &buf), - refs: FfiConverterString.read(from: &buf), + detail: FfiConverterString.read(from: &buf), topLanes: FfiConverterSequenceUInt32.read(from: &buf), bottomLanes: FfiConverterSequenceUInt32.read(from: &buf), nodeLane: FfiConverterOptionUInt32.read(from: &buf), @@ -1362,8 +1388,7 @@ public struct FfiConverterTypeCommitRow: FfiConverterRustBuffer { public static func write(_ value: CommitRow, into buf: inout [UInt8]) { FfiConverterString.write(value.sha, into: &buf) FfiConverterString.write(value.title, into: &buf) - FfiConverterString.write(value.meta, into: &buf) - FfiConverterString.write(value.refs, into: &buf) + FfiConverterString.write(value.detail, into: &buf) FfiConverterSequenceUInt32.write(value.topLanes, into: &buf) FfiConverterSequenceUInt32.write(value.bottomLanes, into: &buf) FfiConverterOptionUInt32.write(value.nodeLane, into: &buf) @@ -1562,20 +1587,14 @@ public func FfiConverterTypeFileRow_lower(_ value: FileRow) -> RustBuffer { public struct HeatCell: Equatable, Hashable { - public var week: UInt32 - public var day: UInt32 public var level: UInt32 public var timestamp: Int64 - public var contributions: Int64 // Default memberwise initializers are never public by default, so we // declare one manually. - public init(week: UInt32, day: UInt32, level: UInt32, timestamp: Int64, contributions: Int64) { - self.week = week - self.day = day + public init(level: UInt32, timestamp: Int64) { self.level = level self.timestamp = timestamp - self.contributions = contributions } @@ -1594,20 +1613,14 @@ public struct FfiConverterTypeHeatCell: FfiConverterRustBuffer { public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> HeatCell { return try HeatCell( - week: FfiConverterUInt32.read(from: &buf), - day: FfiConverterUInt32.read(from: &buf), level: FfiConverterUInt32.read(from: &buf), - timestamp: FfiConverterInt64.read(from: &buf), - contributions: FfiConverterInt64.read(from: &buf) + timestamp: FfiConverterInt64.read(from: &buf) ) } public static func write(_ value: HeatCell, into buf: inout [UInt8]) { - FfiConverterUInt32.write(value.week, into: &buf) - FfiConverterUInt32.write(value.day, into: &buf) FfiConverterUInt32.write(value.level, into: &buf) FfiConverterInt64.write(value.timestamp, into: &buf) - FfiConverterInt64.write(value.contributions, into: &buf) } } @@ -1630,14 +1643,18 @@ public func FfiConverterTypeHeatCell_lower(_ value: HeatCell) -> RustBuffer { public struct HomePage: Equatable, Hashable { public var serverName: String public var activities: [ActivityRow] + public var issueActivities: [ActivityRow] + public var pullActivities: [ActivityRow] public var heatCells: [HeatCell] public var contributionCount: Int64 // Default memberwise initializers are never public by default, so we // declare one manually. - public init(serverName: String, activities: [ActivityRow], heatCells: [HeatCell], contributionCount: Int64) { + public init(serverName: String, activities: [ActivityRow], issueActivities: [ActivityRow], pullActivities: [ActivityRow], heatCells: [HeatCell], contributionCount: Int64) { self.serverName = serverName self.activities = activities + self.issueActivities = issueActivities + self.pullActivities = pullActivities self.heatCells = heatCells self.contributionCount = contributionCount } @@ -1660,6 +1677,8 @@ public struct FfiConverterTypeHomePage: FfiConverterRustBuffer { try HomePage( serverName: FfiConverterString.read(from: &buf), activities: FfiConverterSequenceTypeActivityRow.read(from: &buf), + issueActivities: FfiConverterSequenceTypeActivityRow.read(from: &buf), + pullActivities: FfiConverterSequenceTypeActivityRow.read(from: &buf), heatCells: FfiConverterSequenceTypeHeatCell.read(from: &buf), contributionCount: FfiConverterInt64.read(from: &buf) ) @@ -1668,6 +1687,8 @@ public struct FfiConverterTypeHomePage: FfiConverterRustBuffer { public static func write(_ value: HomePage, into buf: inout [UInt8]) { FfiConverterString.write(value.serverName, into: &buf) FfiConverterSequenceTypeActivityRow.write(value.activities, into: &buf) + FfiConverterSequenceTypeActivityRow.write(value.issueActivities, into: &buf) + FfiConverterSequenceTypeActivityRow.write(value.pullActivities, into: &buf) FfiConverterSequenceTypeHeatCell.write(value.heatCells, into: &buf) FfiConverterInt64.write(value.contributionCount, into: &buf) } @@ -1692,17 +1713,23 @@ public func FfiConverterTypeHomePage_lower(_ value: HomePage) -> RustBuffer { public struct IssueFilterOptions: Equatable, Hashable { public var milestones: [String] public var labels: [String] + public var unavailableLabels: [String] public var selectedMilestone: String public var selectedLabels: [String] // Default memberwise initializers are never public by default, so we // declare one manually. - public init(milestones: [String], labels: [String], selectedMilestone: String, selectedLabels: [String]) { + public init(milestones: [String], labels: [String], unavailableLabels: [String], selectedMilestone: String, selectedLabels: [String]) { self.milestones = milestones self.labels = labels + self.unavailableLabels = unavailableLabels self.selectedMilestone = selectedMilestone self.selectedLabels = selectedLabels } + + + + } #if compiler(>=6) @@ -1718,6 +1745,7 @@ public struct FfiConverterTypeIssueFilterOptions: FfiConverterRustBuffer { try IssueFilterOptions( milestones: FfiConverterSequenceString.read(from: &buf), labels: FfiConverterSequenceString.read(from: &buf), + unavailableLabels: FfiConverterSequenceString.read(from: &buf), selectedMilestone: FfiConverterString.read(from: &buf), selectedLabels: FfiConverterSequenceString.read(from: &buf) ) @@ -1726,11 +1754,13 @@ public struct FfiConverterTypeIssueFilterOptions: FfiConverterRustBuffer { public static func write(_ value: IssueFilterOptions, into buf: inout [UInt8]) { FfiConverterSequenceString.write(value.milestones, into: &buf) FfiConverterSequenceString.write(value.labels, into: &buf) + FfiConverterSequenceString.write(value.unavailableLabels, into: &buf) FfiConverterString.write(value.selectedMilestone, into: &buf) FfiConverterSequenceString.write(value.selectedLabels, into: &buf) } } + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -2003,18 +2033,20 @@ public struct MilestoneRow: Equatable, Hashable { public var title: String public var description: String public var meta: String - public var openIssues: Int64 - public var closedIssues: Int64 + public var hasIssues: Bool + public var progress: Double + public var progressAccessibility: String // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: Int64, title: String, description: String, meta: String, openIssues: Int64, closedIssues: Int64) { + public init(id: Int64, title: String, description: String, meta: String, hasIssues: Bool, progress: Double, progressAccessibility: String) { self.id = id self.title = title self.description = description self.meta = meta - self.openIssues = openIssues - self.closedIssues = closedIssues + self.hasIssues = hasIssues + self.progress = progress + self.progressAccessibility = progressAccessibility } @@ -2037,8 +2069,9 @@ public struct FfiConverterTypeMilestoneRow: FfiConverterRustBuffer { title: FfiConverterString.read(from: &buf), description: FfiConverterString.read(from: &buf), meta: FfiConverterString.read(from: &buf), - openIssues: FfiConverterInt64.read(from: &buf), - closedIssues: FfiConverterInt64.read(from: &buf) + hasIssues: FfiConverterBool.read(from: &buf), + progress: FfiConverterDouble.read(from: &buf), + progressAccessibility: FfiConverterString.read(from: &buf) ) } @@ -2047,8 +2080,9 @@ public struct FfiConverterTypeMilestoneRow: FfiConverterRustBuffer { FfiConverterString.write(value.title, into: &buf) FfiConverterString.write(value.description, into: &buf) FfiConverterString.write(value.meta, into: &buf) - FfiConverterInt64.write(value.openIssues, into: &buf) - FfiConverterInt64.write(value.closedIssues, into: &buf) + FfiConverterBool.write(value.hasIssues, into: &buf) + FfiConverterDouble.write(value.progress, into: &buf) + FfiConverterString.write(value.progressAccessibility, into: &buf) } } @@ -2270,6 +2304,72 @@ public func FfiConverterTypeRepositoryContentRow_lower(_ value: RepositoryConten } +public struct RepositoryFilePage: Equatable, Hashable { + public var name: String + public var kind: String + public var language: String + public var text: String + public var data: Data + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(name: String, kind: String, language: String, text: String, data: Data) { + self.name = name + self.kind = kind + self.language = language + self.text = text + self.data = data + } + + + + +} + +#if compiler(>=6) +extension RepositoryFilePage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeRepositoryFilePage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> RepositoryFilePage { + return + try RepositoryFilePage( + name: FfiConverterString.read(from: &buf), + kind: FfiConverterString.read(from: &buf), + language: FfiConverterString.read(from: &buf), + text: FfiConverterString.read(from: &buf), + data: FfiConverterData.read(from: &buf) + ) + } + + public static func write(_ value: RepositoryFilePage, into buf: inout [UInt8]) { + FfiConverterString.write(value.name, into: &buf) + FfiConverterString.write(value.kind, into: &buf) + FfiConverterString.write(value.language, into: &buf) + FfiConverterString.write(value.text, into: &buf) + FfiConverterData.write(value.data, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeRepositoryFilePage_lift(_ buf: RustBuffer) throws -> RepositoryFilePage { + return try FfiConverterTypeRepositoryFilePage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeRepositoryFilePage_lower(_ value: RepositoryFilePage) -> RustBuffer { + return FfiConverterTypeRepositoryFilePage.lower(value) +} + + public struct RepositoryRow: Equatable, Hashable { public var name: String public var owner: String @@ -3035,6 +3135,9 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_issue_filters() != 24054) { return InitializationResult.apiChecksumMismatch } + if (uniffi_gotcha_core_checksum_method_gotchacore_issue_filters_active() != 51470) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_gotcha_core_checksum_method_gotchacore_issues() != 1316) { return InitializationResult.apiChecksumMismatch } @@ -3059,7 +3162,7 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_repository_contents() != 8454) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_repository_file() != 27399) { + if (uniffi_gotcha_core_checksum_method_gotchacore_repository_file() != 44948) { return InitializationResult.apiChecksumMismatch } if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 22721) { @@ -3109,4 +3212,4 @@ public func uniffiEnsureGotchaCoreInitialized() { } } -// swiftlint:enable all +// swiftlint:enable all \ No newline at end of file diff --git a/ios/Generated/gotcha_coreFFI.h b/ios/Generated/gotcha_coreFFI.h index 2ae88b8..6228515 100644 --- a/ios/Generated/gotcha_coreFFI.h +++ b/ios/Generated/gotcha_coreFFI.h @@ -304,6 +304,11 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_issue(uint64_t ptr, RustBuffer uint64_t uniffi_gotcha_core_fn_method_gotchacore_issue_filters(uint64_t ptr, RustBuffer owner, RustBuffer repository ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE_FILTERS_ACTIVE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE_FILTERS_ACTIVE +int8_t uniffi_gotcha_core_fn_method_gotchacore_issue_filters_active(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustCallStatus *_Nonnull out_status +); +#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 @@ -706,6 +711,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_issue(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE_FILTERS uint16_t uniffi_gotcha_core_checksum_method_gotchacore_issue_filters(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE_FILTERS_ACTIVE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE_FILTERS_ACTIVE +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_issue_filters_active(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUES diff --git a/ios/Sources/ContentScreens.swift b/ios/Sources/ContentScreens.swift index 1d604ce..bac4cc6 100644 --- a/ios/Sources/ContentScreens.swift +++ b/ios/Sources/ContentScreens.swift @@ -135,13 +135,10 @@ final class IssuesViewController: RefreshingTableViewController { private func milestoneMenu(_ options: IssueFilterOptions) -> UIMenu { let selected = options.selectedMilestone - var milestones = options.milestones - if !selected.isEmpty, !milestones.contains(selected) { milestones.append(selected) } let all = UIAction(title: "All Milestones", state: selected.isEmpty ? .on : .off) { [weak self] _ in self?.selectMilestone("") } - let actions = milestones.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } - .map { milestone in + let actions = options.milestones.map { milestone in UIAction( title: milestone, state: selected == milestone ? .on : .off @@ -156,13 +153,10 @@ final class IssuesViewController: RefreshingTableViewController { } private func labelMenu(_ options: IssueFilterOptions) -> UIMenu { - let available = Set(options.labels) let selected = Set(options.selectedLabels) - let labels = available.union(selected) - .sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } - let actions = labels.map { label in + let actions = options.labels.map { label in UIAction( - title: available.contains(label) ? label : "\(label) (Unavailable)", + title: options.unavailableLabels.contains(label) ? "\(label) (Unavailable)" : label, attributes: .keepsMenuPresented, state: selected.contains(label) ? .on : .off ) { [weak self] action in @@ -172,7 +166,7 @@ final class IssuesViewController: RefreshingTableViewController { if labels.remove(label) == nil { labels.insert(label) } do { try self.saveFilters(milestone: options.selectedMilestone, labels: labels) - self.filterOptions?.selectedLabels = labels.sorted() + self.filterOptions?.selectedLabels = Array(labels) action.state = labels.contains(label) ? .on : .off self.updateFilterMenu() self.loadIssues(refreshing: false) @@ -208,14 +202,12 @@ final class IssuesViewController: RefreshingTableViewController { owner: owner, repository: repository, milestone: milestone, - labels: labels.sorted() + labels: Array(labels) ) } private var filtersActive: Bool { - context.core.settings().issueStatus != "open" - || filterOptions?.selectedMilestone.isEmpty == false - || filterOptions?.selectedLabels.isEmpty == false + (try? context.core.issueFiltersActive(owner: owner, repository: repository)) ?? false } private func updateFilterTint() { @@ -519,11 +511,10 @@ final class MilestoneCell: UITableViewCell { titleLabel.text = row.title descriptionLabel.text = row.description metaLabel.text = row.meta - let total = row.openIssues + row.closedIssues - progress.progress = total == 0 ? 0 : Float(row.closedIssues) / Float(total) - progress.trackTintColor = total == 0 ? .systemGray5 : .systemOrange + progress.progress = Float(row.progress) + progress.trackTintColor = row.hasIssues ? .systemOrange : .systemGray5 progress.accessibilityLabel = "Milestone progress" - progress.accessibilityValue = "\(row.closedIssues) closed, \(row.openIssues) open" + progress.accessibilityValue = row.progressAccessibility } } @@ -599,7 +590,7 @@ final class PullsViewController: RefreshingTableViewController { let row = rows[indexPath.row] configureTextCell( cell, - title: "\(row.repository) #\(row.number)\n\(row.title)", + title: row.title, detail: "\(row.summary)\n\(row.meta)" ) cell.accessoryType = .disclosureIndicator @@ -819,9 +810,7 @@ final class CommitCell: UITableViewCell { var content = defaultContentConfiguration() content.directionalLayoutMargins.leading = laneCount == 0 ? 0 : min(72, CGFloat(laneCount) * 10 + 8) content.text = row.title - content.secondaryText = row.refs.isEmpty - ? "\(row.meta)\n\(row.sha.prefix(8))" - : "\(row.refs)\n\(row.meta) · \(row.sha.prefix(8))" + content.secondaryText = row.detail content.secondaryTextProperties.numberOfLines = 2 contentConfiguration = content setNeedsDisplay() diff --git a/ios/Sources/DetailScreens.swift b/ios/Sources/DetailScreens.swift index e3b2b10..aa129f3 100644 --- a/ios/Sources/DetailScreens.swift +++ b/ios/Sources/DetailScreens.swift @@ -3,7 +3,6 @@ import MarkdownUI import QuickLook import SwiftUI import UIKit -import UniformTypeIdentifiers @MainActor class MarkdownPageViewController: UIViewController { @@ -273,13 +272,13 @@ final class RepositoryDirectoryViewController: RefreshingTableViewController { private let path: String private var rows: [RepositoryContentRow] = [] - init(context: AppContext, owner: String, repository: String, path: String) { + init(context: AppContext, owner: String, repository: String, path: String, name: String) { self.context = context self.owner = owner self.repository = repository self.path = path super.init() - title = path.split(separator: "/").last.map(String.init) ?? repository + title = name } @available(*, unavailable) @@ -349,18 +348,19 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD private var loadingTask: Task? private var previewURL: URL? private var markdownSource: String? + private var fileLanguage: String? private var displayedController: UIViewController? private var displayedView: UIView? private weak var sourceScrollView: UIScrollView? private weak var sourceTextView: UITextView? - init(context: AppContext, owner: String, repository: String, path: String) { + init(context: AppContext, owner: String, repository: String, path: String, name: String) { self.context = context self.owner = owner self.repository = repository self.path = path super.init(nibName: nil, bundle: nil) - title = path.split(separator: "/").last.map(String.init) + title = name } @available(*, unavailable) @@ -372,19 +372,17 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD beginNavigationLoading(spinner) loadingTask = Task { do { - let data = try await context.core.repositoryFile( + let page = try await context.core.repositoryFile( owner: owner, repository: repository, path: path ) - if !isMediaFile(path), let source = String(data: data, encoding: .utf8) { - if isMarkdownFile { - showMarkdown(source) - } else { - showSource(source) - } - } else { - try showQuickLook(data) + title = page.name + fileLanguage = page.language.isEmpty ? nil : page.language + switch page.kind { + case "markdown": showMarkdown(page.text) + case "source": showSource(page.text) + default: try showQuickLook(page.data, name: page.name) } } catch { if !Task.isCancelled { show(error: error) } @@ -398,20 +396,6 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD updateSourceLayout() } - private func isMediaFile(_ path: String) -> Bool { - guard let type = UTType(filenameExtension: (path as NSString).pathExtension) else { - return false - } - return type.conforms(to: .image) - || type.conforms(to: .audio) - || type.conforms(to: .audiovisualContent) - || type.conforms(to: .pdf) - } - - private var isMarkdownFile: Bool { - ["md", "markdown", "mdown", "mkd"].contains((path as NSString).pathExtension.lowercased()) - } - deinit { loadingTask?.cancel() if let previewURL { try? FileManager.default.removeItem(at: previewURL) } @@ -464,7 +448,7 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD traitCollection.userInterfaceStyle == .dark ? "atom-one-dark" : "atom-one-light" ) highlighter?.theme.setCodeFont(.monospacedSystemFont(ofSize: 13, weight: .regular)) - let highlighted = highlighter?.highlight(source, as: sourceLanguage(path)) + let highlighted = highlighter?.highlight(source, as: fileLanguage) ?? NSAttributedString(string: source, attributes: [ .font: UIFont.monospacedSystemFont(ofSize: 13, weight: .regular), .foregroundColor: UIColor.label, @@ -532,8 +516,7 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD scrollView.contentSize = contentSize } - private func showQuickLook(_ data: Data) throws { - let name = path.split(separator: "/").last.map(String.init) ?? "Preview" + private func showQuickLook(_ data: Data, name: String) throws { let url = FileManager.default.temporaryDirectory .appendingPathComponent("\(UUID().uuidString)-\(name)") try data.write(to: url, options: .atomic) diff --git a/ios/Sources/ListScreens.swift b/ios/Sources/ListScreens.swift index bfdcef3..01026aa 100644 --- a/ios/Sources/ListScreens.swift +++ b/ios/Sources/ListScreens.swift @@ -303,17 +303,6 @@ final class HomeViewController: RefreshingTableViewController { case all case issues case pulls - - func includes(_ row: ActivityRow) -> Bool { - switch self { - case .all: - return true - case .issues: - return row.icon.contains("issue") - case .pulls: - return row.icon.contains("pull") - } - } } private let context: AppContext @@ -321,7 +310,12 @@ final class HomeViewController: RefreshingTableViewController { private var filter = ActivityFilter.all private var activities: [ActivityRow] { - page?.activities.filter(filter.includes) ?? [] + guard let page else { return [] } + switch filter { + case .all: return page.activities + case .issues: return page.issueActivities + case .pulls: return page.pullActivities + } } init(context: AppContext) { @@ -443,20 +437,7 @@ final class HeatmapView: UIView { private let calendar = Calendar(identifier: .gregorian) init(page: HomePage, selectedFilter: Int, onFilter: @escaping (Int) -> Void) { - let latest = page.heatCells.last.map { - Date(timeIntervalSince1970: TimeInterval($0.timestamp)) - } ?? Date() - let latestMonth = Calendar(identifier: .gregorian).date( - from: Calendar(identifier: .gregorian).dateComponents([.year, .month], from: latest) - ) ?? latest - let firstMonth = Calendar(identifier: .gregorian).date( - byAdding: .month, - value: -8, - to: latestMonth - ) ?? latestMonth - cells = page.heatCells.filter { - Date(timeIntervalSince1970: TimeInterval($0.timestamp)) >= firstMonth - } + cells = page.heatCells super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 180)) backgroundColor = .systemBackground let title = UILabel(frame: CGRect(x: 16, y: 12, width: 300, height: 22)) @@ -465,7 +446,7 @@ final class HeatmapView: UIView { title.textColor = .secondaryLabel addSubview(title) let total = UILabel(frame: CGRect(x: 16, y: 108, width: 300, height: 18)) - total.text = "\(cells.reduce(0) { $0 + $1.contributions }) contributions" + total.text = "\(page.contributionCount) contributions" total.font = .preferredFont(forTextStyle: .caption1) total.textColor = .tertiaryLabel addSubview(total) diff --git a/ios/Sources/Support.swift b/ios/Sources/Support.swift index 9549136..53e1e97 100644 --- a/ios/Sources/Support.swift +++ b/ios/Sources/Support.swift @@ -151,39 +151,19 @@ func showRepositoryContent( context: context, owner: owner, repository: repository, - path: row.path + path: row.path, + name: row.name ) : RepositoryFileViewController( context: context, owner: owner, repository: repository, - path: row.path + path: row.path, + name: row.name ) navigationController?.pushViewController(destination, animated: true) } -func sourceLanguage(_ path: String) -> String? { - let filename = (path as NSString).lastPathComponent.lowercased() - let filenames = [ - "cmakelists.txt": "cmake", "dockerfile": "dockerfile", "gemfile": "ruby", - "makefile": "makefile", "podfile": "ruby", - ] - if let language = filenames[filename] { return language } - let languages = [ - "asm": "x86asm", "c": "c", "cc": "cpp", "clj": "clojure", "cpp": "cpp", - "cs": "csharp", "css": "css", "dart": "dart", "ex": "elixir", "exs": "elixir", - "fs": "fsharp", "go": "go", "groovy": "groovy", "h": "cpp", "hpp": "cpp", - "hs": "haskell", "html": "xml", "java": "java", "jl": "julia", "js": "javascript", - "json": "json", "jsx": "javascript", "kt": "kotlin", "kts": "kotlin", "lua": "lua", - "m": "objectivec", "md": "markdown", "mm": "objectivec", "php": "php", "pl": "perl", - "plist": "xml", "ps1": "powershell", "py": "python", "r": "r", "rb": "ruby", - "rs": "rust", "scala": "scala", "scss": "scss", "sh": "bash", "sql": "sql", - "swift": "swift", "toml": "ini", "ts": "typescript", "tsx": "typescript", - "txt": "plaintext", "xml": "xml", "yaml": "yaml", "yml": "yaml", - ] - return languages[(path as NSString).pathExtension.lowercased()] -} - func symbolText(_ symbol: String, text: String, font: UIFont, color: UIColor) -> NSAttributedString { let output = NSMutableAttributedString() if let image = UIImage(systemName: symbol)?.withTintColor(color, renderingMode: .alwaysOriginal) {