diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs index a540d9e..cbb73d2 100644 --- a/crates/app/src/api.rs +++ b/crates/app/src/api.rs @@ -222,6 +222,51 @@ pub async fn load_branches( Ok(branches) } +pub async fn load_repository_contents( + server: &Server, + owner: &str, + repository: &str, + path: &str, +) -> Result, String> { + let client = + Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; + let configuration = client.configuration(); + if path.is_empty() { + apis::repository_api::repo_get_contents_list(&configuration, owner, repository, None) + .await + .map_err(|error| error.to_string()) + } else { + apis::repository_api::repo_get_contents_ext( + &configuration, + owner, + repository, + path, + None, + None, + ) + .await + .map(|contents| contents.dir_contents.unwrap_or_default()) + .map_err(|error| error.to_string()) + } +} + +pub async fn load_repository_file( + server: &Server, + owner: &str, + repository: &str, + path: &str, +) -> Result, String> { + let client = + Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; + apis::repository_api::repo_get_raw_file(&client.configuration(), owner, repository, path, None) + .await + .map_err(|error| error.to_string())? + .bytes() + .await + .map(|bytes| bytes.to_vec()) + .map_err(|error| error.to_string()) +} + pub async fn load_branch_commits( server: &Server, owner: &str, diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index e9a6c3d..0c064e7 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -290,6 +290,26 @@ impl GotchaCore { Ok(commit_page(branches, &commits, branch.is_none())) } + pub async fn repository_contents( + &self, + owner: String, + repository: String, + path: String, + ) -> Result, GotchaError> { + Ok(repository_content_rows( + load_repository_contents(&self.server()?, &owner, &repository, &path).await?, + )) + } + + pub async fn repository_file( + &self, + owner: String, + repository: String, + path: String, + ) -> Result, GotchaError> { + Ok(load_repository_file(&self.server()?, &owner, &repository, &path).await?) + } + pub async fn commit_files( &self, owner: String, diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index 529cbe7..f9b9aab 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -116,6 +116,14 @@ pub struct FileRow { pub status: String, } +#[derive(Clone, uniffi::Record)] +pub struct RepositoryContentRow { + pub name: String, + pub path: String, + pub kind: String, + pub size: i64, +} + #[derive(Clone, uniffi::Record)] pub struct DiffLine { pub old_number: String, @@ -422,6 +430,24 @@ pub fn commit_file_rows(files: Vec) -> Vec .collect() } +pub fn repository_content_rows( + contents: Vec, +) -> Vec { + let mut rows: Vec<_> = contents + .into_iter() + .filter_map(|content| { + Some(RepositoryContentRow { + name: content.name?, + path: content.path?, + kind: content.r#type.unwrap_or_else(|| "file".into()), + size: content.size.unwrap_or_default(), + }) + }) + .collect(); + rows.sort_by_key(|row| (row.kind != "dir", row.name.to_lowercase())); + rows +} + pub fn diff_page(path: &str, diff: crate::diff::Parsed) -> DiffPage { DiffPage { title: path.rsplit('/').next().unwrap_or(path).into(), @@ -758,6 +784,31 @@ pub fn compact_date(date: Option<&str>) -> String { mod tests { use super::*; + #[test] + fn repository_directories_sort_before_files() { + let contents = [ + ("z.swift", "file"), + ("Sources", "dir"), + ("README.md", "file"), + ] + .into_iter() + .map(|(name, kind)| models::ContentsResponse { + name: Some(name.into()), + path: Some(name.into()), + r#type: Some(kind.into()), + ..Default::default() + }) + .collect(); + + assert_eq!( + repository_content_rows(contents) + .into_iter() + .map(|row| row.name) + .collect::>(), + ["Sources", "README.md", "z.swift"] + ); + } + #[test] fn maps_heat_levels_and_labels() { let heatmap = vec![ diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index 2be1da3..bb41ab5 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -564,6 +564,24 @@ fileprivate struct FfiConverterString: FfiConverter { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterData: FfiConverterRustBuffer { + typealias SwiftType = Data + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Data { + let len: Int32 = try readInt(&buf) + return Data(try readBytes(&buf, count: Int(len))) + } + + public static func write(_ value: Data, into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + writeBytes(&buf, value) + } +} + @@ -599,6 +617,10 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func repositories() async throws -> [RepositoryRow] + func repositoryContents(owner: String, repository: String, path: String) async throws -> [RepositoryContentRow] + + func repositoryFile(owner: String, repository: String, path: String) async throws -> Data + func selectServer(index: UInt32) throws func servers() -> [ServerRow] @@ -903,6 +925,38 @@ open func repositories()async throws -> [RepositoryRow] { ) } +open func repositoryContents(owner: String, repository: String, path: String)async throws -> [RepositoryContentRow] { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_repository_contents( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterString.lower(path) + ) + }, + 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: FfiConverterSequenceTypeRepositoryContentRow.lift, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + +open func repositoryFile(owner: String, repository: String, path: String)async throws -> Data { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_repository_file( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterString.lower(path) + ) + }, + 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, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + open func selectServer(index: UInt32)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { uniffiCallStatus in uniffi_gotcha_core_fn_method_gotchacore_select_server( @@ -2061,6 +2115,68 @@ public func FfiConverterTypePullRow_lower(_ value: PullRow) -> RustBuffer { } +public struct RepositoryContentRow: Equatable, Hashable { + public var name: String + public var path: String + public var kind: String + public var size: Int64 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(name: String, path: String, kind: String, size: Int64) { + self.name = name + self.path = path + self.kind = kind + self.size = size + } + + + + +} + +#if compiler(>=6) +extension RepositoryContentRow: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeRepositoryContentRow: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> RepositoryContentRow { + return + try RepositoryContentRow( + name: FfiConverterString.read(from: &buf), + path: FfiConverterString.read(from: &buf), + kind: FfiConverterString.read(from: &buf), + size: FfiConverterInt64.read(from: &buf) + ) + } + + public static func write(_ value: RepositoryContentRow, into buf: inout [UInt8]) { + FfiConverterString.write(value.name, into: &buf) + FfiConverterString.write(value.path, into: &buf) + FfiConverterString.write(value.kind, into: &buf) + FfiConverterInt64.write(value.size, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeRepositoryContentRow_lift(_ buf: RustBuffer) throws -> RepositoryContentRow { + return try FfiConverterTypeRepositoryContentRow.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeRepositoryContentRow_lower(_ value: RepositoryContentRow) -> RustBuffer { + return FfiConverterTypeRepositoryContentRow.lower(value) +} + + public struct RepositoryRow: Equatable, Hashable { public var name: String public var owner: String @@ -2661,6 +2777,31 @@ fileprivate struct FfiConverterSequenceTypePullRow: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeRepositoryContentRow: FfiConverterRustBuffer { + typealias SwiftType = [RepositoryContentRow] + + public static func write(_ value: [RepositoryContentRow], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeRepositoryContentRow.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [RepositoryContentRow] { + let len: Int32 = try readInt(&buf) + var seq = [RepositoryContentRow]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeRepositoryContentRow.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -2819,6 +2960,12 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_repositories() != 12256) { return InitializationResult.apiChecksumMismatch } + if (uniffi_gotcha_core_checksum_method_gotchacore_repository_contents() != 8454) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_gotcha_core_checksum_method_gotchacore_repository_file() != 27399) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 22721) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/Generated/gotcha_coreFFI.h b/ios/Generated/gotcha_coreFFI.h index 92a82b8..2ab2e86 100644 --- a/ios/Generated/gotcha_coreFFI.h +++ b/ios/Generated/gotcha_coreFFI.h @@ -334,6 +334,16 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_pulls(uint64_t ptr uint64_t uniffi_gotcha_core_fn_method_gotchacore_repositories(uint64_t ptr ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_CONTENTS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_CONTENTS +uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_contents(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer path +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_FILE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_REPOSITORY_FILE +uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_file(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer path +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER void uniffi_gotcha_core_fn_method_gotchacore_select_server(uint64_t ptr, uint32_t index, RustCallStatus *_Nonnull out_status @@ -722,6 +732,18 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_pulls(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORIES uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repositories(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_CONTENTS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_CONTENTS +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repository_contents(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_FILE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_FILE +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repository_file(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SELECT_SERVER diff --git a/ios/Gotcha.xcodeproj/project.pbxproj b/ios/Gotcha.xcodeproj/project.pbxproj index eaa6654..efd0f7d 100644 --- a/ios/Gotcha.xcodeproj/project.pbxproj +++ b/ios/Gotcha.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5E10787062D1175125BB4C /* ContentScreens.swift */; }; 25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */; }; 374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC828923A1E11A58AE348A74 /* ListScreens.swift */; }; + 7DD332583B169B3CA1CA46E0 /* Highlighter in Frameworks */ = {isa = PBXBuildFile; productRef = EC5F999F50905E3801E8A71A /* Highlighter */; }; 950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C0921C76676A14A024BA417 /* AppDelegate.swift */; }; 96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */; }; D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE12480725C13293FAFDDA52 /* gotcha_core.swift */; }; @@ -31,6 +32,17 @@ FE12480725C13293FAFDDA52 /* gotcha_core.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = gotcha_core.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFrameworksBuildPhase section */ + 9BC71F568A96324A1942A0AC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7DD332583B169B3CA1CA46E0 /* Highlighter in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + /* Begin PBXGroup section */ 710A50F51478401FC642E6E3 /* Sources */ = { isa = PBXGroup; @@ -82,6 +94,7 @@ E01FE2CE8A64C51D25060BE9 /* Build Rust core */, 409DBF67E9C2743801B8F8A4 /* Sources */, 9EDD4380917C29EE67EE344B /* Resources */, + 9BC71F568A96324A1942A0AC /* Frameworks */, ); buildRules = ( ); @@ -89,6 +102,7 @@ ); name = Gotcha; packageProductDependencies = ( + EC5F999F50905E3801E8A71A /* Highlighter */, ); productName = Gotcha; productReference = 5FB3250A93766966A60A685E /* Gotcha.app */; @@ -118,6 +132,9 @@ ); mainGroup = A8558BC8DD12191B80F52573; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 85EFB86C5B2FDE8DEAD98A24 /* XCRemoteSwiftPackageReference "HighlighterSwift" */, + ); preferredProjectObjectVersion = 77; productRefGroup = F059299C038F3CAFCE470831 /* Products */; projectDirPath = ""; @@ -370,6 +387,25 @@ defaultConfigurationName = Debug; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 85EFB86C5B2FDE8DEAD98A24 /* XCRemoteSwiftPackageReference "HighlighterSwift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/smittytone/HighlighterSwift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.1.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + EC5F999F50905E3801E8A71A /* Highlighter */ = { + isa = XCSwiftPackageProductDependency; + package = 85EFB86C5B2FDE8DEAD98A24 /* XCRemoteSwiftPackageReference "HighlighterSwift" */; + productName = Highlighter; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = C30A6F1B592A907D96D6AB40 /* Project object */; } diff --git a/ios/Gotcha.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Gotcha.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..97d9c79 --- /dev/null +++ b/ios/Gotcha.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "00a1c96095892a05bfd089593813422ede18735e452666e3e7a642ebd9048a45", + "pins" : [ + { + "identity" : "highlighterswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/smittytone/HighlighterSwift", + "state" : { + "revision" : "fe7aae9c9b31d3b296fd3d2dd575e1a207bb29e0", + "version" : "3.1.0" + } + } + ], + "version" : 3 +} diff --git a/ios/Sources/ContentScreens.swift b/ios/Sources/ContentScreens.swift index c4a2c82..1208b2b 100644 --- a/ios/Sources/ContentScreens.swift +++ b/ios/Sources/ContentScreens.swift @@ -502,11 +502,15 @@ final class PullsViewController: RefreshingTableViewController { @MainActor final class CommitsViewController: RefreshingTableViewController { + private enum Mode: Int { case history, files } + private let context: AppContext private let owner: String private let repository: String private var page: CommitPage? + private var contents: [RepositoryContentRow] = [] private var branch: String? + private var mode = Mode.history init(context: AppContext, owner: String, repository: String) { self.context = context @@ -522,6 +526,11 @@ final class CommitsViewController: RefreshingTableViewController { override func viewDidLoad() { super.viewDidLoad() tableView.register(CommitCell.self, forCellReuseIdentifier: "commit") + let modeControl = UISegmentedControl(items: ["History", "Files"]) + modeControl.selectedSegmentIndex = mode.rawValue + modeControl.addTarget(self, action: #selector(modeChanged(_:)), for: .valueChanged) + modeControl.accessibilityLabel = "Repository view" + navigationItem.titleView = modeControl updateBranchMenu() loadContent(refreshing: false) } @@ -531,16 +540,23 @@ final class CommitsViewController: RefreshingTableViewController { loadingTask?.cancel() loadingTask = Task { do { - page = try await context.core.commits( - owner: owner, - repository: repository, - branch: branch - ) - updateBranchMenu() + switch mode { + case .history: + page = try await context.core.commits( + owner: owner, + repository: repository, + branch: branch + ) + updateBranchMenu() + case .files: + contents = try await context.core.repositoryContents( + owner: owner, + repository: repository, + path: "" + ) + } tableView.reloadData() - tableView.backgroundView = page?.commits.isEmpty == true - ? EmptyBackgroundView(title: "No commits", detail: "This repository has no commit history.") - : nil + updateEmptyView() } catch { if !Task.isCancelled { show(error: error) } } @@ -549,34 +565,75 @@ final class CommitsViewController: RefreshingTableViewController { } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - page?.commits.count ?? 0 + mode == .history ? page?.commits.count ?? 0 : contents.count } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "commit", for: indexPath) as! CommitCell - if let page { cell.configure(page.commits[indexPath.row], laneCount: page.laneCount) } - return cell + switch mode { + case .history: + let cell = tableView.dequeueReusableCell(withIdentifier: "commit", for: indexPath) as! CommitCell + if let page { cell.configure(page.commits[indexPath.row], laneCount: page.laneCount) } + return cell + case .files: + let cell = tableView.dequeueReusableCell(withIdentifier: "repository-content") + ?? UITableViewCell(style: .subtitle, reuseIdentifier: "repository-content") + configureRepositoryContentCell(cell, row: contents[indexPath.row]) + return cell + } } override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - 86 + mode == .history ? 86 : UITableView.automaticDimension } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - guard let row = page?.commits[indexPath.row] else { return } - navigationController?.pushViewController( - FilesViewController( + switch mode { + case .history: + guard let row = page?.commits[indexPath.row] else { return } + navigationController?.pushViewController( + FilesViewController( + context: context, + owner: owner, + repository: repository, + sha: row.sha + ), + animated: true + ) + case .files: + showRepositoryContent( + contents[indexPath.row], context: context, owner: owner, repository: repository, - sha: row.sha - ), - animated: true - ) + navigationController: navigationController + ) + } + } + + @objc private func modeChanged(_ sender: UISegmentedControl) { + guard let mode = Mode(rawValue: sender.selectedSegmentIndex), mode != self.mode else { return } + self.mode = mode + navigationItem.rightBarButtonItem = nil + if mode == .history { updateBranchMenu() } + tableView.reloadData() + loadContent(refreshing: false) + } + + private func updateEmptyView() { + switch mode { + case .history: + tableView.backgroundView = page?.commits.isEmpty == true + ? EmptyBackgroundView(title: "No commits", detail: "This repository has no commit history.") + : nil + case .files: + tableView.backgroundView = contents.isEmpty + ? EmptyBackgroundView(title: "No files", detail: "This repository is empty.") + : nil + } } private func updateBranchMenu() { diff --git a/ios/Sources/DetailScreens.swift b/ios/Sources/DetailScreens.swift index 6a93fec..cec5fc4 100644 --- a/ios/Sources/DetailScreens.swift +++ b/ios/Sources/DetailScreens.swift @@ -1,4 +1,7 @@ +import Highlighter +import QuickLook import UIKit +import UniformTypeIdentifiers @MainActor class MarkdownPageViewController: UIViewController { @@ -259,6 +262,192 @@ final class FilesViewController: RefreshingTableViewController { } } +@MainActor +final class RepositoryDirectoryViewController: RefreshingTableViewController { + private let context: AppContext + private let owner: String + private let repository: String + private let path: String + private var rows: [RepositoryContentRow] = [] + + init(context: AppContext, owner: String, repository: String, path: String) { + self.context = context + self.owner = owner + self.repository = repository + self.path = path + super.init() + title = path.split(separator: "/").last.map(String.init) ?? repository + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override func viewDidLoad() { + super.viewDidLoad() + loadContent(refreshing: false) + } + + override func loadContent(refreshing: Bool) { + beginLoading(refreshing: refreshing) + loadingTask?.cancel() + loadingTask = Task { + do { + rows = try await context.core.repositoryContents( + owner: owner, + repository: repository, + path: path + ) + tableView.reloadData() + tableView.backgroundView = rows.isEmpty + ? EmptyBackgroundView(title: "Empty folder", detail: "This folder does not contain any files.") + : nil + } catch { + if !Task.isCancelled { show(error: error) } + } + endLoading() + } + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + rows.count + } + + override func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "repository-content") + ?? UITableViewCell(style: .subtitle, reuseIdentifier: "repository-content") + configureRepositoryContentCell(cell, row: rows[indexPath.row]) + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + showRepositoryContent( + rows[indexPath.row], + context: context, + owner: owner, + repository: repository, + navigationController: navigationController + ) + } +} + +@MainActor +final class RepositoryFileViewController: UIViewController, QLPreviewControllerDataSource { + private let context: AppContext + private let owner: String + private let repository: String + private let path: String + private let spinner = UIActivityIndicatorView(style: .medium) + private var loadingTask: Task? + private var previewURL: URL? + + init(context: AppContext, owner: String, repository: String, path: 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) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + beginNavigationLoading(spinner) + loadingTask = Task { + do { + let data = try await context.core.repositoryFile( + owner: owner, + repository: repository, + path: path + ) + if !isMediaFile(path), let source = String(data: data, encoding: .utf8) { + showSource(source) + } else { + try showQuickLook(data) + } + } catch { + if !Task.isCancelled { show(error: error) } + } + endNavigationLoading(spinner) + } + } + + 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) + } + + deinit { + loadingTask?.cancel() + if let previewURL { try? FileManager.default.removeItem(at: previewURL) } + } + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + previewURL == nil ? 0 : 1 + } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + previewURL! as NSURL + } + + private func showSource(_ source: String) { + let textView = UITextView(frame: view.bounds) + textView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + textView.isEditable = false + textView.isSelectable = true + textView.alwaysBounceHorizontal = true + textView.isDirectionalLockEnabled = true + textView.showsHorizontalScrollIndicator = true + textView.textContainer.widthTracksTextView = false + textView.textContainer.size = CGSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + let highlighter = Highlighter() + highlighter?.setTheme( + traitCollection.userInterfaceStyle == .dark ? "atom-one-dark" : "atom-one-light" + ) + highlighter?.theme.setCodeFont(.monospacedSystemFont(ofSize: 13, weight: .regular)) + textView.attributedText = highlighter?.highlight(source, as: sourceLanguage(path)) + ?? NSAttributedString(string: source, attributes: [ + .font: UIFont.monospacedSystemFont(ofSize: 13, weight: .regular), + .foregroundColor: UIColor.label, + ]) + textView.layoutManager.ensureLayout(for: textView.textContainer) + textView.contentSize.width = textView.layoutManager.usedRect(for: textView.textContainer).width + + textView.textContainerInset.left + + textView.textContainerInset.right + view.addSubview(textView) + } + + private func showQuickLook(_ data: Data) throws { + let name = path.split(separator: "/").last.map(String.init) ?? "Preview" + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("\(UUID().uuidString)-\(name)") + try data.write(to: url, options: .atomic) + previewURL = url + let preview = QLPreviewController() + preview.dataSource = self + addChild(preview) + preview.view.frame = view.bounds + preview.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(preview.view) + preview.didMove(toParent: self) + } +} + enum DiffSource { case commit(owner: String, repository: String, sha: String, path: String) case pull(owner: String, repository: String, number: Int64, path: String) diff --git a/ios/Sources/Support.swift b/ios/Sources/Support.swift index 9b03e04..9549136 100644 --- a/ios/Sources/Support.swift +++ b/ios/Sources/Support.swift @@ -124,6 +124,66 @@ func configureTextCell(_ cell: UITableViewCell, title: String, detail: String, i cell.backgroundColor = .systemBackground } +func configureRepositoryContentCell(_ cell: UITableViewCell, row: RepositoryContentRow) { + let directory = row.kind == "dir" + let detail = directory + ? "Folder" + : ByteCountFormatter.string(fromByteCount: row.size, countStyle: .file) + configureTextCell( + cell, + title: row.name, + detail: detail, + image: UIImage(systemName: directory ? "folder.fill" : "doc") + ) + cell.accessoryType = .disclosureIndicator +} + +@MainActor +func showRepositoryContent( + _ row: RepositoryContentRow, + context: AppContext, + owner: String, + repository: String, + navigationController: UINavigationController? +) { + let destination: UIViewController = row.kind == "dir" + ? RepositoryDirectoryViewController( + context: context, + owner: owner, + repository: repository, + path: row.path + ) + : RepositoryFileViewController( + context: context, + owner: owner, + repository: repository, + path: row.path + ) + 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) { diff --git a/ios/project.yml b/ios/project.yml index e1310a1..f0b936d 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -3,6 +3,10 @@ options: bundleIdPrefix: de.rfc1437 settings: ENABLE_USER_SCRIPT_SANDBOXING: NO +packages: + Highlighter: + url: https://github.com/smittytone/HighlighterSwift + from: 3.1.0 targets: Gotcha: type: application @@ -29,6 +33,8 @@ targets: - Assets.xcassets - Sources - Generated/gotcha_core.swift + dependencies: + - package: Highlighter preBuildScripts: - name: Build Rust core basedOnDependencyAnalysis: false