diff --git a/TESTING.md b/TESTING.md index 9ee980f..e6aaeed 100644 --- a/TESTING.md +++ b/TESTING.md @@ -207,6 +207,11 @@ xcrun simctl launch booted de.rfc1437.gotcha - [ ] In Files, traverse several nested folders using rows, the navigation-bar Back button, and the left-edge swipe. Folder contents and titles match the 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, Swift, and a scripting or markup language. Keywords, strings, comments, 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 horizontally for long lines. Switch back and verify the rendered preview 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 native preview appropriate to its media type and returns cleanly to Files. diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs index 79238e4..b92ba6f 100644 --- a/crates/app/src/api.rs +++ b/crates/app/src/api.rs @@ -450,9 +450,10 @@ pub async fn load_branch_commits( owner: &str, repository: &str, branch: &str, + path: Option<&str>, pages: u32, ) -> Result, String> { - load_commits_for_ref(server, owner, repository, Some(branch), pages) + load_commits_for_ref(server, owner, repository, Some(branch), path, pages) .await .map(|page| Page { has_more: page.has_more, @@ -476,15 +477,24 @@ pub async fn load_all_commits( owner: &str, repository: &str, branches: &[String], + path: Option<&str>, pages: u32, ) -> Result, String> { let mut tasks = JoinSet::new(); for (lane, branch) in branches.iter().cloned().enumerate() { let (server, owner, repository) = (server.clone(), owner.to_string(), repository.to_string()); + let path = path.map(str::to_string); tasks.spawn(async move { - let commits = - load_commits_for_ref(&server, &owner, &repository, Some(&branch), pages).await?; + let commits = load_commits_for_ref( + &server, + &owner, + &repository, + Some(&branch), + path.as_deref(), + pages, + ) + .await?; Ok::<_, String>((lane, branch, commits)) }); } @@ -1024,6 +1034,7 @@ async fn load_commits_for_ref( owner: &str, repository: &str, branch: Option<&str>, + path: Option<&str>, pages: u32, ) -> Result, String> { let client = @@ -1037,7 +1048,7 @@ async fn load_commits_for_ref( owner, repository, branch, - None, + path, None, None, None, diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 22eac60..961d2a1 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -599,6 +599,7 @@ impl GotchaCore { owner: String, repository: String, branch: Option, + path: String, pages: u32, ) -> Result { let server = self.server()?; @@ -612,11 +613,16 @@ impl GotchaCore { .map(|candidate| candidate.default_branch.clone()) .unwrap_or_else(|| "main".into()); 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() { 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( branches, diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index 4aa60de..d69fdbf 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -613,7 +613,7 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { 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 @@ -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 try await uniffiRustCallAsync( rustFutureFunc: { 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, @@ -3948,7 +3948,7 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_commit_files() != 43926) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_commits() != 59625) { + if (uniffi_gotcha_core_checksum_method_gotchacore_commits() != 60062) { return InitializationResult.apiChecksumMismatch } if (uniffi_gotcha_core_checksum_method_gotchacore_home() != 38990) { diff --git a/ios/Generated/gotcha_coreFFI.h b/ios/Generated/gotcha_coreFFI.h index aeea108..2ae6d18 100644 --- a/ios/Generated/gotcha_coreFFI.h +++ b/ios/Generated/gotcha_coreFFI.h @@ -286,7 +286,7 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_commit_files(uint64_t ptr, Rust #endif #ifndef 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 #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME diff --git a/ios/Sources/ContentScreens.swift b/ios/Sources/ContentScreens.swift index 03435b6..9c25d09 100644 --- a/ios/Sources/ContentScreens.swift +++ b/ios/Sources/ContentScreens.swift @@ -958,6 +958,7 @@ final class CommitsViewController: RefreshingTableViewController { owner: owner, repository: repository, branch: branch, + path: "", pages: requestedPage ) currentPage = requestedPage diff --git a/ios/Sources/DetailScreens.swift b/ios/Sources/DetailScreens.swift index 42494ad..37db998 100644 --- a/ios/Sources/DetailScreens.swift +++ b/ios/Sources/DetailScreens.swift @@ -425,11 +425,17 @@ final class FilesViewController: RefreshingTableViewController { @MainActor final class RepositoryDirectoryViewController: RefreshingTableViewController { + private enum Mode: Int { case files, history } + private let context: AppContext private let owner: String private let repository: String private let path: String + private var mode = Mode.files 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) { self.context = context @@ -445,23 +451,47 @@ final class RepositoryDirectoryViewController: RefreshingTableViewController { override func 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) } 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) loadingTask?.cancel() loadingTask = Task { do { - rows = try await context.core.repositoryContents( + let rows = try await context.core.repositoryContents( owner: owner, repository: repository, path: path ) + guard !Task.isCancelled, mode == .files else { + endLoading() + return + } + self.rows = rows + loadedFiles = true tableView.reloadData() - tableView.backgroundView = rows.isEmpty - ? EmptyBackgroundView(title: "Empty folder", detail: "This folder does not contain any files.") - : nil + updateEmptyView() } catch { 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 { - rows.count + mode == .files ? rows.count : history?.commits.count ?? 0 } 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 + switch mode { + case .files: + let cell = tableView.dequeueReusableCell(withIdentifier: "repository-content") + ?? 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) { tableView.deselectRow(at: indexPath, animated: true) - showRepositoryContent( - rows[indexPath.row], - context: context, - owner: owner, - repository: repository, - navigationController: navigationController + switch mode { + case .files: + showRepositoryContent( + rows[indexPath.row], + context: context, + 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 final class RepositoryFileViewController: UIViewController, QLPreviewControllerDataSource { - private enum MarkdownMode: Int { case preview, source } - private let context: AppContext private let owner: String private let repository: String private let path: String private let spinner = UIActivityIndicatorView(style: .medium) + private let modeControl = UISegmentedControl() private var loadingTask: Task? + private var page: RepositoryFilePage? private var previewURL: URL? - private var markdownSource: String? private var fileLanguage: String? + private var historyController: RepositoryHistoryViewController? private var displayedController: UIViewController? private var displayedView: UIView? private weak var sourceScrollView: UIScrollView? @@ -536,13 +779,11 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD repository: repository, path: path ) + guard !Task.isCancelled else { return } + self.page = page 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) - } + configureModes(for: page) } catch { if !Task.isCancelled { show(error: error) } } @@ -568,26 +809,62 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD previewURL! as NSURL } - private func showMarkdown(_ source: String) { - markdownSource = source + private func configureModes(for page: RepositoryFilePage) { navigationItem.prompt = title - let modeControl = UISegmentedControl(items: ["Preview", "Source"]) - modeControl.selectedSegmentIndex = MarkdownMode.preview.rawValue - modeControl.addTarget(self, action: #selector(markdownModeChanged(_:)), for: .valueChanged) - modeControl.accessibilityLabel = "Markdown view" + modeControl.removeAllSegments() + let modes = page.kind == "markdown" + ? ["Preview", "Source", "History"] + : ["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 - showMarkdownPreview(source) + showContent(page, mode: modes[0]) } - @objc private func markdownModeChanged(_ sender: UISegmentedControl) { - guard let mode = MarkdownMode(rawValue: sender.selectedSegmentIndex), - let source = markdownSource else { return } + @objc private func fileModeChanged(_ sender: UISegmentedControl) { + guard let page, let mode = sender.titleForSegment(at: sender.selectedSegmentIndex) else { return } + showContent(page, mode: mode) + } + + private func showContent(_ page: RepositoryFilePage, mode: String) { switch mode { - case .preview: showMarkdownPreview(source) - case .source: showSource(source) + case "Preview": showMarkdownPreview(page.text) + 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) { removeDisplayedView() let preview = UIHostingController(rootView: RepositoryMarkdownPreview(source: source)) @@ -683,15 +960,20 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD } private func showQuickLook(_ data: Data, name: String) throws { - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("\(UUID().uuidString)-\(name)") - try data.write(to: url, options: .atomic) - previewURL = url + removeDisplayedView() + if previewURL == nil { + 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] + displayedController = preview + displayedView = preview.view view.addSubview(preview.view) preview.didMove(toParent: self) }