From 32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Tue, 17 Mar 2026 23:19:43 -0500 Subject: v1.0 --- Hutch/Views/Repositories/FileTreeView.swift | 446 ++++++++++++++++++++++++++++ 1 file changed, 446 insertions(+) create mode 100644 Hutch/Views/Repositories/FileTreeView.swift (limited to 'Hutch/Views/Repositories/FileTreeView.swift') diff --git a/Hutch/Views/Repositories/FileTreeView.swift b/Hutch/Views/Repositories/FileTreeView.swift new file mode 100644 index 0000000..3750adb --- /dev/null +++ b/Hutch/Views/Repositories/FileTreeView.swift @@ -0,0 +1,446 @@ +import SwiftUI + +struct FileTreeView: View { + let repository: RepositorySummary + let client: SRHTClient + + @State private var viewModel: FileTreeViewModel? + + var body: some View { + Group { + if let viewModel { + FileTreeContentView(repository: repository, viewModel: viewModel) + } else { + SRHTLoadingStateView(message: "Loading files…") + } + } + .task { + if viewModel == nil { + let vm = FileTreeViewModel( + repositoryRid: repository.rid, + service: repository.service, + client: client + ) + viewModel = vm + async let loadTree: () = vm.loadRootTree() + async let loadRefs: () = vm.loadReferences() + _ = await (loadTree, loadRefs) + } + } + } +} + +// MARK: - Content View + +private struct FileTreeContentView: View { + let repository: RepositorySummary + let viewModel: FileTreeViewModel + + @State private var showRefPicker = false + + var body: some View { + VStack(spacing: 0) { + breadcrumbBar + Divider() + contentArea + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showRefPicker = true + } label: { + Label( + revspecLabel, + systemImage: "arrow.triangle.branch" + ) + .font(.subheadline) + } + } + } + .sheet(isPresented: $showRefPicker) { + RefPickerSheet(viewModel: viewModel, isPresented: $showRefPicker) + } + .srhtErrorBanner(error: Binding( + get: { viewModel.error }, + set: { viewModel.error = $0 } + )) + .refreshable { + await viewModel.loadRootTree() + } + } + + private var shareURL: URL? { + guard let viewingEntry = viewModel.viewingEntry else { return nil } + return SRHTWebURL.file( + repository: repository, + revspec: viewModel.revspec, + path: currentFilePath(for: viewingEntry) + ) + } + + private var revspecLabel: String { + let revspec = viewModel.revspec + if revspec == "HEAD" { + return "HEAD" + } + if revspec.hasPrefix("refs/heads/") { + return String(revspec.dropFirst("refs/heads/".count)) + } else if revspec.hasPrefix("refs/tags/") { + return String(revspec.dropFirst("refs/tags/".count)) + } + return revspec + } + + // MARK: - Breadcrumb Bar + + private var breadcrumbBar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + HStack(spacing: 4) { + ForEach(Array(viewModel.navStack.enumerated()), id: \.offset) { index, navEntry in + if index > 0 { + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + } + + Button { + Task { + await viewModel.navigateToBreadcrumb(at: index) + } + } label: { + Text(navEntry.name) + .font(.subheadline.monospaced()) + .foregroundStyle( + index == viewModel.navStack.count - 1 && viewModel.viewingEntry == nil + ? .primary : .secondary + ) + } + .buttonStyle(.plain) + } + + if let viewing = viewModel.viewingEntry { + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + + Text(viewing.name) + .font(.subheadline.monospaced()) + .foregroundStyle(.primary) + } + } + + Spacer(minLength: 0) + } + .padding(.horizontal) + .padding(.vertical, 8) + } + .background(.bar) + } + + private func currentFilePath(for entry: TreeEntry) -> String { + let directoryComponents = viewModel.navStack + .dropFirst() + .map(\.name) + return (directoryComponents + [entry.name]).joined(separator: "/") + } + + // MARK: - Content Area + + @ViewBuilder + private var contentArea: some View { + if viewModel.isLoading, viewModel.entries.isEmpty, viewModel.viewingEntry == nil { + SRHTLoadingStateView(message: "Loading files…") + } else if let entry = viewModel.viewingEntry, let object = viewModel.viewingObject { + // Viewing a file + fileContentView(entry: entry, object: object) + } else if let error = viewModel.error, viewModel.entries.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Files", + message: error, + retryAction: { await viewModel.loadRootTree() } + ) + } else if !viewModel.entries.isEmpty { + // Viewing a directory listing + treeListView + } else if viewModel.navStack.isEmpty { + ContentUnavailableView( + "No Files", + systemImage: "folder", + description: Text("This repository could not be loaded.") + ) + } else { + ContentUnavailableView( + "Empty Directory", + systemImage: "folder", + description: Text("This directory has no files.") + ) + } + } + + // MARK: - File Content View + + @ViewBuilder + private func fileContentView(entry: TreeEntry, object: GitObject) -> some View { + switch object { + case .textBlob(let blob): + textBlobView(entry: entry, blob: blob) + case .binaryBlob(let blob): + binaryBlobView(entry: entry, blob: blob) + default: + ContentUnavailableView( + "Unknown Object", + systemImage: "questionmark.folder", + description: Text("Cannot display this object type.") + ) + } + } + + // MARK: - Tree List + + private var treeListView: some View { + let sorted = viewModel.entries.sorted { a, b in + let aIsTree = a.object?.isTree == true + let bIsTree = b.object?.isTree == true + if aIsTree != bIsTree { return aIsTree } + return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending + } + + return List(sorted) { entry in + TreeEntryRow(entry: entry) + .contentShape(Rectangle()) + .onTapGesture { + Task { + await viewModel.navigateInto(entry: entry) + } + } + } + .listStyle(.plain) + } + + // MARK: - Text Blob + + @ViewBuilder + private func textBlobView(entry: TreeEntry, blob: GitTextBlob) -> some View { + VStack(spacing: 0) { + HStack { + Spacer() + SRHTShareButton(url: shareURL, target: .file) { + Label("Share File", systemImage: "square.and.arrow.up") + } + .buttonStyle(.bordered) + } + .padding(.horizontal) + .padding(.top, 12) + + GeometryReader { geometry in + ScrollView([.vertical, .horizontal]) { + Text(blob.text) + .font(.system(.body, design: .monospaced)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: true, vertical: false) + .frame(minWidth: geometry.size.width, + minHeight: geometry.size.height, + alignment: .topLeading) + .padding() + } + .frame(width: geometry.size.width, height: geometry.size.height) + } + } + } + + // MARK: - Binary Blob + + @ViewBuilder + private func binaryBlobView(entry: TreeEntry, blob: GitBinaryBlob) -> some View { + VStack(spacing: 16) { + Spacer() + + Image(systemName: "doc.zipper") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + + Text(entry.name) + .font(.headline) + + if let size = blob.size { + Text(formatBytes(size)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Text("Binary file — cannot be displayed inline.") + .font(.subheadline) + .foregroundStyle(.tertiary) + + SRHTShareButton(url: shareURL, target: .file) { + Label("Share File", systemImage: "square.and.arrow.up") + } + .buttonStyle(.bordered) + + if let content = blob.content, let url = URL(string: content) { + Link(destination: url) { + Label("Open in Safari", systemImage: "safari") + } + .buttonStyle(.borderedProminent) + } + + Button { + viewModel.dismissFileView() + } label: { + Text("Back to directory") + } + + Spacer() + } + .frame(maxWidth: .infinity) + } + + // MARK: - Helpers + + private func formatBytes(_ bytes: Int) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(bytes)) + } +} + +// MARK: - Tree Entry Row + +private struct TreeEntryRow: View { + let entry: TreeEntry + + var body: some View { + Label { + Text(entry.name) + .font(.body.monospaced()) + .lineLimit(1) + } icon: { + Image(systemName: iconName) + .foregroundStyle(iconColor) + } + } + + private var iconName: String { + switch entry.object { + case .tree: "folder.fill" + case .unknown: "questionmark.circle" + default: "doc" + } + } + + private var iconColor: Color { + switch entry.object { + case .tree: .blue + case .unknown: .orange + default: .secondary + } + } +} + +// MARK: - Ref Picker Sheet + +private struct RefPickerSheet: View { + let viewModel: FileTreeViewModel + @Binding var isPresented: Bool + + var body: some View { + NavigationStack { + List { + Section { + Button { + Task { + await viewModel.changeRevspec("HEAD") + isPresented = false + } + } label: { + refRow( + title: "HEAD", + systemImage: "arrow.triangle.branch", + color: .blue, + isSelected: viewModel.revspec == "HEAD" + ) + } + .buttonStyle(.plain) + } + + if !viewModel.branches.isEmpty { + Section("Branches") { + ForEach(viewModel.branches, id: \.name) { ref in + Button { + Task { + await viewModel.changeRevspec(ref.name) + isPresented = false + } + } label: { + refRow( + title: ref.name.replacingOccurrences(of: "refs/heads/", with: ""), + systemImage: "arrow.triangle.branch", + color: .blue, + isSelected: viewModel.revspec == ref.name + ) + } + .buttonStyle(.plain) + } + } + } + + if !viewModel.tags.isEmpty { + Section("Tags") { + ForEach(viewModel.tags, id: \.name) { ref in + Button { + Task { + await viewModel.changeRevspec(ref.name) + isPresented = false + } + } label: { + refRow( + title: ref.name.replacingOccurrences(of: "refs/tags/", with: ""), + systemImage: "tag", + color: .orange, + isSelected: viewModel.revspec == ref.name + ) + } + .buttonStyle(.plain) + } + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("Select Ref") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + isPresented = false + } + } + } + .overlay { + if viewModel.isLoadingRefs { + SRHTLoadingStateView(message: "Loading references…") + } + } + } + } + + private func refRow(title: String, systemImage: String, color: Color, isSelected: Bool) -> some View { + HStack(spacing: 12) { + Image(systemName: systemImage) + .foregroundStyle(color) + + Text(title) + .font(.body.monospaced()) + .foregroundStyle(.primary) + + Spacer() + + if isSelected { + Image(systemName: "checkmark") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tint) + } + } + .contentShape(Rectangle()) + } +} -- cgit v1.2.3