2.5.1 #11
@@ -716,6 +716,10 @@ The weather is sunny today!
|
||||
<div class="example">
|
||||
<pre><code>~/Library/Application Support/oAI/sync/
|
||||
├── 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/
|
||||
├── my-first-chat.md
|
||||
├── python-help.md
|
||||
@@ -773,6 +777,16 @@ The weather is sunny today!
|
||||
<li>Check footer for detailed error message</li>
|
||||
</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>
|
||||
<ul>
|
||||
<li>Stop auto-sync on all but one machine</li>
|
||||
|
||||
@@ -33,6 +33,17 @@ class GitSyncService {
|
||||
// Debounce tracking
|
||||
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() {
|
||||
// Check if repository is cloned at initialization (synchronous check)
|
||||
let localPath = expandPath(settings.syncLocalPath)
|
||||
@@ -83,12 +94,84 @@ class GitSyncService {
|
||||
let localPath = expandPath(settings.syncLocalPath)
|
||||
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()
|
||||
|
||||
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
|
||||
func push(message: String = "Sync from Confab") async throws {
|
||||
try ensureCloned()
|
||||
@@ -299,6 +382,46 @@ class GitSyncService {
|
||||
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
|
||||
func importAllConversations() async throws -> (imported: Int, skipped: Int, errors: Int) {
|
||||
try ensureCloned()
|
||||
|
||||
@@ -131,6 +131,27 @@ struct ChatView: View {
|
||||
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 {
|
||||
if !collapsedFolders.contains(entry.folder.id) {
|
||||
ForEach(folderConversations) { conversation in
|
||||
conversationRow(conversation)
|
||||
conversationRow(conversation, depth: entry.depth + 1)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
@@ -320,8 +320,9 @@ struct SidebarView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func conversationRow(_ conversation: Conversation) -> some View {
|
||||
private func conversationRow(_ conversation: Conversation, depth: Int = 0) -> some View {
|
||||
SidebarConversationRow(conversation: conversation)
|
||||
.padding(.leading, CGFloat(depth) * 14)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(count: 2) {
|
||||
chatViewModel.loadConversation(conversation)
|
||||
|
||||
@@ -266,7 +266,7 @@ struct ConversationListView: View {
|
||||
Section {
|
||||
if !collapsedFolders.contains(entry.folder.id) {
|
||||
ForEach(folderConversations) { conversation in
|
||||
conversationRow(conversation)
|
||||
conversationRow(conversation, depth: entry.depth + 1)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
@@ -337,7 +337,7 @@ struct ConversationListView: View {
|
||||
}
|
||||
|
||||
@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
|
||||
HStack(spacing: 12) {
|
||||
if isSelecting {
|
||||
@@ -382,6 +382,7 @@ struct ConversationListView: View {
|
||||
.help("Delete conversation")
|
||||
}
|
||||
}
|
||||
.padding(.leading, CGFloat(depth) * 14)
|
||||
.listRowBackground(
|
||||
!isSelecting && index == selectedIndex
|
||||
? 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) {
|
||||
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(
|
||||
isPresented: $showRestoreFilePicker,
|
||||
allowedContentTypes: [.json],
|
||||
|
||||
+13
-9
@@ -206,7 +206,7 @@ struct oAIApp: App {
|
||||
|
||||
// ── Help menu ─────────────────────────────────────────────────
|
||||
CommandGroup(replacing: .help) {
|
||||
Button("Confab Help") { openHelp() }
|
||||
Button("Confab Help") { Self.openHelpBook() }
|
||||
.keyboardShortcut("?", modifiers: .command)
|
||||
Divider()
|
||||
Button("Read Release Notes") {
|
||||
@@ -223,14 +223,18 @@ struct oAIApp: App {
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func openHelp() {
|
||||
// Opens the Help Book's index.html directly in the default browser rather than
|
||||
// through NSHelpManager/Help Viewer — see CLAUDE.md's macOS 27 beta note for why
|
||||
// (Apple's Tips.app replacement for Help Viewer can't resolve anchors on this beta).
|
||||
// Revisit once macOS 27 reaches RC.
|
||||
if let helpBookURL = Bundle.main.url(forResource: "Confab.help", withExtension: nil) {
|
||||
NSWorkspace.shared.open(helpBookURL.appendingPathComponent("Contents/Resources/en.lproj/index.html"))
|
||||
}
|
||||
/// Opens the Help Book's index.html directly in the default browser rather than through
|
||||
/// NSHelpManager/Help Viewer — see CLAUDE.md's macOS 27 beta note for why (Apple's Tips.app
|
||||
/// replacement for Help Viewer can't resolve anchors on this beta). Revisit once macOS 27
|
||||
/// reaches RC. No anchor/fragment support: NSWorkspace.shared.open() silently drops #fragments
|
||||
/// for file:// URLs before handing off to the browser (confirmed via location.hash coming back
|
||||
/// empty in the opened page) — deep links into a specific section aren't reliable through this
|
||||
/// 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
|
||||
}
|
||||
|
||||
@@ -285,4 +285,71 @@ struct GitSyncServiceTests {
|
||||
#expect(decoded.notes[conversationId]?.filename == "Chat-a3f2.md")
|
||||
#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