summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hutch.xcodeproj/project.pbxproj16
-rw-r--r--Hutch/Models/RepositorySummary.swift34
-rw-r--r--Hutch/Views/Lookup/UserProfileView.swift4
-rw-r--r--Hutch/Views/Lookup/UserProfileViewModel.swift11
-rw-r--r--Hutch/Views/Lookup/UserRepositoriesView.swift4
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailView.swift37
-rw-r--r--Hutch/Views/Repositories/RepositoryListView.swift15
-rw-r--r--Hutch/Views/Repositories/RepositoryListViewModel.swift11
-rw-r--r--Hutch/Views/Repositories/RepositoryRowView.swift4
-rw-r--r--Hutch/Views/Repositories/RepositorySettingsView.swift226
-rw-r--r--Hutch/Views/Repositories/RepositorySettingsViewModel.swift320
-rw-r--r--HutchTests/RepositorySettingsViewModelTests.swift26
12 files changed, 473 insertions, 235 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 0a9a9b6..3a7f7cd 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -515,7 +515,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 44;
+ CURRENT_PROJECT_VERSION = 45;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.19.1;
+ MARKETING_VERSION = 2.19.2;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 44;
+ CURRENT_PROJECT_VERSION = 45;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.19.1;
+ MARKETING_VERSION = 2.19.2;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 44;
+ CURRENT_PROJECT_VERSION = 45;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 2.19.1;
+ MARKETING_VERSION = 2.19.2;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 44;
+ CURRENT_PROJECT_VERSION = 45;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 2.19.1;
+ MARKETING_VERSION = 2.19.2;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
diff --git a/Hutch/Models/RepositorySummary.swift b/Hutch/Models/RepositorySummary.swift
index f4ecc6e..3821aa2 100644
--- a/Hutch/Models/RepositorySummary.swift
+++ b/Hutch/Models/RepositorySummary.swift
@@ -55,3 +55,37 @@ struct RepositorySummary: Codable, Sendable, Identifiable, Hashable {
self.head = try container.decodeIfPresent(Reference.self, forKey: .head)
}
}
+
+extension RepositorySummary {
+ var defaultBranchName: String? {
+ head?.name.replacingOccurrences(of: "refs/heads/", with: "")
+ }
+
+ func updating(
+ name: String? = nil,
+ description: String? = nil,
+ visibility: Visibility? = nil,
+ updated: Date? = nil,
+ head: Reference? = nil
+ ) -> RepositorySummary {
+ RepositorySummary(
+ id: id,
+ rid: rid,
+ service: service,
+ name: name ?? self.name,
+ description: description ?? self.description,
+ visibility: visibility ?? self.visibility,
+ updated: updated ?? self.updated,
+ owner: owner,
+ head: head ?? self.head
+ )
+ }
+
+ static func displayBranchName(for reference: Reference) -> String {
+ displayBranchName(for: reference.name)
+ }
+
+ static func displayBranchName(for referenceName: String) -> String {
+ referenceName.replacingOccurrences(of: "refs/heads/", with: "")
+ }
+}
diff --git a/Hutch/Views/Lookup/UserProfileView.swift b/Hutch/Views/Lookup/UserProfileView.swift
index b694738..068e05f 100644
--- a/Hutch/Views/Lookup/UserProfileView.swift
+++ b/Hutch/Views/Lookup/UserProfileView.swift
@@ -119,7 +119,9 @@ struct UserProfileView: View {
} else {
ForEach(viewModel.repositories.prefix(4)) { repo in
NavigationLink {
- RepositoryDetailView(repository: repo)
+ RepositoryDetailView(repository: repo) { updatedRepository in
+ viewModel.updateRepository(updatedRepository)
+ }
} label: {
RepositoryRowView(repository: repo, buildStatus: .none)
}
diff --git a/Hutch/Views/Lookup/UserProfileViewModel.swift b/Hutch/Views/Lookup/UserProfileViewModel.swift
index 56c2852..b88d36a 100644
--- a/Hutch/Views/Lookup/UserProfileViewModel.swift
+++ b/Hutch/Views/Lookup/UserProfileViewModel.swift
@@ -61,6 +61,17 @@ final class UserProfileViewModel {
}
}
+ func updateRepository(_ repository: RepositorySummary) {
+ guard let index = repositories.firstIndex(where: { $0.id == repository.id }) else { return }
+ repositories[index] = repository
+ repositories.sort { lhs, rhs in
+ if lhs.updated == rhs.updated {
+ return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
+ }
+ return lhs.updated > rhs.updated
+ }
+ }
+
func loadTrackers() async {
isLoadingTrackers = true
trackersError = nil
diff --git a/Hutch/Views/Lookup/UserRepositoriesView.swift b/Hutch/Views/Lookup/UserRepositoriesView.swift
index 52d3c15..7aeea10 100644
--- a/Hutch/Views/Lookup/UserRepositoriesView.swift
+++ b/Hutch/Views/Lookup/UserRepositoriesView.swift
@@ -7,7 +7,9 @@ struct UserRepositoriesView: View {
List {
ForEach(viewModel.repositories) { repo in
NavigationLink {
- RepositoryDetailView(repository: repo)
+ RepositoryDetailView(repository: repo) { updatedRepository in
+ viewModel.updateRepository(updatedRepository)
+ }
} label: {
RepositoryRowView(repository: repo, buildStatus: .none)
}
diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift
index b19e5b0..0031e47 100644
--- a/Hutch/Views/Repositories/RepositoryDetailView.swift
+++ b/Hutch/Views/Repositories/RepositoryDetailView.swift
@@ -1,7 +1,7 @@
import SwiftUI
struct RepositoryDetailView: View {
- var repository: RepositorySummary
+ let onRepositoryUpdated: ((RepositorySummary) -> Void)?
var onDeleted: (() -> Void)?
@Environment(AppState.self) private var appState
@@ -10,22 +10,26 @@ struct RepositoryDetailView: View {
@State private var selectedTab: RepositoryDetailViewModel.Tab = .summary
@State private var showSettings = false
@State private var showACLs = false
- @State private var displayName: String
+ @State private var currentRepository: RepositorySummary
private var canManageRepository: Bool {
guard let currentUser = appState.currentUser else { return false }
- return normalizedUsername(currentUser.username) == normalizedUsername(repository.owner.canonicalName)
+ return normalizedUsername(currentUser.username) == normalizedUsername(currentRepository.owner.canonicalName)
}
- init(repository: RepositorySummary, onDeleted: (() -> Void)? = nil) {
- self.repository = repository
+ init(
+ repository: RepositorySummary,
+ onRepositoryUpdated: ((RepositorySummary) -> Void)? = nil,
+ onDeleted: (() -> Void)? = nil
+ ) {
+ self.onRepositoryUpdated = onRepositoryUpdated
self.onDeleted = onDeleted
- self._displayName = State(initialValue: repository.name)
+ self._currentRepository = State(initialValue: repository)
}
var body: some View {
- if repository.service == .hg {
- HgRepositoryDetailView(repository: repository, onDeleted: onDeleted)
+ if currentRepository.service == .hg {
+ HgRepositoryDetailView(repository: currentRepository, onDeleted: onDeleted)
} else {
Group {
if let viewModel {
@@ -34,11 +38,11 @@ struct RepositoryDetailView: View {
SRHTLoadingStateView(message: "Loading repository…")
}
}
- .navigationTitle(displayName)
+ .navigationTitle(currentRepository.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
- SRHTShareButton(url: SRHTWebURL.repository(repository), target: .repository) {
+ SRHTShareButton(url: SRHTWebURL.repository(currentRepository), target: .repository) {
Image(systemName: "square.and.arrow.up")
}
@@ -59,11 +63,12 @@ struct RepositoryDetailView: View {
}
.sheet(isPresented: $showSettings) {
RepositorySettingsView(
- repository: repository,
+ repository: currentRepository,
branches: viewModel?.branches ?? [],
client: appState.client,
- onRenamed: { newName in
- displayName = newName
+ onUpdated: { updatedRepository in
+ currentRepository = updatedRepository
+ onRepositoryUpdated?(updatedRepository)
},
onDeleted: {
dismiss()
@@ -74,7 +79,7 @@ struct RepositoryDetailView: View {
.sheet(isPresented: $showACLs) {
NavigationStack {
RepositoryACLView(
- repository: repository,
+ repository: currentRepository,
client: appState.client,
showsDoneButton: true
)
@@ -83,7 +88,7 @@ struct RepositoryDetailView: View {
.task {
if viewModel == nil {
viewModel = RepositoryDetailViewModel(
- repository: repository,
+ repository: currentRepository,
client: appState.client
)
}
@@ -110,7 +115,7 @@ struct RepositoryDetailView: View {
ReadmeView(viewModel: viewModel)
case .tree:
FileTreeView(
- repository: repository,
+ repository: currentRepository,
client: appState.client
)
case .log:
diff --git a/Hutch/Views/Repositories/RepositoryListView.swift b/Hutch/Views/Repositories/RepositoryListView.swift
index fb090b2..d176e4b 100644
--- a/Hutch/Views/Repositories/RepositoryListView.swift
+++ b/Hutch/Views/Repositories/RepositoryListView.swift
@@ -45,7 +45,13 @@ struct RepositoryListView: View {
}
)) {
if let createdRepository {
- RepositoryDetailView(repository: createdRepository) {
+ RepositoryDetailView(
+ repository: createdRepository,
+ onRepositoryUpdated: { updatedRepository in
+ self.createdRepository = updatedRepository
+ viewModel?.updateRepository(updatedRepository)
+ }
+ ) {
viewModel?.removeRepository(id: createdRepository.id)
}
}
@@ -139,7 +145,12 @@ struct RepositoryListView: View {
}
}
.navigationDestination(for: RepositorySummary.self) { repo in
- RepositoryDetailView(repository: repo) {
+ RepositoryDetailView(
+ repository: repo,
+ onRepositoryUpdated: { updatedRepository in
+ viewModel.updateRepository(updatedRepository)
+ }
+ ) {
viewModel.removeRepository(id: repo.id)
}
}
diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift
index fd5bafe..7a65441 100644
--- a/Hutch/Views/Repositories/RepositoryListViewModel.swift
+++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift
@@ -208,6 +208,17 @@ final class RepositoryListViewModel {
repositories.removeAll { $0.id == id }
}
+ func updateRepository(_ repository: RepositorySummary) {
+ if let index = repositories.firstIndex(where: { $0.id == repository.id }) {
+ repositories[index] = repository
+ repositories.sort(by: repositorySortOrder)
+ }
+
+ let updatedRepositories = repositoriesForSearchIndex
+ .filter { $0.id != repository.id } + [repository]
+ updateSearchIndex(with: updatedRepositories)
+ }
+
func createRepository(
service: RepositoryCreationService,
name: String,
diff --git a/Hutch/Views/Repositories/RepositoryRowView.swift b/Hutch/Views/Repositories/RepositoryRowView.swift
index f791494..814ea76 100644
--- a/Hutch/Views/Repositories/RepositoryRowView.swift
+++ b/Hutch/Views/Repositories/RepositoryRowView.swift
@@ -41,8 +41,8 @@ struct RepositoryRowView: View {
}
HStack(spacing: 12) {
- if let head = repository.head {
- Label(head.name, systemImage: "arrow.triangle.branch")
+ if let defaultBranchName = repository.defaultBranchName {
+ Label(defaultBranchName, systemImage: "arrow.triangle.branch")
.font(.caption)
.foregroundStyle(.secondary)
}
diff --git a/Hutch/Views/Repositories/RepositorySettingsView.swift b/Hutch/Views/Repositories/RepositorySettingsView.swift
index e6beedf..4736619 100644
--- a/Hutch/Views/Repositories/RepositorySettingsView.swift
+++ b/Hutch/Views/Repositories/RepositorySettingsView.swift
@@ -4,14 +4,13 @@ struct RepositorySettingsView: View {
let repository: RepositorySummary
let branches: [ReferenceDetail]
let client: SRHTClient
- let onRenamed: (String) -> Void
+ let onUpdated: (RepositorySummary) -> Void
let onDeleted: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var viewModel: RepositorySettingsViewModel?
+ @State private var showVisibilityConfirmation = false
@State private var showDeleteConfirmation = false
- @State private var showRenameConfirmation = false
- @State private var saveResultAlert: SaveResultAlert?
var body: some View {
NavigationStack {
@@ -32,12 +31,11 @@ struct RepositorySettingsView: View {
}
.task {
if viewModel == nil {
- let vm = RepositorySettingsViewModel(
+ viewModel = RepositorySettingsViewModel(
repository: repository,
branches: branches,
client: client
)
- viewModel = vm
}
}
}
@@ -47,37 +45,36 @@ struct RepositorySettingsView: View {
@Bindable var vm = viewModel
Form {
- infoSection(viewModel)
- renameSection(viewModel)
- accessSection()
+ currentConfigurationSection(viewModel)
+ metadataSection(viewModel)
+ defaultBranchSection(viewModel)
+ visibilitySection(viewModel)
deleteSection(viewModel)
}
.srhtErrorBanner(error: $vm.error)
.alert(
- "Rename repository to \(viewModel.editedName.trimmingCharacters(in: .whitespacesAndNewlines))?",
- isPresented: $showRenameConfirmation
+ visibilityConfirmationTitle(for: viewModel),
+ isPresented: $showVisibilityConfirmation
) {
Button("Cancel", role: .cancel) {
- // Alert dismissal is implicit; no additional action required.
+ viewModel.editedVisibility = viewModel.repository.visibility
}
- Button("Rename", role: .destructive) {
+ Button("Apply", role: .destructive) {
Task {
- await viewModel.rename()
- if let newName = viewModel.updatedName {
- onRenamed(newName)
- dismiss()
+ if let updatedRepository = await viewModel.updateVisibility() {
+ onUpdated(updatedRepository)
}
}
}
} message: {
- Text("This will change the repository URL. Existing clones will be redirected but links may break.")
+ Text(visibilityConfirmationMessage(for: viewModel))
}
.alert(
- "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?",
+ "Permanently delete \(viewModel.repository.owner.canonicalName)/\(viewModel.repository.name)?",
isPresented: $showDeleteConfirmation
) {
Button("Cancel", role: .cancel) {
- // Alert dismissal is implicit; no additional action required.
+ // Alert dismissal is implicit.
}
Button("Delete", role: .destructive) {
Task {
@@ -91,115 +88,156 @@ struct RepositorySettingsView: View {
} message: {
Text("This cannot be undone.")
}
- .alert(item: $saveResultAlert) { alert in
- Alert(
- title: Text(alert.title),
- message: Text(alert.message),
- dismissButton: .default(Text("OK"))
- )
- }
}
- // MARK: - Info Section
-
@ViewBuilder
- private func infoSection(_ viewModel: RepositorySettingsViewModel) -> some View {
- Section("Info") {
- LabeledContent("Name") {
- Text(repository.name)
+ private func currentConfigurationSection(_ viewModel: RepositorySettingsViewModel) -> some View {
+ Section("Current Configuration") {
+ LabeledContent("Repository") {
+ Text("\(viewModel.repository.owner.canonicalName)/\(viewModel.repository.name)")
.font(.body.monospaced())
}
- TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
- .lineLimit(3...6)
+ LabeledContent("Default Branch") {
+ Text(viewModel.currentDefaultBranchName)
+ .font(.body.monospaced())
+ }
- Picker("Visibility", selection: Bindable(viewModel).editedVisibility) {
- Text("Public").tag(Visibility.public)
- Text("Unlisted").tag(Visibility.unlisted)
- Text("Private").tag(Visibility.private)
+ LabeledContent("Visibility") {
+ Text(repositoryVisibilityLabel(viewModel.repository.visibility))
}
+ }
+ }
- if !viewModel.branches.isEmpty {
- Picker("Default Branch", selection: Bindable(viewModel).editedHead) {
- ForEach(viewModel.branches, id: \.name) { branch in
- let name = branch.name.replacingOccurrences(of: "refs/heads/", with: "")
- Text(name).tag(name)
- }
- }
+ @ViewBuilder
+ private func metadataSection(_ viewModel: RepositorySettingsViewModel) -> some View {
+ Section {
+ TextField("Repository name", text: Bindable(viewModel).editedName)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+
+ TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
+ .lineLimit(2...4)
+
+ if let metadataValidationMessage = viewModel.metadataValidationMessage {
+ Text(metadataValidationMessage)
+ .font(.caption)
+ .foregroundStyle(.red)
+ } else if viewModel.normalizedEditedName != viewModel.repository.name {
+ Text("Changing the repository name updates the repository URL.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ } else {
+ Text("Name and description stay pending until you save this section.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
}
Button {
Task {
- let didSave = await viewModel.saveInfo()
- saveResultAlert = SaveResultAlert(
- title: didSave ? "Settings Updated" : "Couldn't Update Settings",
- message: didSave ? "Repository settings were saved." : (viewModel.error ?? "Please try again.")
- )
+ if let updatedRepository = await viewModel.saveMetadata() {
+ onUpdated(updatedRepository)
+ }
}
} label: {
- if viewModel.isSavingInfo {
+ if viewModel.isSavingMetadata {
ProgressView()
.frame(maxWidth: .infinity)
} else {
- Text("Save Changes")
+ Text("Save Details")
.frame(maxWidth: .infinity)
}
}
- .disabled(viewModel.isSavingInfo)
+ .disabled(
+ viewModel.isMutating ||
+ !viewModel.isMetadataDirty ||
+ viewModel.metadataValidationMessage != nil
+ )
+ } header: {
+ Text("Repository Details")
}
}
- // MARK: - Rename Section
-
@ViewBuilder
- private func renameSection(_ viewModel: RepositorySettingsViewModel) -> some View {
+ private func defaultBranchSection(_ viewModel: RepositorySettingsViewModel) -> some View {
Section {
- TextField("New repository name", text: Bindable(viewModel).editedName)
- .autocorrectionDisabled()
- .textInputAutocapitalization(.never)
+ LabeledContent("Current") {
+ Text(viewModel.currentDefaultBranchName)
+ .font(.body.monospaced())
+ }
- Text("This will change the repository URL. Existing clones will be redirected but links may break.")
- .font(.caption)
- .foregroundStyle(.secondary)
+ if viewModel.branches.isEmpty {
+ Text("This repository doesn't have any branches yet.")
+ .foregroundStyle(.secondary)
+ } else {
+ Picker("Branch", selection: Bindable(viewModel).editedHead) {
+ ForEach(viewModel.availableBranchNames, id: \.self) { branch in
+ Text(branch)
+ .font(.body.monospaced())
+ .tag(branch)
+ }
+ }
- Button {
- showRenameConfirmation = true
- } label: {
- if viewModel.isRenaming {
- ProgressView()
- .frame(maxWidth: .infinity)
- } else {
- Text("Rename Repository")
- .frame(maxWidth: .infinity)
+ Text("Changes stay pending until you set the new default branch.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Button {
+ Task {
+ if let updatedRepository = await viewModel.saveDefaultBranch() {
+ onUpdated(updatedRepository)
+ }
+ }
+ } label: {
+ if viewModel.isSavingDefaultBranch {
+ ProgressView()
+ .frame(maxWidth: .infinity)
+ } else {
+ Text("Set Default Branch")
+ .frame(maxWidth: .infinity)
+ }
}
+ .disabled(
+ viewModel.isMutating ||
+ !viewModel.isDefaultBranchDirty ||
+ viewModel.defaultBranchValidationMessage != nil
+ )
}
- .disabled(viewModel.isRenaming || viewModel.editedName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
} header: {
- Text("Rename")
+ Text("Default Branch")
}
}
- // MARK: - Access Section
-
@ViewBuilder
- private func accessSection() -> some View {
+ private func visibilitySection(_ viewModel: RepositorySettingsViewModel) -> some View {
Section {
- NavigationLink {
- RepositoryACLView(repository: repository, client: client, showsDoneButton: false)
- } label: {
- Label("Manage Access", systemImage: "person.2")
+ Picker("Visibility", selection: Bindable(viewModel).editedVisibility) {
+ Text("Public").tag(Visibility.public)
+ Text("Unlisted").tag(Visibility.unlisted)
+ Text("Private").tag(Visibility.private)
}
- Text("Review and update repository access without leaving settings.")
+ Text("Visibility changes apply immediately after you confirm them.")
.font(.caption)
.foregroundStyle(.secondary)
+
+ Button {
+ showVisibilityConfirmation = true
+ } label: {
+ if viewModel.isUpdatingVisibility {
+ ProgressView()
+ .frame(maxWidth: .infinity)
+ } else {
+ Text("Apply Visibility Change")
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .disabled(viewModel.isMutating || !viewModel.isVisibilityDirty)
} header: {
- Text("Access")
+ Text("Sensitive Settings")
}
}
- // MARK: - Delete Section
-
@ViewBuilder
private func deleteSection(_ viewModel: RepositorySettingsViewModel) -> some View {
Section {
@@ -214,14 +252,24 @@ struct RepositorySettingsView: View {
.frame(maxWidth: .infinity)
}
}
- .disabled(viewModel.isDeleting)
+ .disabled(viewModel.isMutating)
+ } header: {
+ Text("Danger Zone")
}
}
- private struct SaveResultAlert: Identifiable {
- let title: String
- let message: String
+ private func visibilityConfirmationTitle(for viewModel: RepositorySettingsViewModel) -> String {
+ "Change visibility to \(repositoryVisibilityLabel(viewModel.editedVisibility))?"
+ }
- var id: String { "\(title)-\(message)" }
+ private func visibilityConfirmationMessage(for viewModel: RepositorySettingsViewModel) -> String {
+ switch viewModel.editedVisibility {
+ case .public:
+ "Anyone will be able to find and view this repository."
+ case .unlisted:
+ "People with the link can view this repository, but it won't appear in public listings."
+ case .private:
+ "Only people with explicit access will be able to view this repository."
+ }
}
}
diff --git a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
index 30fe040..8bd80a1 100644
--- a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
+++ b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
@@ -1,72 +1,119 @@
import Foundation
-// MARK: - Response types
-
-private struct UpdateRepoResponse: Decodable, Sendable {
- let updateRepository: UpdatedRepo
-}
-
-private struct UpdateRepoInfoResponse: Decodable, Sendable {
- let updateRepository: UpdatedRepoInfo
+private struct UpdateRepositoryResponse: Decodable, Sendable {
+ let updateRepository: UpdatedRepositoryPayload
}
-private struct UpdatedRepo: Decodable, Sendable {
- let id: Int?
- let rid: String?
+private struct UpdatedRepositoryPayload: Decodable, Sendable {
+ let id: Int
+ let rid: String
let name: String
let description: String?
- let visibility: Visibility?
-}
+ let visibility: Visibility
+ let updated: Date
+ let head: Reference?
-private struct UpdatedRepoInfo: Decodable, Sendable {
- let id: Int
+ enum CodingKeys: String, CodingKey {
+ case id, rid, name, description, visibility, updated
+ case head = "HEAD"
+ }
+
+ func repositorySummary(using owner: Entity, service: SRHTService) -> RepositorySummary {
+ RepositorySummary(
+ id: id,
+ rid: rid,
+ service: service,
+ name: name,
+ description: description,
+ visibility: visibility,
+ updated: updated,
+ owner: owner,
+ head: head
+ )
+ }
}
-private struct DeleteRepoResponse: Decodable, Sendable {
- let deleteRepository: DeletedRepo
+private struct DeleteRepositoryResponse: Decodable, Sendable {
+ let deleteRepository: DeletedRepositoryPayload
}
-private struct DeletedRepo: Decodable, Sendable {
+private struct DeletedRepositoryPayload: Decodable, Sendable {
let id: Int
}
-// MARK: - View Model
-
@Observable
@MainActor
final class RepositorySettingsViewModel {
-
let repositoryId: Int
let repositoryRid: String
let service: SRHTService
- private let client: SRHTClient
- // MARK: - Info fields
+ private let client: SRHTClient
+ private(set) var repository: RepositorySummary
+ var editedName: String
var editedDescription: String
var editedVisibility: Visibility
var editedHead: String
- private let originalEditedHead: String
- var isSavingInfo = false
- // MARK: - Rename fields
+ private(set) var branches: [ReferenceDetail]
+ var isSavingMetadata = false
+ var isSavingDefaultBranch = false
+ var isUpdatingVisibility = false
+ var isDeleting = false
+ var error: String?
+ var didDelete = false
- var editedName: String
- var isRenaming = false
+ var isMutating: Bool {
+ isSavingMetadata || isSavingDefaultBranch || isUpdatingVisibility || isDeleting
+ }
- // MARK: - Delete state
+ var currentDefaultBranchName: String {
+ repository.defaultBranchName ?? "Not set"
+ }
- var isDeleting = false
+ var availableBranchNames: [String] {
+ branches.map { RepositorySummary.displayBranchName(for: $0.name) }
+ }
- // MARK: - Branches (for HEAD picker)
+ var normalizedEditedName: String {
+ editedName.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
- var branches: [ReferenceDetail]
+ var normalizedEditedDescription: String {
+ editedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
- // MARK: - Results
+ var metadataValidationMessage: String? {
+ Self.metadataValidationMessage(for: normalizedEditedName)
+ }
- var error: String?
- var updatedName: String?
- var didDelete = false
+ var defaultBranchValidationMessage: String? {
+ guard !branches.isEmpty else {
+ return "This repository doesn't have any branches yet."
+ }
+ let normalizedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !normalizedHead.isEmpty else {
+ return "Select a default branch."
+ }
+ guard availableBranchNames.contains(normalizedHead) else {
+ return "Select one of the available branches."
+ }
+ return nil
+ }
+
+ var isMetadataDirty: Bool {
+ normalizedEditedName != repository.name ||
+ normalizedEditedDescription != (repository.description ?? "")
+ }
+
+ var isDefaultBranchDirty: Bool {
+ editedHead.trimmingCharacters(in: .whitespacesAndNewlines) != (repository.defaultBranchName ?? "")
+ }
+
+ var isVisibilityDirty: Bool {
+ editedVisibility != repository.visibility
+ }
init(
repository: RepositorySummary,
@@ -77,118 +124,103 @@ final class RepositorySettingsViewModel {
self.repositoryRid = repository.rid
self.service = repository.service
self.client = client
+ self.repository = repository
+ self.branches = branches
+ self.editedName = repository.name
self.editedDescription = repository.description ?? ""
self.editedVisibility = repository.visibility
- self.editedName = repository.name
- self.branches = branches
-
- // Extract branch name from HEAD reference
- let initialEditedHead: String
- if let head = repository.head?.name {
- initialEditedHead = head.replacingOccurrences(of: "refs/heads/", with: "")
- } else {
- initialEditedHead = "main"
- }
- self.editedHead = initialEditedHead
- self.originalEditedHead = initialEditedHead
+ self.editedHead = repository.defaultBranchName ?? ""
}
- // MARK: - Update Repository Info
-
- private static let updateRepoMutation = """
+ private static let updateRepositoryMutation = """
mutation updateRepository($id: Int!, $input: RepoInput!) {
updateRepository(id: $id, input: $input) {
- id rid name description visibility
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ HEAD { name target }
}
}
"""
- private static let updateRepoInfoMutation = """
- mutation updateRepository($id: Int!, $input: RepoInput!) {
- updateRepository(id: $id, input: $input) {
- id
- }
+ private static let deleteRepositoryMutation = """
+ mutation deleteRepository($id: Int!) {
+ deleteRepository(id: $id) { id }
}
"""
- func saveInfo() async -> Bool {
- isSavingInfo = true
- defer { isSavingInfo = false }
+ func saveMetadata() async -> RepositorySummary? {
+ guard !isMutating else { return nil }
+ if let metadataValidationMessage {
+ error = metadataValidationMessage
+ return nil
+ }
+ guard isMetadataDirty else { return repository }
+
+ isSavingMetadata = true
+ defer { isSavingMetadata = false }
error = nil
+ let input: [String: any Sendable] = [
+ "name": normalizedEditedName,
+ "description": normalizedEditedDescription
+ ]
+
do {
- var input: [String: any Sendable] = [
- "description": editedDescription,
- "visibility": editedVisibility.rawValue
- ]
- if let headReference = selectedHeadReferenceForSave() {
- input["HEAD"] = headReference
- }
- _ = try await client.execute(
- service: service,
- query: Self.updateRepoInfoMutation,
- variables: ["id": repositoryId, "input": input],
- responseType: UpdateRepoInfoResponse.self
- )
- return true
+ return try await updateRepository(with: input)
} catch {
- self.error = error.userFacingMessage
- return false
+ self.error = "Couldn't update repository details. \(error.userFacingMessage)"
+ return nil
}
}
- // MARK: - Rename
+ func saveDefaultBranch() async -> RepositorySummary? {
+ guard !isMutating else { return nil }
+ if let defaultBranchValidationMessage {
+ error = defaultBranchValidationMessage
+ return nil
+ }
+ guard isDefaultBranchDirty else { return repository }
+ guard let headReference = selectedHeadReferenceForSave() else {
+ error = "Select one of the available branches."
+ return nil
+ }
- func rename() async {
- isRenaming = true
- defer { isRenaming = false }
+ isSavingDefaultBranch = true
+ defer { isSavingDefaultBranch = false }
error = nil
do {
- let input: [String: any Sendable] = [
- "name": editedName
- ]
- let result = try await client.execute(
- service: service,
- query: Self.updateRepoMutation,
- variables: ["id": repositoryId, "input": input],
- responseType: UpdateRepoResponse.self
- )
- updatedName = result.updateRepository.name
+ return try await updateRepository(with: ["HEAD": headReference])
} catch {
- self.error = error.userFacingMessage
+ self.error = "Couldn't update the default branch. \(error.userFacingMessage)"
+ return nil
}
}
- static func gitHeadReference(from input: String) -> String {
- let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return trimmed }
- if trimmed.hasPrefix("refs/") {
- return trimmed
- }
- return "refs/heads/\(trimmed)"
- }
+ func updateVisibility() async -> RepositorySummary? {
+ guard !isMutating else { return nil }
+ guard isVisibilityDirty else { return repository }
- func selectedHeadReferenceForSave() -> String? {
- let normalizedEditedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines)
- guard normalizedEditedHead != originalEditedHead else {
+ isUpdatingVisibility = true
+ defer { isUpdatingVisibility = false }
+ error = nil
+
+ do {
+ return try await updateRepository(with: ["visibility": editedVisibility.rawValue])
+ } catch {
+ self.error = "Couldn't update visibility. \(error.userFacingMessage)"
+ editedVisibility = repository.visibility
return nil
}
-
- return branches.first {
- $0.name.replacingOccurrences(of: "refs/heads/", with: "") == normalizedEditedHead
- }?.name
}
- // MARK: - Delete Repository
-
- private static let deleteRepoMutation = """
- mutation deleteRepository($id: Int!) {
- deleteRepository(id: $id) { id }
- }
- """
-
func deleteRepository() async {
+ guard !isMutating else { return }
+
isDeleting = true
defer { isDeleting = false }
error = nil
@@ -196,13 +228,69 @@ final class RepositorySettingsViewModel {
do {
_ = try await client.execute(
service: service,
- query: Self.deleteRepoMutation,
+ query: Self.deleteRepositoryMutation,
variables: ["id": repositoryId],
- responseType: DeleteRepoResponse.self
+ responseType: DeleteRepositoryResponse.self
)
didDelete = true
} catch {
self.error = error.userFacingMessage
}
}
+
+ private func updateRepository(with input: [String: any Sendable]) async throws -> RepositorySummary {
+ let result = try await client.execute(
+ service: service,
+ query: Self.updateRepositoryMutation,
+ variables: ["id": repositoryId, "input": input],
+ responseType: UpdateRepositoryResponse.self
+ )
+ let updatedRepository = result.updateRepository.repositorySummary(
+ using: repository.owner,
+ service: service
+ )
+ apply(updatedRepository)
+ return updatedRepository
+ }
+
+ private func apply(_ updatedRepository: RepositorySummary) {
+ repository = updatedRepository
+ editedName = updatedRepository.name
+ editedDescription = updatedRepository.description ?? ""
+ editedVisibility = updatedRepository.visibility
+ editedHead = updatedRepository.defaultBranchName ?? ""
+ }
+
+ static func metadataValidationMessage(for name: String) -> String? {
+ guard !name.isEmpty else {
+ return "Enter a repository name."
+ }
+ guard !name.contains("/") else {
+ return "Repository names can't contain '/'."
+ }
+ guard name.rangeOfCharacter(from: .whitespacesAndNewlines) == nil else {
+ return "Repository names can't contain spaces."
+ }
+ return nil
+ }
+
+ static func gitHeadReference(from input: String) -> String {
+ let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return trimmed }
+ if trimmed.hasPrefix("refs/") {
+ return trimmed
+ }
+ return "refs/heads/\(trimmed)"
+ }
+
+ func selectedHeadReferenceForSave() -> String? {
+ let normalizedEditedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard normalizedEditedHead != (repository.defaultBranchName ?? "") else {
+ return nil
+ }
+
+ return branches.first {
+ RepositorySummary.displayBranchName(for: $0.name) == normalizedEditedHead
+ }?.name
+ }
}
diff --git a/HutchTests/RepositorySettingsViewModelTests.swift b/HutchTests/RepositorySettingsViewModelTests.swift
index 5b06539..3b76da1 100644
--- a/HutchTests/RepositorySettingsViewModelTests.swift
+++ b/HutchTests/RepositorySettingsViewModelTests.swift
@@ -89,6 +89,32 @@ struct RepositorySettingsViewModelTests {
#expect(viewModel.selectedHeadReferenceForSave() == nil)
}
+ @Test
+ @MainActor
+ func metadataValidationRejectsEmptyOrInvalidNames() {
+ #expect(RepositorySettingsViewModel.metadataValidationMessage(for: "") == "Enter a repository name.")
+ #expect(RepositorySettingsViewModel.metadataValidationMessage(for: "repo/name") == "Repository names can't contain '/'.")
+ #expect(RepositorySettingsViewModel.metadataValidationMessage(for: "repo name") == "Repository names can't contain spaces.")
+ }
+
+ @Test
+ @MainActor
+ func metadataDirtyStateTracksNormalizedInputs() {
+ let viewModel = RepositorySettingsViewModel(
+ repository: makeRepository(headName: "refs/heads/main"),
+ branches: [ReferenceDetail(name: "refs/heads/main", target: nil, date: nil)],
+ client: SRHTClient(token: "test-token")
+ )
+
+ #expect(viewModel.isMetadataDirty == false)
+ viewModel.editedDescription = " updated "
+ #expect(viewModel.isMetadataDirty)
+
+ viewModel.editedDescription = "desc"
+ viewModel.editedName = " repo "
+ #expect(viewModel.isMetadataDirty == false)
+ }
+
@MainActor
private func makeRepository(headName: String?) -> RepositorySummary {
RepositorySummary(