NSWorkspace.shared.open() silently drops #fragment anchors on file:// URLs, so "Fix It Myself" always landed on the Help Book index instead of the relevant section. Replaced with GitSyncManualFixSheet, an in-app sheet showing the real conflicting filenames and sync path. Also indent conversation rows one level deeper than their containing folder in the sidebar and conversation list, so nesting is visible on the conversations themselves and not just the folder headers.
177 lines
6.6 KiB
Swift
177 lines
6.6 KiB
Swift
//
|
|
// GitSyncConflictSheet.swift
|
|
// Confab
|
|
//
|
|
// Recovery UI for Git Sync's "untracked working tree files" pull failure
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
//
|
|
// This file is part of Confab.
|
|
//
|
|
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
|
|
// You may use, study, modify, and share it for any noncommercial
|
|
// purpose. Commercial use — including selling Confab or any part of
|
|
// it, standalone or bundled into another product or service —
|
|
// requires a separate commercial license from the copyright holder.
|
|
//
|
|
// See the LICENSE file or
|
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
|
// the full license text. For commercial licensing, contact Rune
|
|
// Olsen via <https://confab.no>.
|
|
|
|
|
|
import SwiftUI
|
|
|
|
struct GitSyncConflictSheet: View {
|
|
let pending: GitSyncService.PendingGitConflict
|
|
/// Performs the automatic fix; nil return means success.
|
|
let onFixForMe: () async -> String?
|
|
/// Swaps this sheet for GitSyncManualFixSheet's in-app step-by-step instructions.
|
|
let onFixMyself: () -> Void
|
|
let onDismiss: () -> Void
|
|
|
|
private enum RecoveryState: Equatable {
|
|
case idle
|
|
case fixing
|
|
case succeeded
|
|
case failed(String)
|
|
}
|
|
|
|
@State private var recoveryState: RecoveryState = .idle
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
// Header
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "exclamationmark.arrow.triangle.2.circlepath")
|
|
.font(.title2)
|
|
.foregroundStyle(.orange)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Sync Ran Into a Conflict")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
Text("Confab syncs in the background automatically, and just hit a file that collided with a leftover local copy of itself")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
Spacer()
|
|
}
|
|
|
|
// Conflicting files
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("AFFECTED FILES")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ForEach(pending.files, id: \.self) { file in
|
|
Text(file)
|
|
.font(.system(size: 13, design: .monospaced))
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.textSelection(.enabled)
|
|
.padding(12)
|
|
.background(Color.secondary.opacity(0.08))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
Text("These are Confab's own sync bookkeeping files, not your conversations — nothing you've written is at risk either way.")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
statusBanner
|
|
|
|
if !pending.canAutoFix {
|
|
HStack(alignment: .top, spacing: 8) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.orange)
|
|
.font(.system(size: 13))
|
|
.padding(.top, 1)
|
|
Text("One or more of these files aren't ones Confab recognizes as safe to remove automatically — please fix this one yourself.")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.padding(10)
|
|
.background(Color.orange.opacity(0.08))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
|
|
// Buttons
|
|
HStack(spacing: 8) {
|
|
Button("Fix It Myself") {
|
|
onFixMyself()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.keyboardShortcut(.escape, modifiers: [])
|
|
|
|
Spacer()
|
|
|
|
if recoveryState == .succeeded {
|
|
Button("Done") {
|
|
onDismiss()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.keyboardShortcut(.return, modifiers: [])
|
|
} else {
|
|
Button("Fix It For Me") {
|
|
recoveryState = .fixing
|
|
Task {
|
|
if let errorMessage = await onFixForMe() {
|
|
recoveryState = .failed(errorMessage)
|
|
} else {
|
|
recoveryState = .succeeded
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(!pending.canAutoFix || recoveryState == .fixing)
|
|
.keyboardShortcut(.return, modifiers: [])
|
|
}
|
|
}
|
|
}
|
|
.padding(24)
|
|
.frame(width: 480)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var statusBanner: some View {
|
|
switch recoveryState {
|
|
case .idle:
|
|
EmptyView()
|
|
case .fixing:
|
|
HStack(spacing: 8) {
|
|
ProgressView().controlSize(.small)
|
|
Text("Fixing...")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
case .succeeded:
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
Text("Fixed — your conversations are back in sync.")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
case .failed(let message):
|
|
HStack(alignment: .top, spacing: 8) {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(.red)
|
|
.font(.system(size: 13))
|
|
.padding(.top, 1)
|
|
Text("Still couldn't sync: \(message)")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
}
|
|
}
|