Add path-filtered repository history
This commit is contained in:
10
TESTING.md
10
TESTING.md
@@ -207,6 +207,11 @@ xcrun simctl launch booted de.rfc1437.gotcha
|
|||||||
- [ ] In Files, traverse several nested folders using rows, the navigation-bar
|
- [ ] In Files, traverse several nested folders using rows, the navigation-bar
|
||||||
Back button, and the left-edge swipe. Folder contents and titles match the
|
Back button, and the left-edge swipe. Folder contents and titles match the
|
||||||
repository hierarchy.
|
repository hierarchy.
|
||||||
|
- [ ] In the repository root and in several nested folders, switch between
|
||||||
|
**Files** and **History**. Each folder history contains only commits that
|
||||||
|
affect that folder or its descendants. Switch back to Files and verify the
|
||||||
|
same folder and navigation stack are preserved. Open a history commit and
|
||||||
|
verify its changed-files detail opens normally.
|
||||||
- [ ] Open representative source files in several languages, including Rust,
|
- [ ] Open representative source files in several languages, including Rust,
|
||||||
Swift, and a scripting or markup language. Keywords, strings, comments,
|
Swift, and a scripting or markup language. Keywords, strings, comments,
|
||||||
types, and punctuation use plausible language-specific highlighting.
|
types, and punctuation use plausible language-specific highlighting.
|
||||||
@@ -223,6 +228,11 @@ xcrun simctl launch booted de.rfc1437.gotcha
|
|||||||
is syntax-highlighted, selectable, does not word-wrap, and scrolls
|
is syntax-highlighted, selectable, does not word-wrap, and scrolls
|
||||||
horizontally for long lines. Switch back and verify the rendered preview
|
horizontally for long lines. Switch back and verify the rendered preview
|
||||||
is restored without stale or overlapping content.
|
is restored without stale or overlapping content.
|
||||||
|
- [ ] Switch Markdown files among **Preview**, **Source**, and **History** and
|
||||||
|
switch other source and native-preview files between **Content** and
|
||||||
|
**History**. Each history contains only commits that affect that exact
|
||||||
|
file. Returning to content preserves the file path and restores its native
|
||||||
|
preview or source presentation without stale or overlapping views.
|
||||||
- [ ] Open representative image, PDF, audio, and video files. Each uses the
|
- [ ] Open representative image, PDF, audio, and video files. Each uses the
|
||||||
native preview appropriate to its media type and returns cleanly to Files.
|
native preview appropriate to its media type and returns cleanly to Files.
|
||||||
|
|
||||||
|
|||||||
@@ -450,9 +450,10 @@ pub async fn load_branch_commits(
|
|||||||
owner: &str,
|
owner: &str,
|
||||||
repository: &str,
|
repository: &str,
|
||||||
branch: &str,
|
branch: &str,
|
||||||
|
path: Option<&str>,
|
||||||
pages: u32,
|
pages: u32,
|
||||||
) -> Result<Page<HistoryCommit>, String> {
|
) -> Result<Page<HistoryCommit>, String> {
|
||||||
load_commits_for_ref(server, owner, repository, Some(branch), pages)
|
load_commits_for_ref(server, owner, repository, Some(branch), path, pages)
|
||||||
.await
|
.await
|
||||||
.map(|page| Page {
|
.map(|page| Page {
|
||||||
has_more: page.has_more,
|
has_more: page.has_more,
|
||||||
@@ -476,15 +477,24 @@ pub async fn load_all_commits(
|
|||||||
owner: &str,
|
owner: &str,
|
||||||
repository: &str,
|
repository: &str,
|
||||||
branches: &[String],
|
branches: &[String],
|
||||||
|
path: Option<&str>,
|
||||||
pages: u32,
|
pages: u32,
|
||||||
) -> Result<Page<HistoryCommit>, String> {
|
) -> Result<Page<HistoryCommit>, String> {
|
||||||
let mut tasks = JoinSet::new();
|
let mut tasks = JoinSet::new();
|
||||||
for (lane, branch) in branches.iter().cloned().enumerate() {
|
for (lane, branch) in branches.iter().cloned().enumerate() {
|
||||||
let (server, owner, repository) =
|
let (server, owner, repository) =
|
||||||
(server.clone(), owner.to_string(), repository.to_string());
|
(server.clone(), owner.to_string(), repository.to_string());
|
||||||
|
let path = path.map(str::to_string);
|
||||||
tasks.spawn(async move {
|
tasks.spawn(async move {
|
||||||
let commits =
|
let commits = load_commits_for_ref(
|
||||||
load_commits_for_ref(&server, &owner, &repository, Some(&branch), pages).await?;
|
&server,
|
||||||
|
&owner,
|
||||||
|
&repository,
|
||||||
|
Some(&branch),
|
||||||
|
path.as_deref(),
|
||||||
|
pages,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok::<_, String>((lane, branch, commits))
|
Ok::<_, String>((lane, branch, commits))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1024,6 +1034,7 @@ async fn load_commits_for_ref(
|
|||||||
owner: &str,
|
owner: &str,
|
||||||
repository: &str,
|
repository: &str,
|
||||||
branch: Option<&str>,
|
branch: Option<&str>,
|
||||||
|
path: Option<&str>,
|
||||||
pages: u32,
|
pages: u32,
|
||||||
) -> Result<Page<models::Commit>, String> {
|
) -> Result<Page<models::Commit>, String> {
|
||||||
let client =
|
let client =
|
||||||
@@ -1037,7 +1048,7 @@ async fn load_commits_for_ref(
|
|||||||
owner,
|
owner,
|
||||||
repository,
|
repository,
|
||||||
branch,
|
branch,
|
||||||
None,
|
path,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -599,6 +599,7 @@ impl GotchaCore {
|
|||||||
owner: String,
|
owner: String,
|
||||||
repository: String,
|
repository: String,
|
||||||
branch: Option<String>,
|
branch: Option<String>,
|
||||||
|
path: String,
|
||||||
pages: u32,
|
pages: u32,
|
||||||
) -> Result<CommitPage, GotchaError> {
|
) -> Result<CommitPage, GotchaError> {
|
||||||
let server = self.server()?;
|
let server = self.server()?;
|
||||||
@@ -612,11 +613,16 @@ impl GotchaCore {
|
|||||||
.map(|candidate| candidate.default_branch.clone())
|
.map(|candidate| candidate.default_branch.clone())
|
||||||
.unwrap_or_else(|| "main".into());
|
.unwrap_or_else(|| "main".into());
|
||||||
let branches = load_branches(&server, &owner, &repository, &default_branch).await?;
|
let branches = load_branches(&server, &owner, &repository, &default_branch).await?;
|
||||||
|
let path = (!path.is_empty()).then_some(path.as_str());
|
||||||
let commits = match branch.as_deref() {
|
let commits = match branch.as_deref() {
|
||||||
Some(branch) => {
|
Some(branch) => {
|
||||||
load_branch_commits(&server, &owner, &repository, branch, pages.max(1)).await?
|
load_branch_commits(&server, &owner, &repository, branch, path, pages.max(1))
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
load_all_commits(&server, &owner, &repository, &branches, path, pages.max(1))
|
||||||
|
.await?
|
||||||
}
|
}
|
||||||
None => load_all_commits(&server, &owner, &repository, &branches, pages.max(1)).await?,
|
|
||||||
};
|
};
|
||||||
Ok(commit_page(
|
Ok(commit_page(
|
||||||
branches,
|
branches,
|
||||||
|
|||||||
@@ -613,7 +613,7 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable {
|
|||||||
|
|
||||||
func commitFiles(owner: String, repository: String, sha: String) async throws -> [FileRow]
|
func commitFiles(owner: String, repository: String, sha: String) async throws -> [FileRow]
|
||||||
|
|
||||||
func commits(owner: String, repository: String, branch: String?, pages: UInt32) async throws -> CommitPage
|
func commits(owner: String, repository: String, branch: String?, path: String, pages: UInt32) async throws -> CommitPage
|
||||||
|
|
||||||
func home(page: UInt32) async throws -> HomePage
|
func home(page: UInt32) async throws -> HomePage
|
||||||
|
|
||||||
@@ -803,12 +803,12 @@ open func commitFiles(owner: String, repository: String, sha: String)async throw
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
open func commits(owner: String, repository: String, branch: String?, pages: UInt32)async throws -> CommitPage {
|
open func commits(owner: String, repository: String, branch: String?, path: String, pages: UInt32)async throws -> CommitPage {
|
||||||
return
|
return
|
||||||
try await uniffiRustCallAsync(
|
try await uniffiRustCallAsync(
|
||||||
rustFutureFunc: {
|
rustFutureFunc: {
|
||||||
uniffi_gotcha_core_fn_method_gotchacore_commits(
|
uniffi_gotcha_core_fn_method_gotchacore_commits(
|
||||||
self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterOptionString.lower(branch),FfiConverterUInt32.lower(pages)
|
self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterOptionString.lower(branch),FfiConverterString.lower(path),FfiConverterUInt32.lower(pages)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer,
|
pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer,
|
||||||
@@ -3948,7 +3948,7 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_gotcha_core_checksum_method_gotchacore_commit_files() != 43926) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_commit_files() != 43926) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
if (uniffi_gotcha_core_checksum_method_gotchacore_commits() != 59625) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_commits() != 60062) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
if (uniffi_gotcha_core_checksum_method_gotchacore_home() != 38990) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_home() != 38990) {
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_commit_files(uint64_t ptr, Rust
|
|||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMITS
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMITS
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMITS
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMITS
|
||||||
uint64_t uniffi_gotcha_core_fn_method_gotchacore_commits(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer branch, uint32_t pages
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_commits(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer branch, RustBuffer path, uint32_t pages
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME
|
||||||
|
|||||||
@@ -958,6 +958,7 @@ final class CommitsViewController: RefreshingTableViewController {
|
|||||||
owner: owner,
|
owner: owner,
|
||||||
repository: repository,
|
repository: repository,
|
||||||
branch: branch,
|
branch: branch,
|
||||||
|
path: "",
|
||||||
pages: requestedPage
|
pages: requestedPage
|
||||||
)
|
)
|
||||||
currentPage = requestedPage
|
currentPage = requestedPage
|
||||||
|
|||||||
@@ -425,11 +425,17 @@ final class FilesViewController: RefreshingTableViewController {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class RepositoryDirectoryViewController: RefreshingTableViewController {
|
final class RepositoryDirectoryViewController: RefreshingTableViewController {
|
||||||
|
private enum Mode: Int { case files, history }
|
||||||
|
|
||||||
private let context: AppContext
|
private let context: AppContext
|
||||||
private let owner: String
|
private let owner: String
|
||||||
private let repository: String
|
private let repository: String
|
||||||
private let path: String
|
private let path: String
|
||||||
|
private var mode = Mode.files
|
||||||
private var rows: [RepositoryContentRow] = []
|
private var rows: [RepositoryContentRow] = []
|
||||||
|
private var history: CommitPage?
|
||||||
|
private var currentPage: UInt32 = 0
|
||||||
|
private var loadedFiles = false
|
||||||
|
|
||||||
init(context: AppContext, owner: String, repository: String, path: String, name: String) {
|
init(context: AppContext, owner: String, repository: String, path: String, name: String) {
|
||||||
self.context = context
|
self.context = context
|
||||||
@@ -445,23 +451,47 @@ final class RepositoryDirectoryViewController: RefreshingTableViewController {
|
|||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
tableView.register(CommitCell.self, forCellReuseIdentifier: "commit")
|
||||||
|
navigationItem.prompt = title
|
||||||
|
let modeControl = UISegmentedControl(items: ["Files", "History"])
|
||||||
|
modeControl.selectedSegmentIndex = mode.rawValue
|
||||||
|
modeControl.addTarget(self, action: #selector(modeChanged(_:)), for: .valueChanged)
|
||||||
|
modeControl.accessibilityLabel = "Directory view"
|
||||||
|
navigationItem.titleView = modeControl
|
||||||
loadContent(refreshing: false)
|
loadContent(refreshing: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func loadContent(refreshing: Bool) {
|
override func loadContent(refreshing: Bool) {
|
||||||
|
switch mode {
|
||||||
|
case .files: loadFiles(refreshing: refreshing)
|
||||||
|
case .history: loadHistory(page: 1, refreshing: refreshing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadMoreContent() {
|
||||||
|
guard mode == .history else { return }
|
||||||
|
loadHistory(page: currentPage + 1, refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadFiles(refreshing: Bool) {
|
||||||
|
resetPagination()
|
||||||
beginLoading(refreshing: refreshing)
|
beginLoading(refreshing: refreshing)
|
||||||
loadingTask?.cancel()
|
loadingTask?.cancel()
|
||||||
loadingTask = Task {
|
loadingTask = Task {
|
||||||
do {
|
do {
|
||||||
rows = try await context.core.repositoryContents(
|
let rows = try await context.core.repositoryContents(
|
||||||
owner: owner,
|
owner: owner,
|
||||||
repository: repository,
|
repository: repository,
|
||||||
path: path
|
path: path
|
||||||
)
|
)
|
||||||
|
guard !Task.isCancelled, mode == .files else {
|
||||||
|
endLoading()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.rows = rows
|
||||||
|
loadedFiles = true
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
tableView.backgroundView = rows.isEmpty
|
updateEmptyView()
|
||||||
? EmptyBackgroundView(title: "Empty folder", detail: "This folder does not contain any files.")
|
|
||||||
: nil
|
|
||||||
} catch {
|
} catch {
|
||||||
if !Task.isCancelled { show(error: error) }
|
if !Task.isCancelled { show(error: error) }
|
||||||
}
|
}
|
||||||
@@ -469,45 +499,258 @@ final class RepositoryDirectoryViewController: RefreshingTableViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func loadHistory(page requestedPage: UInt32, refreshing: Bool) {
|
||||||
|
if requestedPage == 1 {
|
||||||
|
resetPagination()
|
||||||
|
beginLoading(refreshing: refreshing)
|
||||||
|
}
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
let history = try await context.core.commits(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
branch: nil,
|
||||||
|
path: path,
|
||||||
|
pages: requestedPage
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled, mode == .history else {
|
||||||
|
if requestedPage == 1 { endLoading() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.history = history
|
||||||
|
currentPage = requestedPage
|
||||||
|
finishPagination(hasMore: history.hasMore)
|
||||||
|
tableView.reloadData()
|
||||||
|
updateEmptyView()
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled {
|
||||||
|
show(error: error)
|
||||||
|
failPagination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if requestedPage == 1 { endLoading() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
rows.count
|
mode == .files ? rows.count : history?.commits.count ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tableView(
|
override func tableView(
|
||||||
_ tableView: UITableView,
|
_ tableView: UITableView,
|
||||||
cellForRowAt indexPath: IndexPath
|
cellForRowAt indexPath: IndexPath
|
||||||
) -> UITableViewCell {
|
) -> UITableViewCell {
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: "repository-content")
|
switch mode {
|
||||||
?? UITableViewCell(style: .subtitle, reuseIdentifier: "repository-content")
|
case .files:
|
||||||
configureRepositoryContentCell(cell, row: rows[indexPath.row])
|
let cell = tableView.dequeueReusableCell(withIdentifier: "repository-content")
|
||||||
return cell
|
?? UITableViewCell(style: .subtitle, reuseIdentifier: "repository-content")
|
||||||
|
configureRepositoryContentCell(cell, row: rows[indexPath.row])
|
||||||
|
return cell
|
||||||
|
case .history:
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "commit", for: indexPath) as! CommitCell
|
||||||
|
if let history {
|
||||||
|
cell.configure(history.commits[indexPath.row], laneCount: history.laneCount)
|
||||||
|
}
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
|
mode == .history ? 86 : UITableView.automaticDimension
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
showRepositoryContent(
|
switch mode {
|
||||||
rows[indexPath.row],
|
case .files:
|
||||||
context: context,
|
showRepositoryContent(
|
||||||
owner: owner,
|
rows[indexPath.row],
|
||||||
repository: repository,
|
context: context,
|
||||||
navigationController: navigationController
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
navigationController: navigationController
|
||||||
|
)
|
||||||
|
case .history:
|
||||||
|
guard let commit = history?.commits[indexPath.row] else { return }
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
FilesViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
sha: commit.sha
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func modeChanged(_ sender: UISegmentedControl) {
|
||||||
|
guard let mode = Mode(rawValue: sender.selectedSegmentIndex), mode != self.mode else { return }
|
||||||
|
loadingTask?.cancel()
|
||||||
|
endLoading()
|
||||||
|
self.mode = mode
|
||||||
|
resetPagination()
|
||||||
|
tableView.backgroundView = nil
|
||||||
|
tableView.reloadData()
|
||||||
|
switch mode {
|
||||||
|
case .files:
|
||||||
|
if loadedFiles { updateEmptyView() } else { loadFiles(refreshing: false) }
|
||||||
|
case .history:
|
||||||
|
if let history {
|
||||||
|
finishPagination(hasMore: history.hasMore)
|
||||||
|
updateEmptyView()
|
||||||
|
} else {
|
||||||
|
loadHistory(page: 1, refreshing: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateEmptyView() {
|
||||||
|
switch mode {
|
||||||
|
case .files:
|
||||||
|
tableView.backgroundView = loadedFiles && rows.isEmpty
|
||||||
|
? EmptyBackgroundView(title: "Empty folder", detail: "This folder does not contain any files.")
|
||||||
|
: nil
|
||||||
|
case .history:
|
||||||
|
tableView.backgroundView = history?.commits.isEmpty == true
|
||||||
|
? EmptyBackgroundView(title: "No commits", detail: "No commits affect this folder.")
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class RepositoryHistoryViewController: RefreshingTableViewController {
|
||||||
|
private let context: AppContext
|
||||||
|
private let owner: String
|
||||||
|
private let repository: String
|
||||||
|
private let path: String
|
||||||
|
private let emptyDetail: String
|
||||||
|
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
private var page: CommitPage?
|
||||||
|
private var currentPage: UInt32 = 0
|
||||||
|
|
||||||
|
init(
|
||||||
|
context: AppContext,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
path: String,
|
||||||
|
emptyDetail: String
|
||||||
|
) {
|
||||||
|
self.context = context
|
||||||
|
self.owner = owner
|
||||||
|
self.repository = repository
|
||||||
|
self.path = path
|
||||||
|
self.emptyDetail = emptyDetail
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
tableView.register(CommitCell.self, forCellReuseIdentifier: "commit")
|
||||||
|
loadContent(refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadContent(refreshing: Bool) {
|
||||||
|
loadPage(1, refreshing: refreshing)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadMoreContent() {
|
||||||
|
loadPage(currentPage + 1, refreshing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
|
||||||
|
if requestedPage == 1 {
|
||||||
|
resetPagination()
|
||||||
|
if !refreshing {
|
||||||
|
loadingIndicator.startAnimating()
|
||||||
|
tableView.backgroundView = loadingIndicator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadingTask?.cancel()
|
||||||
|
loadingTask = Task {
|
||||||
|
do {
|
||||||
|
let page = try await context.core.commits(
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
branch: nil,
|
||||||
|
path: path,
|
||||||
|
pages: requestedPage
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
self.page = page
|
||||||
|
currentPage = requestedPage
|
||||||
|
finishPagination(hasMore: page.hasMore)
|
||||||
|
tableView.reloadData()
|
||||||
|
updateEmptyView()
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled {
|
||||||
|
show(error: error)
|
||||||
|
failPagination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if requestedPage == 1 {
|
||||||
|
loadingIndicator.stopAnimating()
|
||||||
|
refreshControl?.endRefreshing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
page?.commits.count ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
|
86
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
guard let commit = page?.commits[indexPath.row] else { return }
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
FilesViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
sha: commit.sha
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateEmptyView() {
|
||||||
|
tableView.backgroundView = page?.commits.isEmpty == true
|
||||||
|
? EmptyBackgroundView(title: "No commits", detail: emptyDetail)
|
||||||
|
: nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class RepositoryFileViewController: UIViewController, QLPreviewControllerDataSource {
|
final class RepositoryFileViewController: UIViewController, QLPreviewControllerDataSource {
|
||||||
private enum MarkdownMode: Int { case preview, source }
|
|
||||||
|
|
||||||
private let context: AppContext
|
private let context: AppContext
|
||||||
private let owner: String
|
private let owner: String
|
||||||
private let repository: String
|
private let repository: String
|
||||||
private let path: String
|
private let path: String
|
||||||
private let spinner = UIActivityIndicatorView(style: .medium)
|
private let spinner = UIActivityIndicatorView(style: .medium)
|
||||||
|
private let modeControl = UISegmentedControl()
|
||||||
private var loadingTask: Task<Void, Never>?
|
private var loadingTask: Task<Void, Never>?
|
||||||
|
private var page: RepositoryFilePage?
|
||||||
private var previewURL: URL?
|
private var previewURL: URL?
|
||||||
private var markdownSource: String?
|
|
||||||
private var fileLanguage: String?
|
private var fileLanguage: String?
|
||||||
|
private var historyController: RepositoryHistoryViewController?
|
||||||
private var displayedController: UIViewController?
|
private var displayedController: UIViewController?
|
||||||
private var displayedView: UIView?
|
private var displayedView: UIView?
|
||||||
private weak var sourceScrollView: UIScrollView?
|
private weak var sourceScrollView: UIScrollView?
|
||||||
@@ -536,13 +779,11 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD
|
|||||||
repository: repository,
|
repository: repository,
|
||||||
path: path
|
path: path
|
||||||
)
|
)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
self.page = page
|
||||||
title = page.name
|
title = page.name
|
||||||
fileLanguage = page.language.isEmpty ? nil : page.language
|
fileLanguage = page.language.isEmpty ? nil : page.language
|
||||||
switch page.kind {
|
configureModes(for: page)
|
||||||
case "markdown": showMarkdown(page.text)
|
|
||||||
case "source": showSource(page.text)
|
|
||||||
default: try showQuickLook(page.data, name: page.name)
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
if !Task.isCancelled { show(error: error) }
|
if !Task.isCancelled { show(error: error) }
|
||||||
}
|
}
|
||||||
@@ -568,26 +809,62 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD
|
|||||||
previewURL! as NSURL
|
previewURL! as NSURL
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showMarkdown(_ source: String) {
|
private func configureModes(for page: RepositoryFilePage) {
|
||||||
markdownSource = source
|
|
||||||
navigationItem.prompt = title
|
navigationItem.prompt = title
|
||||||
let modeControl = UISegmentedControl(items: ["Preview", "Source"])
|
modeControl.removeAllSegments()
|
||||||
modeControl.selectedSegmentIndex = MarkdownMode.preview.rawValue
|
let modes = page.kind == "markdown"
|
||||||
modeControl.addTarget(self, action: #selector(markdownModeChanged(_:)), for: .valueChanged)
|
? ["Preview", "Source", "History"]
|
||||||
modeControl.accessibilityLabel = "Markdown view"
|
: ["Content", "History"]
|
||||||
|
for (index, mode) in modes.enumerated() {
|
||||||
|
modeControl.insertSegment(withTitle: mode, at: index, animated: false)
|
||||||
|
}
|
||||||
|
modeControl.selectedSegmentIndex = 0
|
||||||
|
modeControl.addTarget(self, action: #selector(fileModeChanged(_:)), for: .valueChanged)
|
||||||
|
modeControl.accessibilityLabel = "File view"
|
||||||
navigationItem.titleView = modeControl
|
navigationItem.titleView = modeControl
|
||||||
showMarkdownPreview(source)
|
showContent(page, mode: modes[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func markdownModeChanged(_ sender: UISegmentedControl) {
|
@objc private func fileModeChanged(_ sender: UISegmentedControl) {
|
||||||
guard let mode = MarkdownMode(rawValue: sender.selectedSegmentIndex),
|
guard let page, let mode = sender.titleForSegment(at: sender.selectedSegmentIndex) else { return }
|
||||||
let source = markdownSource else { return }
|
showContent(page, mode: mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showContent(_ page: RepositoryFilePage, mode: String) {
|
||||||
switch mode {
|
switch mode {
|
||||||
case .preview: showMarkdownPreview(source)
|
case "Preview": showMarkdownPreview(page.text)
|
||||||
case .source: showSource(source)
|
case "Source": showSource(page.text)
|
||||||
|
case "Content" where page.kind == "source": showSource(page.text)
|
||||||
|
case "Content":
|
||||||
|
do {
|
||||||
|
try showQuickLook(page.data, name: page.name)
|
||||||
|
} catch {
|
||||||
|
show(error: error)
|
||||||
|
}
|
||||||
|
case "History": showHistory()
|
||||||
|
default: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func showHistory() {
|
||||||
|
removeDisplayedView()
|
||||||
|
let history = historyController ?? RepositoryHistoryViewController(
|
||||||
|
context: context,
|
||||||
|
owner: owner,
|
||||||
|
repository: repository,
|
||||||
|
path: path,
|
||||||
|
emptyDetail: "No commits affect this file."
|
||||||
|
)
|
||||||
|
historyController = history
|
||||||
|
addChild(history)
|
||||||
|
history.view.frame = view.bounds
|
||||||
|
history.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
displayedController = history
|
||||||
|
displayedView = history.view
|
||||||
|
view.addSubview(history.view)
|
||||||
|
history.didMove(toParent: self)
|
||||||
|
}
|
||||||
|
|
||||||
private func showMarkdownPreview(_ source: String) {
|
private func showMarkdownPreview(_ source: String) {
|
||||||
removeDisplayedView()
|
removeDisplayedView()
|
||||||
let preview = UIHostingController(rootView: RepositoryMarkdownPreview(source: source))
|
let preview = UIHostingController(rootView: RepositoryMarkdownPreview(source: source))
|
||||||
@@ -683,15 +960,20 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func showQuickLook(_ data: Data, name: String) throws {
|
private func showQuickLook(_ data: Data, name: String) throws {
|
||||||
let url = FileManager.default.temporaryDirectory
|
removeDisplayedView()
|
||||||
.appendingPathComponent("\(UUID().uuidString)-\(name)")
|
if previewURL == nil {
|
||||||
try data.write(to: url, options: .atomic)
|
let url = FileManager.default.temporaryDirectory
|
||||||
previewURL = url
|
.appendingPathComponent("\(UUID().uuidString)-\(name)")
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
previewURL = url
|
||||||
|
}
|
||||||
let preview = QLPreviewController()
|
let preview = QLPreviewController()
|
||||||
preview.dataSource = self
|
preview.dataSource = self
|
||||||
addChild(preview)
|
addChild(preview)
|
||||||
preview.view.frame = view.bounds
|
preview.view.frame = view.bounds
|
||||||
preview.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
preview.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
displayedController = preview
|
||||||
|
displayedView = preview.view
|
||||||
view.addSubview(preview.view)
|
view.addSubview(preview.view)
|
||||||
preview.didMove(toParent: self)
|
preview.didMove(toParent: self)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user