diff --git a/Classes/Repository/GitHubClient+Repository.swift b/Classes/Repository/GitHubClient+Repository.swift new file mode 100644 index 00000000..323e8edb --- /dev/null +++ b/Classes/Repository/GitHubClient+Repository.swift @@ -0,0 +1,46 @@ +// +// GitHubClient+Repository.swift +// Freetime +// +// Created by Ryan Nystrom on 10/14/17. +// Copyright © 2017 Ryan Nystrom. All rights reserved. +// + +import Foundation + +extension GithubClient { + + func fetchFiles( + owner: String, + repo: String, + path: String, + completion: @escaping (Result<[RepositoryFile]>) -> () + ) { + let query = RepoFilesQuery(owner: owner, name: repo, branchAndPath: "master:\(path)") + fetch(query: query) { (result, error) in + if let models = result?.data?.repository?.object?.asTree?.entries { + // trees A-Z first, then blobs A-Z + var trees = [RepositoryFile]() + var blobs = [RepositoryFile]() + for model in models { + let isTree = model.type == "tree" + let file = RepositoryFile( + name: model.name, + isDirectory: model.type == "tree" + ) + if isTree { + trees.append(file) + } else { + blobs.append(file) + } + } + trees.sort { $0.name < $1.name } + blobs.sort { $0.name < $1.name } + completion(.success(trees + blobs)) + } else { + completion(.error(nil)) + } + } + } + +} diff --git a/Classes/Repository/RepositoryCodeDirectoryViewController.swift b/Classes/Repository/RepositoryCodeDirectoryViewController.swift index 99e3b622..04273c9c 100644 --- a/Classes/Repository/RepositoryCodeDirectoryViewController.swift +++ b/Classes/Repository/RepositoryCodeDirectoryViewController.swift @@ -8,16 +8,26 @@ import UIKit -class RepositoryCodeDirectoryViewController: UITableViewController { +class RepositoryCodeDirectoryViewController: UIViewController, UITableViewDelegate, UITableViewDataSource { + private let tableView = UITableView(frame: .zero, style: .plain) + private let client: GithubClient private let path: String + private let repo: RepositoryDetails private let cellIdentifier = "cell" private let feedRefresh = FeedRefresh() private var files = [RepositoryFile]() + private let isRoot: Bool - init(path: String) { + init(client: GithubClient, repo: RepositoryDetails, path: String, isRoot: Bool) { + self.client = client + self.repo = repo self.path = path + self.isRoot = isRoot super.init(nibName: nil, bundle: nil) + self.title = isRoot + ? NSLocalizedString("Code", comment: "") + : path } required init?(coder aDecoder: NSCoder) { @@ -27,14 +37,15 @@ class RepositoryCodeDirectoryViewController: UITableViewController { override func viewDidLoad() { super.viewDidLoad() - files = [ - RepositoryFile(name: "Classes", isDirectory: true), - RepositoryFile(name: "Tests", isDirectory: true), - RepositoryFile(name: "Freetime.xcodeproj", isDirectory: true), - RepositoryFile(name: "LICENSE", isDirectory: false), - RepositoryFile(name: "README.md", isDirectory: false), - RepositoryFile(name: "Podfile", isDirectory: false), - ] + tableView.dataSource = self + tableView.delegate = self + view.addSubview(tableView) + + // set the frame in -viewDidLoad is required when working with TabMan + tableView.frame = view.bounds + if isRoot, #available(iOS 11.0, *) { + tableView.contentInsetAdjustmentBehavior = .never + } makeBackBarItemEmpty() @@ -45,36 +56,57 @@ class RepositoryCodeDirectoryViewController: UITableViewController { ) tableView.refreshControl = feedRefresh.refreshControl tableView.register(StyledTableCell.self, forCellReuseIdentifier: cellIdentifier) + tableView.tableFooterView = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 0.1)) feedRefresh.beginRefreshing() fetch() } + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + tableView.frame = view.bounds + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + rz_smoothlyDeselectRows(tableView: tableView) + } + // MARK: Private API func fetch() { - feedRefresh.endRefreshing() + client.fetchFiles(owner: repo.owner, repo: repo.name, path: path) { [weak self] (result) in + switch result { + case .error: + ToastManager.showGenericError() + case .success(let files): + self?.files = files + self?.tableView.reloadData() + self?.feedRefresh.endRefreshing() + } + } } @objc func onRefresh() { - feedRefresh.endRefreshing() + fetch() } // MARK: UITableViewDataSource - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return files.count } - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) let file = files[indexPath.row] cell.textLabel?.text = file.name let imageName = file.isDirectory ? "file-directory" : "file" - cell.imageView?.image = UIImage(named: imageName) + cell.imageView?.image = UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate) + cell.imageView?.tintColor = Styles.Colors.blueGray.color cell.accessoryType = file.isDirectory ? .disclosureIndicator : .none return cell @@ -82,10 +114,19 @@ class RepositoryCodeDirectoryViewController: UITableViewController { // MARK: UITableViewDelegate - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - - // TODO: push another controller + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let file = files[indexPath.row] + if file.isDirectory { + let controller = RepositoryCodeDirectoryViewController( + client: client, + repo: repo, + path: path.isEmpty ? file.name : "\(path)/\(file.name)", + isRoot: false + ) + navigationController?.pushViewController(controller, animated: true) + } else { + tableView.deselectRow(at: indexPath, animated: true) + } } } diff --git a/Classes/Repository/RepositoryViewController.swift b/Classes/Repository/RepositoryViewController.swift index 4288a90b..40e67dde 100644 --- a/Classes/Repository/RepositoryViewController.swift +++ b/Classes/Repository/RepositoryViewController.swift @@ -28,7 +28,10 @@ NewIssueTableViewControllerDelegate { if repo.hasIssuesEnabled { controllers.append(RepositoryIssuesViewController(client: client, repo: repo, type: .issues)) } - controllers.append(RepositoryIssuesViewController(client: client, repo: repo, type: .pullRequests)) + controllers += [ + RepositoryIssuesViewController(client: client, repo: repo, type: .pullRequests), + RepositoryCodeDirectoryViewController(client: client, repo: repo, path: "", isRoot: true), + ] self.controllers = controllers super.init(nibName: nil, bundle: nil) diff --git a/Classes/Views/Styles.swift b/Classes/Views/Styles.swift index fb41bfa2..af941885 100644 --- a/Classes/Views/Styles.swift +++ b/Classes/Views/Styles.swift @@ -81,6 +81,7 @@ enum Styles { static let background = Styles.Colors.Gray.lighter.color static let purple = "6f42c1" + static let blueGray = "8697af" enum Red { static let medium = "cb2431" @@ -111,6 +112,7 @@ enum Styles { static let medium = "f29d50" static let light = "fff5b1" } + } static func setupAppearance() { diff --git a/Freetime.xcodeproj/project.pbxproj b/Freetime.xcodeproj/project.pbxproj index 42b7f8c3..ff1ad1af 100644 --- a/Freetime.xcodeproj/project.pbxproj +++ b/Freetime.xcodeproj/project.pbxproj @@ -265,6 +265,7 @@ 29A5AF3F1F9266370065D529 /* NotificationViewModel+Filterable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29A5AF3E1F9266370065D529 /* NotificationViewModel+Filterable.swift */; }; 29A5AF411F92677D0065D529 /* RepositoryIssueSummaryModel+Filterable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29A5AF401F92677D0065D529 /* RepositoryIssueSummaryModel+Filterable.swift */; }; 29A5AF431F926C600065D529 /* Filterable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29A5AF421F926C600065D529 /* Filterable.swift */; }; + 29A5AF451F9298360065D529 /* GitHubClient+Repository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29A5AF441F9298360065D529 /* GitHubClient+Repository.swift */; }; 29AC90E51F00A7C8000B80E4 /* SplitViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29AC90E41F00A7C8000B80E4 /* SplitViewControllerDelegate.swift */; }; 29AF1E821F8AAB2B0008A0EF /* EditCommentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29AF1E811F8AAB2B0008A0EF /* EditCommentViewController.swift */; }; 29AF1E841F8AAB4A0008A0EF /* UITextView+GitHawk.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29AF1E831F8AAB4A0008A0EF /* UITextView+GitHawk.swift */; }; @@ -609,6 +610,7 @@ 29A5AF3E1F9266370065D529 /* NotificationViewModel+Filterable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewModel+Filterable.swift"; sourceTree = ""; }; 29A5AF401F92677D0065D529 /* RepositoryIssueSummaryModel+Filterable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RepositoryIssueSummaryModel+Filterable.swift"; sourceTree = ""; }; 29A5AF421F926C600065D529 /* Filterable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Filterable.swift; sourceTree = ""; }; + 29A5AF441F9298360065D529 /* GitHubClient+Repository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GitHubClient+Repository.swift"; sourceTree = ""; }; 29AC90E41F00A7C8000B80E4 /* SplitViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitViewControllerDelegate.swift; sourceTree = ""; }; 29AF1E811F8AAB2B0008A0EF /* EditCommentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCommentViewController.swift; sourceTree = ""; }; 29AF1E831F8AAB4A0008A0EF /* UITextView+GitHawk.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextView+GitHawk.swift"; sourceTree = ""; }; @@ -1495,6 +1497,7 @@ isa = PBXGroup; children = ( 292ACE191F5CAF9F00C9A02C /* Empty */, + 29A5AF441F9298360065D529 /* GitHubClient+Repository.swift */, 986B873B1F2CEB1500AAB55C /* GQL+RepositoryIssueSummaryType.swift */, 986B873D1F2E1CE400AAB55C /* RepositoryClient.swift */, 29AF1E8B1F8ABC5A0008A0EF /* RepositoryCodeDirectoryViewController.swift */, @@ -2011,6 +2014,7 @@ 2931892D1F539BA300EF0911 /* IssueMilestoneModel.swift in Sources */, 2931892F1F539C0E00EF0911 /* IssueMilestoneSectionController.swift in Sources */, 29F7F0611F2A83AA00F6075D /* IssueNeckLoadCell.swift in Sources */, + 29A5AF451F9298360065D529 /* GitHubClient+Repository.swift in Sources */, 29F7F0631F2A875C00F6075D /* IssueNeckLoadModel.swift in Sources */, 29F7F05F1F2A839100F6075D /* IssueNeckLoadSectionController.swift in Sources */, 2919295F1F3FD1F40012067B /* IssuePatchContentViewController.swift in Sources */, diff --git a/Resources/Info.plist b/Resources/Info.plist index 5859577c..a44dd205 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -32,7 +32,7 @@ CFBundleVersion - 2038 + 2050 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/gql/API.swift b/gql/API.swift index c0690d29..3e6058b1 100644 --- a/gql/API.swift +++ b/gql/API.swift @@ -11239,6 +11239,233 @@ public final class RemoveReactionMutation: GraphQLMutation { } } +public final class RepoFilesQuery: GraphQLQuery { + public static let operationString = + "query RepoFiles($owner: String!, $name: String!, $branchAndPath: String!) {\n repository(owner: $owner, name: $name) {\n __typename\n object(expression: $branchAndPath) {\n __typename\n ... on Tree {\n entries {\n __typename\n name\n type\n }\n }\n }\n }\n}" + + public var owner: String + public var name: String + public var branchAndPath: String + + public init(owner: String, name: String, branchAndPath: String) { + self.owner = owner + self.name = name + self.branchAndPath = branchAndPath + } + + public var variables: GraphQLMap? { + return ["owner": owner, "name": name, "branchAndPath": branchAndPath] + } + + public struct Data: GraphQLSelectionSet { + public static let possibleTypes = ["Query"] + + public static let selections: [GraphQLSelection] = [ + GraphQLField("repository", arguments: ["owner": GraphQLVariable("owner"), "name": GraphQLVariable("name")], type: .object(Repository.selections)), + ] + + public var snapshot: Snapshot + + public init(snapshot: Snapshot) { + self.snapshot = snapshot + } + + public init(repository: Repository? = nil) { + self.init(snapshot: ["__typename": "Query", "repository": repository.flatMap { $0.snapshot }]) + } + + /// Lookup a given repository by the owner and repository name. + public var repository: Repository? { + get { + return (snapshot["repository"] as? Snapshot).flatMap { Repository(snapshot: $0) } + } + set { + snapshot.updateValue(newValue?.snapshot, forKey: "repository") + } + } + + public struct Repository: GraphQLSelectionSet { + public static let possibleTypes = ["Repository"] + + public static let selections: [GraphQLSelection] = [ + GraphQLField("__typename", type: .nonNull(.scalar(String.self))), + GraphQLField("object", arguments: ["expression": GraphQLVariable("branchAndPath")], type: .object(Object.selections)), + ] + + public var snapshot: Snapshot + + public init(snapshot: Snapshot) { + self.snapshot = snapshot + } + + public init(object: Object? = nil) { + self.init(snapshot: ["__typename": "Repository", "object": object.flatMap { $0.snapshot }]) + } + + public var __typename: String { + get { + return snapshot["__typename"]! as! String + } + set { + snapshot.updateValue(newValue, forKey: "__typename") + } + } + + /// A Git object in the repository + public var object: Object? { + get { + return (snapshot["object"] as? Snapshot).flatMap { Object(snapshot: $0) } + } + set { + snapshot.updateValue(newValue?.snapshot, forKey: "object") + } + } + + public struct Object: GraphQLSelectionSet { + public static let possibleTypes = ["Commit", "Tree", "Blob", "Tag"] + + public static let selections: [GraphQLSelection] = [ + GraphQLTypeCase( + variants: ["Tree": AsTree.selections], + default: [ + GraphQLField("__typename", type: .nonNull(.scalar(String.self))), + ] + ) + ] + + public var snapshot: Snapshot + + public init(snapshot: Snapshot) { + self.snapshot = snapshot + } + + public static func makeCommit() -> Object { + return Object(snapshot: ["__typename": "Commit"]) + } + + public static func makeBlob() -> Object { + return Object(snapshot: ["__typename": "Blob"]) + } + + public static func makeTag() -> Object { + return Object(snapshot: ["__typename": "Tag"]) + } + + public static func makeTree(entries: [AsTree.Entry]? = nil) -> Object { + return Object(snapshot: ["__typename": "Tree", "entries": entries.flatMap { $0.map { $0.snapshot } }]) + } + + public var __typename: String { + get { + return snapshot["__typename"]! as! String + } + set { + snapshot.updateValue(newValue, forKey: "__typename") + } + } + + public var asTree: AsTree? { + get { + if !AsTree.possibleTypes.contains(__typename) { return nil } + return AsTree(snapshot: snapshot) + } + set { + guard let newValue = newValue else { return } + snapshot = newValue.snapshot + } + } + + public struct AsTree: GraphQLSelectionSet { + public static let possibleTypes = ["Tree"] + + public static let selections: [GraphQLSelection] = [ + GraphQLField("__typename", type: .nonNull(.scalar(String.self))), + GraphQLField("entries", type: .list(.nonNull(.object(Entry.selections)))), + ] + + public var snapshot: Snapshot + + public init(snapshot: Snapshot) { + self.snapshot = snapshot + } + + public init(entries: [Entry]? = nil) { + self.init(snapshot: ["__typename": "Tree", "entries": entries.flatMap { $0.map { $0.snapshot } }]) + } + + public var __typename: String { + get { + return snapshot["__typename"]! as! String + } + set { + snapshot.updateValue(newValue, forKey: "__typename") + } + } + + /// A list of tree entries. + public var entries: [Entry]? { + get { + return (snapshot["entries"] as? [Snapshot]).flatMap { $0.map { Entry(snapshot: $0) } } + } + set { + snapshot.updateValue(newValue.flatMap { $0.map { $0.snapshot } }, forKey: "entries") + } + } + + public struct Entry: GraphQLSelectionSet { + public static let possibleTypes = ["TreeEntry"] + + public static let selections: [GraphQLSelection] = [ + GraphQLField("__typename", type: .nonNull(.scalar(String.self))), + GraphQLField("name", type: .nonNull(.scalar(String.self))), + GraphQLField("type", type: .nonNull(.scalar(String.self))), + ] + + public var snapshot: Snapshot + + public init(snapshot: Snapshot) { + self.snapshot = snapshot + } + + public init(name: String, type: String) { + self.init(snapshot: ["__typename": "TreeEntry", "name": name, "type": type]) + } + + public var __typename: String { + get { + return snapshot["__typename"]! as! String + } + set { + snapshot.updateValue(newValue, forKey: "__typename") + } + } + + /// Entry file name. + public var name: String { + get { + return snapshot["name"]! as! String + } + set { + snapshot.updateValue(newValue, forKey: "name") + } + } + + /// Entry file type. + public var type: String { + get { + return snapshot["type"]! as! String + } + set { + snapshot.updateValue(newValue, forKey: "type") + } + } + } + } + } + } + } +} + public final class RepoIssuePagesQuery: GraphQLQuery { public static let operationString = "query RepoIssuePages($owner: String!, $name: String!, $after: String, $page_size: Int!) {\n repository(owner: $owner, name: $name) {\n __typename\n issues(first: $page_size, orderBy: {field: CREATED_AT, direction: DESC}, states: [OPEN, CLOSED], after: $after) {\n __typename\n nodes {\n __typename\n ...repoEventFields\n ...nodeFields\n title\n number\n state\n }\n pageInfo {\n __typename\n hasNextPage\n endCursor\n }\n }\n }\n}" diff --git a/gql/RepoFiles.graphql b/gql/RepoFiles.graphql new file mode 100644 index 00000000..818c7494 --- /dev/null +++ b/gql/RepoFiles.graphql @@ -0,0 +1,12 @@ +query RepoFiles($owner: String!, $name: String!, $branchAndPath: String!) { + repository(owner: $owner, name: $name) { + object(expression: $branchAndPath) { + ... on Tree { + entries { + name + type + } + } + } + } +}