2.5.1 #11
@@ -716,6 +716,10 @@ The weather is sunny today!
|
|||||||
<div class="example">
|
<div class="example">
|
||||||
<pre><code>~/Library/Application Support/oAI/sync/
|
<pre><code>~/Library/Application Support/oAI/sync/
|
||||||
├── README.md # Warning about manual edits
|
├── README.md # Warning about manual edits
|
||||||
|
├── folders.json # Your folder structure (auto-managed, don't edit)
|
||||||
|
├── notes.json # Per-conversation notes index (auto-managed, don't edit)
|
||||||
|
├── notes/ # Per-conversation notes files
|
||||||
|
│ └── ...
|
||||||
└── conversations/
|
└── conversations/
|
||||||
├── my-first-chat.md
|
├── my-first-chat.md
|
||||||
├── python-help.md
|
├── python-help.md
|
||||||
@@ -773,6 +777,16 @@ The weather is sunny today!
|
|||||||
<li>Check footer for detailed error message</li>
|
<li>Check footer for detailed error message</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<h4 id="git-sync-troubleshooting-untracked">"Untracked Working Tree Files Would Be Overwritten"</h4>
|
||||||
|
<p>This can happen the first time a machine syncs after Confab adds a new file to the sync repository (like <code>folders.json</code> or <code>notes.json</code>) — if that file gets written locally before this machine has ever pulled it from the remote, git sees it as a leftover, unrelated file blocking the merge.</p>
|
||||||
|
<p>When Confab detects this specific error, it offers to fix it automatically — a dialog appears with a <strong>"Fix It For Me"</strong> button that removes the leftover local copy and completes the sync, or a <strong>"Fix It Myself"</strong> button if you'd rather do it by hand:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Open your sync folder (default: <code>~/Library/Application Support/oAI/sync</code>)</li>
|
||||||
|
<li>Delete the specific file(s) named in the error message</li>
|
||||||
|
<li>Open Terminal, <code>cd</code> into that folder, and run <code>git pull --ff-only</code> once</li>
|
||||||
|
<li>Confab will regenerate the file correctly on its next sync</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
<h4>Merge Conflicts</h4>
|
<h4>Merge Conflicts</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Stop auto-sync on all but one machine</li>
|
<li>Stop auto-sync on all but one machine</li>
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ class GitSyncService {
|
|||||||
// Debounce tracking
|
// Debounce tracking
|
||||||
private var pendingSyncTask: Task<Void, Never>?
|
private var pendingSyncTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
/// A pull failed because a new sync-repo file (folders.json, notes.json, ...) collided with an
|
||||||
|
/// untracked local copy — see parseUntrackedFileConflict(from:). Surfaced to the user via
|
||||||
|
/// GitSyncConflictSheet (wired in ChatView.swift), offering an automatic or manual fix.
|
||||||
|
struct PendingGitConflict: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let files: [String]
|
||||||
|
let rawError: String
|
||||||
|
var canAutoFix: Bool { files.allSatisfy(GitSyncService.isFileSafeToAutoDelete) }
|
||||||
|
}
|
||||||
|
private(set) var pendingGitConflict: PendingGitConflict? = nil
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
// Check if repository is cloned at initialization (synchronous check)
|
// Check if repository is cloned at initialization (synchronous check)
|
||||||
let localPath = expandPath(settings.syncLocalPath)
|
let localPath = expandPath(settings.syncLocalPath)
|
||||||
@@ -83,12 +94,84 @@ class GitSyncService {
|
|||||||
let localPath = expandPath(settings.syncLocalPath)
|
let localPath = expandPath(settings.syncLocalPath)
|
||||||
log.info("Pulling changes from remote")
|
log.info("Pulling changes from remote")
|
||||||
|
|
||||||
_ = try await runGit(["pull", "--ff-only"], cwd: localPath)
|
do {
|
||||||
|
_ = try await runGit(["pull", "--ff-only"], cwd: localPath)
|
||||||
|
} catch {
|
||||||
|
// Surface an "untracked working tree files" collision as a recoverable conflict the
|
||||||
|
// user can act on, without changing this function's throw contract — existing callers
|
||||||
|
// (syncOnStartup's non-fatal log, syncNow's error display) are unaffected. Guarded on
|
||||||
|
// pendingGitConflict already being nil so a second pull failure while the sheet is
|
||||||
|
// still showing doesn't replace its content out from under the user.
|
||||||
|
if pendingGitConflict == nil,
|
||||||
|
let files = Self.parseUntrackedFileConflict(from: error.localizedDescription) {
|
||||||
|
pendingGitConflict = PendingGitConflict(files: files, rawError: error.localizedDescription)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
syncStatus.lastSyncTime = Date()
|
syncStatus.lastSyncTime = Date()
|
||||||
|
|
||||||
await updateStatus()
|
await updateStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-verifies each file is still genuinely untracked (not just trusting the parsed error text)
|
||||||
|
/// immediately before deleting, deletes them, retries pull(), and on success imports so the
|
||||||
|
/// previously-blocked content actually lands. Returns nil on success, an error description on
|
||||||
|
/// failure. Deliberately does not touch pendingGitConflict itself — dismissPendingGitConflict()
|
||||||
|
/// is the sheet's explicit "I'm done looking at this" signal. SwiftUI's .sheet(item:) dismisses
|
||||||
|
/// the instant pendingGitConflict goes nil, so clearing it here would yank the sheet away before
|
||||||
|
/// the user ever sees whether the fix actually worked.
|
||||||
|
func autoResolveUntrackedConflict(_ conflict: PendingGitConflict) async -> String? {
|
||||||
|
guard conflict.canAutoFix else {
|
||||||
|
return "Some of these files can't be safely removed automatically."
|
||||||
|
}
|
||||||
|
|
||||||
|
let localPath = expandPath(settings.syncLocalPath)
|
||||||
|
|
||||||
|
for file in conflict.files {
|
||||||
|
guard let status = try? await runGit(["status", "--porcelain", "--", file], cwd: localPath),
|
||||||
|
status.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("??")
|
||||||
|
else {
|
||||||
|
return "\(file) is no longer untracked — leaving it in place rather than risk deleting something else. Try syncing again."
|
||||||
|
}
|
||||||
|
try? FileManager.default.removeItem(at: URL(fileURLWithPath: localPath).appendingPathComponent(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await pull()
|
||||||
|
_ = try await importAllConversations()
|
||||||
|
return nil
|
||||||
|
} catch {
|
||||||
|
return error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit dismiss for GitSyncConflictSheet — see autoResolveUntrackedConflict's note on why
|
||||||
|
/// the recovery method itself never clears this.
|
||||||
|
func dismissPendingGitConflict() {
|
||||||
|
pendingGitConflict = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shown by GitSyncManualFixSheet when the user picks "Fix It Myself" on GitSyncConflictSheet.
|
||||||
|
/// Deliberately in-app text rather than a deep link into the Help Book: NSWorkspace.shared.open()
|
||||||
|
/// silently drops the #fragment for file:// URLs before handing off to the default browser (the
|
||||||
|
/// anchor never survives — confirmed by inspecting location.hash in the opened page, it comes
|
||||||
|
/// back empty), so an anchored Help Book link always lands on the index instead of the relevant
|
||||||
|
/// section. Carrying the conflict's own file list and the real sync path into this sheet is also
|
||||||
|
/// just more useful than generic help-page prose pointing at "the file(s) named in the error".
|
||||||
|
private(set) var pendingManualFixInstructions: PendingGitConflict? = nil
|
||||||
|
|
||||||
|
/// Swaps GitSyncConflictSheet for GitSyncManualFixSheet — clearing pendingGitConflict here (rather
|
||||||
|
/// than relying on the sheet's own onDismiss) dismisses the first sheet via its .sheet(item:)
|
||||||
|
/// binding while pendingManualFixInstructions immediately presents the second.
|
||||||
|
func showManualFixInstructions(for conflict: PendingGitConflict) {
|
||||||
|
pendingGitConflict = nil
|
||||||
|
pendingManualFixInstructions = conflict
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissManualFixInstructions() {
|
||||||
|
pendingManualFixInstructions = nil
|
||||||
|
}
|
||||||
|
|
||||||
/// Push local changes to remote
|
/// Push local changes to remote
|
||||||
func push(message: String = "Sync from Confab") async throws {
|
func push(message: String = "Sync from Confab") async throws {
|
||||||
try ensureCloned()
|
try ensureCloned()
|
||||||
@@ -299,6 +382,46 @@ class GitSyncService {
|
|||||||
return localFolderIds.filter { !manifestFolderIds.contains($0) }
|
return localFolderIds.filter { !manifestFolderIds.contains($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Untracked File Conflict Recovery
|
||||||
|
|
||||||
|
/// Parses git's "untracked working tree files would be overwritten by merge" pull failure into
|
||||||
|
/// the list of colliding relative paths. Returns nil for any other error (auth, network, a real
|
||||||
|
/// merge conflict) — those aren't what this recovery flow is for. Exact git format:
|
||||||
|
/// "error: The following untracked working tree files would be overwritten by merge:\n\t<file>\n...\nPlease move or remove them before you merge.\nAborting"
|
||||||
|
nonisolated static func parseUntrackedFileConflict(from message: String) -> [String]? {
|
||||||
|
let marker = "untracked working tree files would be overwritten by merge:"
|
||||||
|
guard let markerRange = message.range(of: marker) else { return nil }
|
||||||
|
|
||||||
|
let lines = message[markerRange.upperBound...].components(separatedBy: "\n")
|
||||||
|
var files: [String] = []
|
||||||
|
for line in lines {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.isEmpty { continue }
|
||||||
|
// The file list ends at the first line that isn't an indented filename (git's own
|
||||||
|
// trailing "Please move or remove them..."/"Aborting" lines aren't tab-indented).
|
||||||
|
guard line.hasPrefix("\t") || line.hasPrefix(" ") else { break }
|
||||||
|
files.append(trimmed)
|
||||||
|
}
|
||||||
|
return files.isEmpty ? nil : files
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Defense in depth for the "Fix It For Me" auto-recovery path: only files this app itself is
|
||||||
|
/// known to write into the sync repo are ever eligible for automatic deletion. Rejects path
|
||||||
|
/// traversal, absolute paths, and anything outside the known shape — an unrecognized file falls
|
||||||
|
/// back to manual recovery only (see PendingGitConflict.canAutoFix).
|
||||||
|
nonisolated static func isFileSafeToAutoDelete(_ relativePath: String) -> Bool {
|
||||||
|
if relativePath == "folders.json" || relativePath == "notes.json" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for prefix in ["conversations/", "notes/"] {
|
||||||
|
guard relativePath.hasPrefix(prefix) else { continue }
|
||||||
|
let rest = relativePath.dropFirst(prefix.count)
|
||||||
|
// Exactly one path segment (no further "/"), and a .md file.
|
||||||
|
return !rest.isEmpty && !rest.contains("/") && rest.hasSuffix(".md")
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/// Import conversations from markdown files
|
/// Import conversations from markdown files
|
||||||
func importAllConversations() async throws -> (imported: Int, skipped: Int, errors: Int) {
|
func importAllConversations() async throws -> (imported: Int, skipped: Int, errors: Int) {
|
||||||
try ensureCloned()
|
try ensureCloned()
|
||||||
|
|||||||
@@ -131,6 +131,27 @@ struct ChatView: View {
|
|||||||
onDeny: { MCPService.shared.denyPendingPersonalDataAction() }
|
onDeny: { MCPService.shared.denyPendingPersonalDataAction() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.sheet(item: Binding(
|
||||||
|
get: { GitSyncService.shared.pendingGitConflict },
|
||||||
|
set: { _ in }
|
||||||
|
)) { pending in
|
||||||
|
GitSyncConflictSheet(
|
||||||
|
pending: pending,
|
||||||
|
onFixForMe: { await GitSyncService.shared.autoResolveUntrackedConflict(pending) },
|
||||||
|
onFixMyself: { GitSyncService.shared.showManualFixInstructions(for: pending) },
|
||||||
|
onDismiss: { GitSyncService.shared.dismissPendingGitConflict() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sheet(item: Binding(
|
||||||
|
get: { GitSyncService.shared.pendingManualFixInstructions },
|
||||||
|
set: { _ in }
|
||||||
|
)) { pending in
|
||||||
|
GitSyncManualFixSheet(
|
||||||
|
files: pending.files,
|
||||||
|
syncPath: SettingsService.shared.syncLocalPath,
|
||||||
|
onDone: { GitSyncService.shared.dismissManualFixInstructions() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ struct SidebarView: View {
|
|||||||
Section {
|
Section {
|
||||||
if !collapsedFolders.contains(entry.folder.id) {
|
if !collapsedFolders.contains(entry.folder.id) {
|
||||||
ForEach(folderConversations) { conversation in
|
ForEach(folderConversations) { conversation in
|
||||||
conversationRow(conversation)
|
conversationRow(conversation, depth: entry.depth + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
@@ -320,8 +320,9 @@ struct SidebarView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func conversationRow(_ conversation: Conversation) -> some View {
|
private func conversationRow(_ conversation: Conversation, depth: Int = 0) -> some View {
|
||||||
SidebarConversationRow(conversation: conversation)
|
SidebarConversationRow(conversation: conversation)
|
||||||
|
.padding(.leading, CGFloat(depth) * 14)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture(count: 2) {
|
.onTapGesture(count: 2) {
|
||||||
chatViewModel.loadConversation(conversation)
|
chatViewModel.loadConversation(conversation)
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ struct ConversationListView: View {
|
|||||||
Section {
|
Section {
|
||||||
if !collapsedFolders.contains(entry.folder.id) {
|
if !collapsedFolders.contains(entry.folder.id) {
|
||||||
ForEach(folderConversations) { conversation in
|
ForEach(folderConversations) { conversation in
|
||||||
conversationRow(conversation)
|
conversationRow(conversation, depth: entry.depth + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
@@ -337,7 +337,7 @@ struct ConversationListView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func conversationRow(_ conversation: Conversation) -> some View {
|
private func conversationRow(_ conversation: Conversation, depth: Int = 0) -> some View {
|
||||||
let index = filteredConversations.firstIndex(where: { $0.id == conversation.id }) ?? 0
|
let index = filteredConversations.firstIndex(where: { $0.id == conversation.id }) ?? 0
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
if isSelecting {
|
if isSelecting {
|
||||||
@@ -382,6 +382,7 @@ struct ConversationListView: View {
|
|||||||
.help("Delete conversation")
|
.help("Delete conversation")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.leading, CGFloat(depth) * 14)
|
||||||
.listRowBackground(
|
.listRowBackground(
|
||||||
!isSelecting && index == selectedIndex
|
!isSelecting && index == selectedIndex
|
||||||
? Color.confabAccent.opacity(0.15)
|
? Color.confabAccent.opacity(0.15)
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
//
|
||||||
|
// GitSyncManualFixSheet.swift
|
||||||
|
// Confab
|
||||||
|
//
|
||||||
|
// Manual-fix instructions for Git Sync's "untracked working tree files" pull failure —
|
||||||
|
// shown in-app instead of deep-linking to the Help Book (NSWorkspace.shared.open() drops
|
||||||
|
// #fragment anchors on file:// URLs, so an anchored help link always landed on the index).
|
||||||
|
//
|
||||||
|
// 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 GitSyncManualFixSheet: View {
|
||||||
|
let files: [String]
|
||||||
|
let syncPath: String
|
||||||
|
let onDone: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "wrench.and.screwdriver")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Fix It Yourself")
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
Text("Four steps, then Confab takes over again")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
step(1, "Open your sync folder:")
|
||||||
|
Text(syncPath)
|
||||||
|
.font(.system(size: 13, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.secondary.opacity(0.08))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
||||||
|
step(2, "Delete the file\(files.count == 1 ? "" : "s") named in the error:")
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(files, id: \.self) { file in
|
||||||
|
Text(file)
|
||||||
|
.font(.system(size: 13, design: .monospaced))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.secondary.opacity(0.08))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
||||||
|
step(3, "Open Terminal, cd into that folder, and run this once:")
|
||||||
|
Text("git pull --ff-only")
|
||||||
|
.font(.system(size: 13, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.secondary.opacity(0.08))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
||||||
|
step(4, "That's it — Confab will regenerate the file correctly on its next sync.")
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Done") {
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.keyboardShortcut(.return, modifiers: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.frame(width: 480)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func step(_ number: Int, _ text: String) -> some View {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
|
Text("\(number).")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -237,6 +237,33 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
.sheet(isPresented: $showEmailLog) {
|
.sheet(isPresented: $showEmailLog) {
|
||||||
EmailLogView()
|
EmailLogView()
|
||||||
}
|
}
|
||||||
|
// Duplicated (not moved) from ChatView.swift: SwiftUI won't stack a new sheet on top of
|
||||||
|
// this already-presented Settings sheet if the .sheet(item:) only lives on ChatView, which
|
||||||
|
// sits underneath/behind Settings once it's open — a conflict triggered by the "Sync Now"
|
||||||
|
// button in here would be silently dropped with no visible modal. Attaching the same
|
||||||
|
// binding here too lets it present correctly regardless of which one is on top; only the
|
||||||
|
// currently-frontmost host actually shows it, so there's no double-presentation risk.
|
||||||
|
.sheet(item: Binding(
|
||||||
|
get: { gitSync.pendingGitConflict },
|
||||||
|
set: { _ in }
|
||||||
|
)) { pending in
|
||||||
|
GitSyncConflictSheet(
|
||||||
|
pending: pending,
|
||||||
|
onFixForMe: { await gitSync.autoResolveUntrackedConflict(pending) },
|
||||||
|
onFixMyself: { gitSync.showManualFixInstructions(for: pending) },
|
||||||
|
onDismiss: { gitSync.dismissPendingGitConflict() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sheet(item: Binding(
|
||||||
|
get: { gitSync.pendingManualFixInstructions },
|
||||||
|
set: { _ in }
|
||||||
|
)) { pending in
|
||||||
|
GitSyncManualFixSheet(
|
||||||
|
files: pending.files,
|
||||||
|
syncPath: SettingsService.shared.syncLocalPath,
|
||||||
|
onDone: { gitSync.dismissManualFixInstructions() }
|
||||||
|
)
|
||||||
|
}
|
||||||
.fileImporter(
|
.fileImporter(
|
||||||
isPresented: $showRestoreFilePicker,
|
isPresented: $showRestoreFilePicker,
|
||||||
allowedContentTypes: [.json],
|
allowedContentTypes: [.json],
|
||||||
|
|||||||
+13
-9
@@ -206,7 +206,7 @@ struct oAIApp: App {
|
|||||||
|
|
||||||
// ── Help menu ─────────────────────────────────────────────────
|
// ── Help menu ─────────────────────────────────────────────────
|
||||||
CommandGroup(replacing: .help) {
|
CommandGroup(replacing: .help) {
|
||||||
Button("Confab Help") { openHelp() }
|
Button("Confab Help") { Self.openHelpBook() }
|
||||||
.keyboardShortcut("?", modifiers: .command)
|
.keyboardShortcut("?", modifiers: .command)
|
||||||
Divider()
|
Divider()
|
||||||
Button("Read Release Notes") {
|
Button("Read Release Notes") {
|
||||||
@@ -223,14 +223,18 @@ struct oAIApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
private func openHelp() {
|
/// Opens the Help Book's index.html directly in the default browser rather than through
|
||||||
// Opens the Help Book's index.html directly in the default browser rather than
|
/// NSHelpManager/Help Viewer — see CLAUDE.md's macOS 27 beta note for why (Apple's Tips.app
|
||||||
// through NSHelpManager/Help Viewer — see CLAUDE.md's macOS 27 beta note for why
|
/// replacement for Help Viewer can't resolve anchors on this beta). Revisit once macOS 27
|
||||||
// (Apple's Tips.app replacement for Help Viewer can't resolve anchors on this beta).
|
/// reaches RC. No anchor/fragment support: NSWorkspace.shared.open() silently drops #fragments
|
||||||
// Revisit once macOS 27 reaches RC.
|
/// for file:// URLs before handing off to the browser (confirmed via location.hash coming back
|
||||||
if let helpBookURL = Bundle.main.url(forResource: "Confab.help", withExtension: nil) {
|
/// empty in the opened page) — deep links into a specific section aren't reliable through this
|
||||||
NSWorkspace.shared.open(helpBookURL.appendingPathComponent("Contents/Resources/en.lproj/index.html"))
|
/// API, so callers needing to point at specific content should show it in-app instead (see
|
||||||
}
|
/// GitSyncManualFixSheet for an example) rather than trying to anchor into this Help Book.
|
||||||
|
nonisolated static func openHelpBook() {
|
||||||
|
guard let helpBookURL = Bundle.main.url(forResource: "Confab.help", withExtension: nil) else { return }
|
||||||
|
let url = helpBookURL.appendingPathComponent("Contents/Resources/en.lproj/index.html")
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,4 +285,71 @@ struct GitSyncServiceTests {
|
|||||||
#expect(decoded.notes[conversationId]?.filename == "Chat-a3f2.md")
|
#expect(decoded.notes[conversationId]?.filename == "Chat-a3f2.md")
|
||||||
#expect(decoded.notes[conversationId]?.enabled == true)
|
#expect(decoded.notes[conversationId]?.enabled == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - parseUntrackedFileConflict
|
||||||
|
|
||||||
|
@Test("Parses a single colliding file out of git's untracked-files error")
|
||||||
|
func parseUntrackedFileConflictSingleFile() {
|
||||||
|
let message = "error: The following untracked working tree files would be overwritten by merge:\n\tfolders.json\nPlease move or remove them before you merge.\nAborting"
|
||||||
|
#expect(GitSyncService.parseUntrackedFileConflict(from: message) == ["folders.json"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Parses multiple colliding files out of git's untracked-files error")
|
||||||
|
func parseUntrackedFileConflictMultipleFiles() {
|
||||||
|
let message = "error: The following untracked working tree files would be overwritten by merge:\n\tfolders.json\n\tnotes.json\nPlease move or remove them before you merge.\nAborting"
|
||||||
|
#expect(GitSyncService.parseUntrackedFileConflict(from: message) == ["folders.json", "notes.json"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Wrapped SyncError.gitFailed description still parses correctly")
|
||||||
|
func parseUntrackedFileConflictWrappedMessage() {
|
||||||
|
let message = "Git command failed: error: The following untracked working tree files would be overwritten by merge:\n\tnotes/Chat-a3f2.md\nPlease move or remove them before you merge.\nAborting"
|
||||||
|
#expect(GitSyncService.parseUntrackedFileConflict(from: message) == ["notes/Chat-a3f2.md"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Unrelated git errors return nil, not an empty or bogus file list")
|
||||||
|
func parseUntrackedFileConflictUnrelatedErrors() {
|
||||||
|
#expect(GitSyncService.parseUntrackedFileConflict(from: "fatal: Authentication failed for 'https://gitlab.pm/rune/oai-swift.git/'") == nil)
|
||||||
|
#expect(GitSyncService.parseUntrackedFileConflict(from: "fatal: unable to access: Could not resolve host") == nil)
|
||||||
|
#expect(GitSyncService.parseUntrackedFileConflict(from: "error: Your local changes to the following files would be overwritten by merge:\n\tconversations/x.md") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - isFileSafeToAutoDelete
|
||||||
|
|
||||||
|
@Test("Known sync-manifest files are safe to auto-delete")
|
||||||
|
func isFileSafeToAutoDeleteAllowsKnownFiles() {
|
||||||
|
#expect(GitSyncService.isFileSafeToAutoDelete("folders.json"))
|
||||||
|
#expect(GitSyncService.isFileSafeToAutoDelete("notes.json"))
|
||||||
|
#expect(GitSyncService.isFileSafeToAutoDelete("conversations/my-chat.md"))
|
||||||
|
#expect(GitSyncService.isFileSafeToAutoDelete("notes/Chat-a3f2.md"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Path traversal and absolute paths are never safe to auto-delete")
|
||||||
|
func isFileSafeToAutoDeleteRejectsTraversal() {
|
||||||
|
#expect(!GitSyncService.isFileSafeToAutoDelete("../etc/passwd"))
|
||||||
|
#expect(!GitSyncService.isFileSafeToAutoDelete("/etc/passwd"))
|
||||||
|
#expect(!GitSyncService.isFileSafeToAutoDelete("conversations/../../../etc/passwd"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Files outside the known shape are never safe to auto-delete")
|
||||||
|
func isFileSafeToAutoDeleteRejectsUnknownFiles() {
|
||||||
|
#expect(!GitSyncService.isFileSafeToAutoDelete("README.md"))
|
||||||
|
#expect(!GitSyncService.isFileSafeToAutoDelete("conversations/sub/x.md"))
|
||||||
|
#expect(!GitSyncService.isFileSafeToAutoDelete("notes/sub/x.md"))
|
||||||
|
#expect(!GitSyncService.isFileSafeToAutoDelete(""))
|
||||||
|
#expect(!GitSyncService.isFileSafeToAutoDelete("conversations/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PendingGitConflict.canAutoFix
|
||||||
|
|
||||||
|
@Test("canAutoFix is true when every file is safe to auto-delete")
|
||||||
|
func pendingGitConflictCanAutoFixAllSafe() {
|
||||||
|
let conflict = GitSyncService.PendingGitConflict(files: ["folders.json", "notes.json"], rawError: "")
|
||||||
|
#expect(conflict.canAutoFix)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("canAutoFix is false when any file isn't safe to auto-delete")
|
||||||
|
func pendingGitConflictCanAutoFixOneUnsafe() {
|
||||||
|
let conflict = GitSyncService.PendingGitConflict(files: ["folders.json", "README.md"], rawError: "")
|
||||||
|
#expect(!conflict.canAutoFix)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user