2.5.1 #11

Merged
rune merged 10 commits from 2.5.1 into main 2026-08-04 14:12:17 +02:00
99 changed files with 2120 additions and 198 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
<https://polyformproject.org/licenses/noncommercial/1.0.0> <https://polyformproject.org/licenses/noncommercial/1.0.0>
Required Notice: Copyright (C) 2026 Rune Olsen (https://oai.pm) Required Notice: Copyright (C) 2026 Rune Olsen (https://confab.no)
## Acceptance ## Acceptance
@@ -143,4 +143,4 @@ advantage or monetary compensation — including selling this
software, or any part of it, standalone or bundled into another software, or any part of it, standalone or bundled into another
product or service — requires a separate commercial license product or service — requires a separate commercial license
from the copyright holder. Contact Rune Olsen via from the copyright holder. Contact Rune Olsen via
<https://oai.pm> to discuss commercial licensing terms. <https://confab.no> to discuss commercial licensing terms.
+1 -1
View File
@@ -81,4 +81,4 @@ If Confab's data handling changes in a meaningful way, this document will be upd
## Contact ## Contact
Questions about this policy: **https://oai.pm/#contact** Questions about this policy: **https://confab.no/#contact**
+2 -2
View File
@@ -333,7 +333,7 @@ Confab is source-available under the **PolyForm Noncommercial License 1.0.0**.
This means you are free to use, study, modify, and share Confab 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. This means you are free to use, study, modify, and share Confab 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.
See [LICENSE](LICENSE) for the full license text, or visit [polyformproject.org/licenses/noncommercial/1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0). For commercial licensing inquiries, contact Rune Olsen via [oai.pm](https://oai.pm). See [LICENSE](LICENSE) for the full license text, or visit [polyformproject.org/licenses/noncommercial/1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0). For commercial licensing inquiries, contact Rune Olsen via [confab.no](https://confab.no).
## Privacy & Security ## Privacy & Security
@@ -346,7 +346,7 @@ See [LICENSE](LICENSE) for the full license text, or visit [polyformproject.org/
**Rune Olsen** **Rune Olsen**
- Website: https://oai.pm - Website: https://confab.no
- Blog: [https://blog.rune.pm](https://blog.rune.pm) - Blog: [https://blog.rune.pm](https://blog.rune.pm)
- Gitlab.pm: [@rune](https://gitlab.pm/rune) - Gitlab.pm: [@rune](https://gitlab.pm/rune)
+1 -1
View File
@@ -8,7 +8,7 @@ Only the latest publicly released version of Confab is supported with security f
If you discover a security vulnerability in Confab, please report it privately rather than opening a public GitHub issue. If you discover a security vulnerability in Confab, please report it privately rather than opening a public GitHub issue.
To report a security concern, use the contact form at **[https://oai.pm/#contact](https://oai.pm/#contact)**. To report a security concern, use the contact form at **[https://confab.no/#contact](https://confab.no/#contact)**.
Please include as much detail as possible: Please include as much detail as possible:
- A description of the vulnerability and its potential impact - A description of the vulnerability and its potential impact
+2 -2
View File
@@ -388,7 +388,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2; MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.5.0; MARKETING_VERSION = 2.5.1;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.Confab; PRODUCT_BUNDLE_IDENTIFIER = com.oai.Confab;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -440,7 +440,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2; MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.5.0; MARKETING_VERSION = 2.5.1;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.Confab; PRODUCT_BUNDLE_IDENTIFIER = com.oai.Confab;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
+6 -6
View File
@@ -8059,36 +8059,36 @@
} }
} }
}, },
"Initialize Repository" : { "Clone Repository" : {
"localizations" : { "localizations" : {
"da" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Initialiser repository" "value" : "Klon repository"
} }
}, },
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Repository initialisieren" "value" : "Repository klonen"
} }
}, },
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Initialiser le dépôt" "value" : "Cloner le dépôt"
} }
}, },
"nb" : { "nb" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Initialiser repositorium" "value" : "Klon repositorium"
} }
}, },
"sv" : { "sv" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Initiera förvar" "value" : "Klona förvar"
} }
} }
} }
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+8 -2
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
@@ -31,6 +31,8 @@ struct Conversation: Identifiable, Codable {
var updatedAt: Date var updatedAt: Date
var primaryModel: String? // Primary model used in this conversation var primaryModel: String? // Primary model used in this conversation
var folderId: UUID? // Folder this conversation is filed under, if any var folderId: UUID? // Folder this conversation is filed under, if any
var notesEnabled: Bool // Whether the per-conversation notes.md feature is on
var notesFilename: String? // Filename under Application Support/oAI/notes/, if notes have ever been created
nonisolated init( nonisolated init(
id: UUID = UUID(), id: UUID = UUID(),
@@ -39,7 +41,9 @@ struct Conversation: Identifiable, Codable {
createdAt: Date = Date(), createdAt: Date = Date(),
updatedAt: Date = Date(), updatedAt: Date = Date(),
primaryModel: String? = nil, primaryModel: String? = nil,
folderId: UUID? = nil folderId: UUID? = nil,
notesEnabled: Bool = false,
notesFilename: String? = nil
) { ) {
self.id = id self.id = id
self.name = name self.name = name
@@ -48,6 +52,8 @@ struct Conversation: Identifiable, Codable {
self.updatedAt = updatedAt self.updatedAt = updatedAt
self.primaryModel = primaryModel self.primaryModel = primaryModel
self.folderId = folderId self.folderId = folderId
self.notesEnabled = notesEnabled
self.notesFilename = notesFilename
} }
var messageCount: Int { var messageCount: Int {
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+5 -2
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
@@ -29,19 +29,22 @@ struct Folder: Identifiable, Codable, Sendable {
var sortOrder: Int var sortOrder: Int
let createdAt: Date let createdAt: Date
var parentId: UUID? var parentId: UUID?
var updatedAt: Date
nonisolated init( nonisolated init(
id: UUID = UUID(), id: UUID = UUID(),
name: String, name: String,
sortOrder: Int = 0, sortOrder: Int = 0,
createdAt: Date = Date(), createdAt: Date = Date(),
parentId: UUID? = nil parentId: UUID? = nil,
updatedAt: Date? = nil
) { ) {
self.id = id self.id = id
self.name = name self.name = name
self.sortOrder = sortOrder self.sortOrder = sortOrder
self.createdAt = createdAt self.createdAt = createdAt
self.parentId = parentId self.parentId = parentId
self.updatedAt = updatedAt ?? createdAt
} }
} }
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+34
View File
@@ -0,0 +1,34 @@
//
// ReleaseNotesRequest.swift
// Confab
//
// Sheet-presentation payload for ReleaseNotesView
//
// 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 Foundation
/// Carries which version's release notes to show atomically to `.sheet(item:)` avoids the
/// two-sequential-@State-mutations race documented for sheets that show existing data.
struct ReleaseNotesRequest: Identifiable {
let id = UUID()
/// Git tag form, e.g. "v2.5.0".
let versionTag: String
/// true: opened from the Help menu for the currently-installed version.
/// false: opened from the "update available" alert for a not-yet-installed version.
let isCurrentlyInstalled: Bool
}
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+36 -1
View File
@@ -14,7 +14,7 @@ import Foundation
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
enum SyncAuthMethod: String, CaseIterable, Codable { enum SyncAuthMethod: String, CaseIterable, Codable {
@@ -39,6 +39,7 @@ enum SyncError: LocalizedError {
case repoNotCloned case repoNotCloned
case secretsDetected([String]) case secretsDetected([String])
case parseError(String) case parseError(String)
case syncInProgress
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
@@ -56,6 +57,8 @@ enum SyncError: LocalizedError {
return "Secrets detected in conversations: \(secrets.joined(separator: ", ")). Remove before syncing." return "Secrets detected in conversations: \(secrets.joined(separator: ", ")). Remove before syncing."
case .parseError(let message): case .parseError(let message):
return "Failed to parse conversation: \(message)" return "Failed to parse conversation: \(message)"
case .syncInProgress:
return "A sync is already in progress. Try again in a moment."
} }
} }
} }
@@ -68,6 +71,38 @@ struct SyncStatus: Equatable {
var remoteStatus: String? // "up-to-date", "ahead 3", "behind 2", etc. var remoteStatus: String? // "up-to-date", "ahead 3", "behind 2", etc.
} }
/// Serialized as `folders.json` at the sync repo root. Unlike conversation exports, this is a
/// manifest meant to be machine-written/read only (the repo's README already warns against manual
/// edits), so plain JSON is used rather than the hand-rolled markdown format no need for that
/// format's human-readability tradeoffs here.
nonisolated struct FolderSyncManifest: Codable {
nonisolated struct FolderEntry: Codable {
let id: String
let name: String
let parentId: String?
let createdAt: Date
let updatedAt: Date
}
var folders: [FolderEntry]
/// conversationId -> folderId. Only present for conversations actually filed in a folder.
var assignments: [String: String]
}
/// Serialized as `notes.json` at the sync repo root, alongside a `notes/` directory holding the
/// raw note file content (same format as `~/Library/Application Support/oAI/notes/`, see
/// ConversationNotesService). Matching is by conversationId via this manifest, not by parsing the
/// embedded `**ID**:` header inside each note file same approach as `FolderSyncManifest`.
nonisolated struct NotesSyncManifest: Codable {
nonisolated struct Entry: Codable {
let filename: String
let enabled: Bool
}
/// conversationId -> notes entry. Only present for conversations that have ever had notes.
var notes: [String: Entry]
}
nonisolated struct ConversationExport { nonisolated struct ConversationExport {
let id: String let id: String
let name: String let name: String
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
@@ -285,6 +285,18 @@
<dd>Enable/disable write permissions</dd> <dd>Enable/disable write permissions</dd>
</dl> </dl>
<h3>Conversation Notes Commands</h3>
<dl class="commands">
<dt>/notes on</dt>
<dd>Enable a persistent notes.md file for this conversation. The AI reads it automatically every turn and can update it on its own, with no per-write approval — turning it on is the only consent step</dd>
<dt>/notes off</dt>
<dd>Disable automatic reading/writing of this conversation's notes (the file itself is kept)</dd>
<dt>/notes show</dt>
<dd>Display this conversation's current notes in the chat</dd>
</dl>
<h3>Shortcuts &amp; Skills Commands</h3> <h3>Shortcuts &amp; Skills Commands</h3>
<dl class="commands"> <dl class="commands">
<dt>/shortcuts</dt> <dt>/shortcuts</dt>
@@ -704,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
@@ -761,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>
@@ -1615,7 +1641,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
<li>Syncing happens automatically after every explicit save — no separate auto-save toggle needed. See <a href="#unsaved-changes">Unsaved Changes &amp; Crash Recovery</a>.</li> <li>Syncing happens automatically after every explicit save — no separate auto-save toggle needed. See <a href="#unsaved-changes">Unsaved Changes &amp; Crash Recovery</a>.</li>
<li><strong>Manual Sync</strong>: <li><strong>Manual Sync</strong>:
<ul> <ul>
<li><strong>Initialize Repository</strong> - Clone repository for first-time setup</li> <li><strong>Clone Repository</strong> - Clone repository for first-time setup</li>
<li><strong>Sync Now</strong> - Full sync (export + pull + import + push)</li> <li><strong>Sync Now</strong> - Full sync (export + pull + import + push)</li>
</ul> </ul>
</li> </li>
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
import os import os
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Contacts import Contacts
+1 -1
View File
@@ -19,7 +19,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import AppKit import AppKit
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+107
View File
@@ -0,0 +1,107 @@
//
// ConversationNotesService.swift
// Confab
//
// Manages per-conversation notes.md files in Application Support/oAI/notes/
//
// 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 Foundation
import AppKit
/// Stores each conversation's notes.md as a single file under
/// `~/Library/Application Support/oAI/notes/`. The filename is a human-readable
/// courtesy for anyone browsing in Finder; the conversation's UUID is embedded in the
/// file content itself (a `**ID**:` header, same convention as `ConversationExport`) so
/// identity never depends on the filename surviving a conversation rename.
///
/// All operations are best-effort a missing or unreadable file is never an error,
/// since notes files are explicitly meant to tolerate being renamed, edited, or
/// deleted by hand outside the app.
nonisolated final class ConversationNotesService {
static let shared = ConversationNotesService()
private let baseDirectory: URL = {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory,
in: .userDomainMask).first!
return appSupport.appendingPathComponent("oAI/notes", isDirectory: true)
}()
private func ensureDirectory() {
try? FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true)
}
/// A human-readable filename derived from the conversation's name, with a short
/// ID suffix so two same-named conversations never collide.
func makeFilename(conversationName: String, conversationId: UUID) -> String {
let base = conversationName.sanitizedForFilename().nonEmptyOrNil ?? "Untitled"
let suffix = conversationId.uuidString.prefix(4).lowercased()
return "\(base)-\(suffix).md"
}
/// Returns the notes body (with the embedded ID header stripped), or nil if the
/// file doesn't exist or can't be read.
func readBody(filename: String) -> String? {
let url = baseDirectory.appendingPathComponent(filename)
guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil }
return Self.stripIDHeader(from: content)
}
/// Returns the file's exact on-disk content, ID header included used by GitSyncService to
/// export the note byte-for-byte without needing to know the header format.
func readRaw(filename: String) -> String? {
let url = baseDirectory.appendingPathComponent(filename)
return try? String(contentsOf: url, encoding: .utf8)
}
/// Writes content exactly as given, with no header wrapping used by GitSyncService to import
/// a pulled note file byte-for-byte (it already carries its own embedded ID header).
func writeRaw(content: String, filename: String) {
ensureDirectory()
let url = baseDirectory.appendingPathComponent(filename)
try? content.write(to: url, atomically: true, encoding: .utf8)
}
/// Writes the full notes body, prefixed with the conversation's embedded ID header.
func write(body: String, filename: String, conversationId: UUID) {
ensureDirectory()
let content = "**ID**: `\(conversationId.uuidString)`\n\n\(body)"
let url = baseDirectory.appendingPathComponent(filename)
try? content.write(to: url, atomically: true, encoding: .utf8)
}
func delete(filename: String) {
let url = baseDirectory.appendingPathComponent(filename)
try? FileManager.default.removeItem(at: url)
}
/// Opens the notes folder in Finder (Settings Advanced "Open Notes Folder").
func openNotesFolder() {
ensureDirectory()
NSWorkspace.shared.open(baseDirectory)
}
nonisolated static func stripIDHeader(from content: String) -> String {
guard content.hasPrefix("**ID**: `") else { return content }
var lines = content.components(separatedBy: "\n").dropFirst()
if lines.first?.isEmpty == true {
lines = lines.dropFirst()
}
return lines.joined(separator: "\n")
}
}
+113 -15
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
@@ -36,6 +36,8 @@ struct ConversationRecord: Codable, FetchableRecord, PersistableRecord, Sendable
var updatedAt: String var updatedAt: String
var primaryModel: String? var primaryModel: String?
var folderId: String? var folderId: String?
var notesEnabled: Bool = false
var notesFilename: String?
} }
struct FolderRecord: Codable, FetchableRecord, PersistableRecord, Sendable { struct FolderRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
@@ -46,6 +48,7 @@ struct FolderRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
var sortOrder: Int var sortOrder: Int
var createdAt: String var createdAt: String
var parentId: String? var parentId: String?
var updatedAt: String?
} }
struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable { struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
@@ -381,6 +384,26 @@ final class DatabaseService: Sendable {
try db.create(index: "idx_folders_parentId", on: "folders", columns: ["parentId"]) try db.create(index: "idx_folders_parentId", on: "folders", columns: ["parentId"])
} }
migrator.registerMigration("v11") { db in
// Needed to resolve folder renames/reparents last-write-wins when Git Sync brings in
// folder state from another machine without a timestamp there's no way to tell whose
// version of a rename is newer. Backfilled from createdAt for existing rows.
try db.alter(table: "folders") { t in
t.add(column: "updatedAt", .text)
}
try db.execute(sql: "UPDATE folders SET updatedAt = createdAt WHERE updatedAt IS NULL")
}
migrator.registerMigration("v12") { db in
// Per-conversation notes.md: opt-in persistent memory file, auto-read/written by the
// model. notesFilename is a local lookup pointer only the file's embedded **ID**
// line is the actual source of truth (see ConversationNotesService).
try db.alter(table: "conversations") { t in
t.add(column: "notesEnabled", .boolean).notNull().defaults(to: false)
t.add(column: "notesFilename", .text)
}
}
return migrator return migrator
} }
@@ -393,6 +416,12 @@ final class DatabaseService: Sendable {
} }
} }
nonisolated func getSetting(key: String) -> String? {
try? dbQueue.read { db in
try SettingRecord.fetchOne(db, key: key)?.value
}
}
nonisolated func setSetting(key: String, value: String) { nonisolated func setSetting(key: String, value: String) {
try? dbQueue.write { db in try? dbQueue.write { db in
let record = SettingRecord(key: key, value: value) let record = SettingRecord(key: key, value: value)
@@ -454,7 +483,9 @@ final class DatabaseService: Sendable {
createdAt: nowString, createdAt: nowString,
updatedAt: nowString, updatedAt: nowString,
primaryModel: primaryModel, primaryModel: primaryModel,
folderId: folderId?.uuidString folderId: folderId?.uuidString,
notesEnabled: false,
notesFilename: nil
) )
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
@@ -568,7 +599,9 @@ final class DatabaseService: Sendable {
createdAt: createdAt, createdAt: createdAt,
updatedAt: updatedAt, updatedAt: updatedAt,
primaryModel: convRecord.primaryModel, primaryModel: convRecord.primaryModel,
folderId: convRecord.folderId.flatMap { UUID(uuidString: $0) } folderId: convRecord.folderId.flatMap { UUID(uuidString: $0) },
notesEnabled: convRecord.notesEnabled,
notesFilename: convRecord.notesFilename
) )
return (conversation, messages) return (conversation, messages)
@@ -611,7 +644,9 @@ final class DatabaseService: Sendable {
createdAt: createdAt, createdAt: createdAt,
updatedAt: lastDate, updatedAt: lastDate,
primaryModel: primaryModel, primaryModel: primaryModel,
folderId: record.folderId.flatMap { UUID(uuidString: $0) } folderId: record.folderId.flatMap { UUID(uuidString: $0) },
notesEnabled: record.notesEnabled,
notesFilename: record.notesFilename
) )
conv.updatedAt = lastDate conv.updatedAt = lastDate
return conv return conv
@@ -632,7 +667,8 @@ final class DatabaseService: Sendable {
name: folder.name, name: folder.name,
sortOrder: folder.sortOrder, sortOrder: folder.sortOrder,
createdAt: Self.isoString(from: folder.createdAt), createdAt: Self.isoString(from: folder.createdAt),
parentId: parentId?.uuidString parentId: parentId?.uuidString,
updatedAt: Self.isoString(from: folder.updatedAt)
) )
try dbQueue.write { db in try dbQueue.write { db in
try record.insert(db) try record.insert(db)
@@ -651,8 +687,8 @@ final class DatabaseService: Sendable {
nonisolated func renameFolder(id: UUID, name: String) throws { nonisolated func renameFolder(id: UUID, name: String) throws {
try dbQueue.write { db in try dbQueue.write { db in
try db.execute( try db.execute(
sql: "UPDATE folders SET name = ? WHERE id = ?", sql: "UPDATE folders SET name = ?, updatedAt = ? WHERE id = ?",
arguments: [name, id.uuidString] arguments: [name, Self.isoString(from: Date()), id.uuidString]
) )
} }
} }
@@ -668,8 +704,38 @@ final class DatabaseService: Sendable {
throw FolderError.wouldCreateCycle throw FolderError.wouldCreateCycle
} }
} }
try db.execute(sql: "UPDATE folders SET parentId = ? WHERE id = ?", try db.execute(sql: "UPDATE folders SET parentId = ?, updatedAt = ? WHERE id = ?",
arguments: [parentId?.uuidString, id.uuidString]) arguments: [parentId?.uuidString, Self.isoString(from: Date()), id.uuidString])
}
}
/// Creates or updates a folder with an externally-supplied identity and timestamps, for Git
/// Sync import as opposed to `createFolder`, which is for user-initiated creation and always
/// mints a fresh id/timestamps. Last-write-wins: if the folder already exists locally, only
/// overwrites name/parentId when `updatedAt` is strictly newer than the local row's.
nonisolated func upsertSyncedFolder(id: UUID, name: String, parentId: UUID?, createdAt: Date, updatedAt: Date) throws {
let existing = try dbQueue.read { db in
try FolderRecord.fetchOne(db, key: id.uuidString)
}
if let existing {
guard let existingUpdatedAt = existing.updatedAt.flatMap(Self.isoDate(from:)),
existingUpdatedAt < updatedAt
else { return }
try dbQueue.write { db in
try db.execute(
sql: "UPDATE folders SET name = ?, parentId = ?, updatedAt = ? WHERE id = ?",
arguments: [name, parentId?.uuidString, Self.isoString(from: updatedAt), id.uuidString]
)
}
} else {
let record = FolderRecord(
id: id.uuidString, name: name, sortOrder: try nextFolderSortOrder(),
createdAt: Self.isoString(from: createdAt), parentId: parentId?.uuidString,
updatedAt: Self.isoString(from: updatedAt)
)
try dbQueue.write { db in
try record.insert(db)
}
} }
} }
@@ -702,9 +768,12 @@ final class DatabaseService: Sendable {
guard let id = UUID(uuidString: record.id), guard let id = UUID(uuidString: record.id),
let createdAt = Self.isoDate(from: record.createdAt) let createdAt = Self.isoDate(from: record.createdAt)
else { return nil } else { return nil }
// Falls back to createdAt if updatedAt is somehow missing (shouldn't happen post-v11
// migration, which backfills every existing row) rather than failing the whole fetch.
let updatedAt = record.updatedAt.flatMap(Self.isoDate(from:)) ?? createdAt
return Folder( return Folder(
id: id, name: record.name, sortOrder: record.sortOrder, createdAt: createdAt, id: id, name: record.name, sortOrder: record.sortOrder, createdAt: createdAt,
parentId: record.parentId.flatMap { UUID(uuidString: $0) } parentId: record.parentId.flatMap { UUID(uuidString: $0) }, updatedAt: updatedAt
) )
} }
@@ -717,6 +786,24 @@ final class DatabaseService: Sendable {
} }
} }
nonisolated func setNotesEnabled(id: UUID, enabled: Bool) throws {
try dbQueue.write { db in
try db.execute(
sql: "UPDATE conversations SET notesEnabled = ? WHERE id = ?",
arguments: [enabled, id.uuidString]
)
}
}
nonisolated func setNotesFilename(id: UUID, filename: String?) throws {
try dbQueue.write { db in
try db.execute(
sql: "UPDATE conversations SET notesFilename = ? WHERE id = ?",
arguments: [filename, id.uuidString]
)
}
}
// MARK: - Usage Statistics // MARK: - Usage Statistics
nonisolated func getOverallUsageStats() throws -> UsageStats { nonisolated func getOverallUsageStats() throws -> UsageStats {
@@ -831,22 +918,33 @@ final class DatabaseService: Sendable {
nonisolated func deleteConversation(id: UUID) throws -> Bool { nonisolated func deleteConversation(id: UUID) throws -> Bool {
Log.db.info("Deleting conversation \(id.uuidString)") Log.db.info("Deleting conversation \(id.uuidString)")
return try dbQueue.write { db in let result = try dbQueue.write { db -> (Bool, String?) in
let notesFilename = try ConversationRecord.fetchOne(db, key: id.uuidString)?.notesFilename
try MessageRecord.filter(Column("conversationId") == id.uuidString).deleteAll(db) try MessageRecord.filter(Column("conversationId") == id.uuidString).deleteAll(db)
return try ConversationRecord.deleteOne(db, key: id.uuidString) let deleted = try ConversationRecord.deleteOne(db, key: id.uuidString)
return (deleted, notesFilename)
} }
if let notesFilename = result.1 {
ConversationNotesService.shared.delete(filename: notesFilename)
}
return result.0
} }
nonisolated func deleteConversation(name: String) throws -> Bool { nonisolated func deleteConversation(name: String) throws -> Bool {
try dbQueue.write { db in let result = try dbQueue.write { db -> (Bool, String?) in
guard let record = try ConversationRecord guard let record = try ConversationRecord
.filter(Column("name") == name) .filter(Column("name") == name)
.fetchOne(db) .fetchOne(db)
else { return false } else { return (false, nil) }
try MessageRecord.filter(Column("conversationId") == record.id).deleteAll(db) try MessageRecord.filter(Column("conversationId") == record.id).deleteAll(db)
return try ConversationRecord.deleteOne(db, key: record.id) let deleted = try ConversationRecord.deleteOne(db, key: record.id)
return (deleted, record.notesFilename)
} }
if let notesFilename = result.1 {
ConversationNotesService.shared.delete(filename: notesFilename)
}
return result.0
} }
nonisolated func updateConversation(id: UUID, name: String?, messages: [Message]?) throws -> Bool { nonisolated func updateConversation(id: UUID, name: String?, messages: [Message]?) throws -> Bool {
+1 -1
View File
@@ -19,7 +19,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -19,7 +19,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -19,7 +19,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import EventKit import EventKit
+438 -44
View File
@@ -14,7 +14,7 @@ import Foundation
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import os import os
@@ -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)
@@ -65,7 +76,7 @@ class GitSyncService {
} }
log.info("Cloning repository from \(self.settings.syncRepoURL)") log.info("Cloning repository from \(self.settings.syncRepoURL)")
_ = try await runGit(["clone", url, localPath]) _ = try await runGit(["clone", url, localPath], timeout: 120)
syncStatus.isCloned = true syncStatus.isCloned = true
// Import immediately so this machine's DB is never left empty after a clone // Import immediately so this machine's DB is never left empty after a clone
@@ -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()
@@ -196,9 +279,72 @@ class GitSyncService {
log.info("Removed orphaned export for deleted conversation: \(filename)") log.info("Removed orphaned export for deleted conversation: \(filename)")
} }
// Export the folder tree + conversationfolder assignments alongside the conversations
// themselves, so a fresh machine's import can restore folder structure too. See
// upsertSyncedFolder/orphanedLocalFolderIds for how the import side consumes this.
let allFolders = try db.listFolders()
let manifest = FolderSyncManifest(
folders: allFolders.map {
FolderSyncManifest.FolderEntry(
id: $0.id.uuidString, name: $0.name, parentId: $0.parentId?.uuidString,
createdAt: $0.createdAt, updatedAt: $0.updatedAt
)
},
assignments: Dictionary(uniqueKeysWithValues: conversations.compactMap { conv in
conv.folderId.map { (conv.id.uuidString, $0.uuidString) }
})
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let manifestData = try encoder.encode(manifest)
try manifestData.write(to: URL(fileURLWithPath: localPath + "/folders.json"))
log.debug("Exported folders.json (\(allFolders.count) folders)")
// Export per-conversation notes (see ConversationNotesService), same manifest + directory
// shape as folders.json: notes/<filename> holds the raw file content byte-for-byte (its
// embedded **ID** header included), notes.json maps conversationId -> {filename, enabled}
// so import can match without parsing file content.
let notesDir = localPath + "/notes"
try FileManager.default.createDirectory(atPath: notesDir, withIntermediateDirectories: true)
var notesEntries: [String: NotesSyncManifest.Entry] = [:]
for conversation in conversations {
guard let filename = conversation.notesFilename,
let content = ConversationNotesService.shared.readRaw(filename: filename)
else { continue }
try content.write(toFile: notesDir + "/" + filename, atomically: true, encoding: .utf8)
notesEntries[conversation.id.uuidString] = NotesSyncManifest.Entry(filename: filename, enabled: conversation.notesEnabled)
}
let notesManifest = NotesSyncManifest(notes: notesEntries)
let notesManifestData = try encoder.encode(notesManifest)
try notesManifestData.write(to: URL(fileURLWithPath: localPath + "/notes.json"))
log.debug("Exported notes.json (\(notesEntries.count) notes)")
// Remove sync-repo note files for conversations that no longer exist locally same
// orphan cleanup and empty-state safety guard as orphanedExportFilenames above.
let existingNoteFiles = (try? FileManager.default.contentsOfDirectory(atPath: notesDir)) ?? []
let currentNoteFilenames = Set(notesEntries.values.map { $0.filename })
for filename in Self.orphanedNoteFilenames(currentFilenames: currentNoteFilenames, existingFiles: existingNoteFiles) {
try? FileManager.default.removeItem(atPath: notesDir + "/" + filename)
log.info("Removed orphaned note file: \(filename)")
}
await updateStatus() await updateStatus()
} }
/// Given the note filenames currently referenced by local conversations and the filenames
/// found on disk in the sync repo's notes directory, returns the filenames safe to delete
/// because no local conversation references them anymore (conversation deleted, or its notes
/// file was replaced). Same empty-state guard as orphanedExportFilenames/orphanedLocalFolderIds
/// an empty currentFilenames set is indistinguishable from "haven't loaded local
/// conversations yet" (e.g. right after a fresh clone), so treating it as "every note file was
/// orphaned" would repeat the exact class of mass-deletion bug that hit conversation sync.
nonisolated static func orphanedNoteFilenames(currentFilenames: Set<String>, existingFiles: [String]) -> [String] {
guard !currentFilenames.isEmpty else { return [] }
return existingFiles.filter { $0.hasSuffix(".md") && !currentFilenames.contains($0) }
}
/// Given the current conversation IDs and the (filename, markdown content) pairs found in /// Given the current conversation IDs and the (filename, markdown content) pairs found in
/// the sync repo's conversations directory, returns the filenames whose export ID doesn't /// the sync repo's conversations directory, returns the filenames whose export ID doesn't
/// match any current conversation i.e. files safe to delete because their conversation /// match any current conversation i.e. files safe to delete because their conversation
@@ -221,6 +367,61 @@ class GitSyncService {
} }
} }
/// Given the folder ids present in a just-pulled `folders.json` manifest and the folder ids
/// that exist locally, returns the local ids that should be deleted (folder was removed
/// upstream since the last sync). Same empty-manifest safety guard as
/// `orphanedExportFilenames` an empty manifest is indistinguishable from "haven't imported
/// folders.json yet" (e.g. an older sync repo with no manifest at all, or a fresh clone before
/// the first export), so treating it as "delete every local folder" would be exactly the same
/// class of mass-deletion bug that hit conversation sync.
nonisolated static func orphanedLocalFolderIds(
manifestFolderIds: Set<String>,
localFolderIds: Set<String>
) -> [String] {
guard !manifestFolderIds.isEmpty else { return [] }
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()
@@ -233,6 +434,49 @@ class GitSyncService {
return (0, 0, 0) return (0, 0, 0)
} }
// Import the folder tree + assignments before any conversation, so a brand-new
// conversation created below can immediately reference a folder that already exists
// locally. Missing/unparsable folders.json (older sync repos, or a fresh clone before the
// first export) is treated as "no folders to import," not an error.
var folderAssignments: [String: String] = [:]
let manifestPath = localPath + "/folders.json"
if let manifestData = try? Data(contentsOf: URL(fileURLWithPath: manifestPath)) {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
if let manifest = try? decoder.decode(FolderSyncManifest.self, from: manifestData) {
folderAssignments = manifest.assignments
// Insert parents before children so parentId's foreign key reference (folders.
// parentId references folders(id)) is always satisfied.
let manifestFolders = manifest.folders.compactMap { entry -> Folder? in
guard let id = UUID(uuidString: entry.id) else { return nil }
return Folder(
id: id, name: entry.name, createdAt: entry.createdAt,
parentId: entry.parentId.flatMap { UUID(uuidString: $0) },
updatedAt: entry.updatedAt
)
}
for (folder, _) in Folder.orderedTree(from: manifestFolders) {
try? db.upsertSyncedFolder(
id: folder.id, name: folder.name, parentId: folder.parentId,
createdAt: folder.createdAt, updatedAt: folder.updatedAt
)
}
// Delete local folders no longer present upstream reparents their contents up
// one level via the existing deleteFolder semantics.
let manifestFolderIds = Set(manifest.folders.map { $0.id })
let localFolderIds = Set((try? db.listFolders())?.map { $0.id.uuidString } ?? [])
for idString in Self.orphanedLocalFolderIds(manifestFolderIds: manifestFolderIds, localFolderIds: localFolderIds) {
if let id = UUID(uuidString: idString) {
try? db.deleteFolder(id: id)
log.info("Removed local folder no longer present in sync repo: \(idString)")
}
}
log.debug("Imported folders.json (\(manifest.folders.count) folders)")
}
}
let files = try FileManager.default.contentsOfDirectory(atPath: conversationsDir) let files = try FileManager.default.contentsOfDirectory(atPath: conversationsDir)
let mdFiles = files.filter { $0.hasSuffix(".md") } let mdFiles = files.filter { $0.hasSuffix(".md") }
@@ -254,8 +498,20 @@ class GitSyncService {
// Check if conversation already exists (by ID) // Check if conversation already exists (by ID)
if let existingId = UUID(uuidString: export.id) { if let existingId = UUID(uuidString: export.id) {
if (try? db.loadConversation(id: existingId)) != nil { if let (existingConversation, _) = try? db.loadConversation(id: existingId) {
// Already exists - skip // Already exists - skip re-importing its content, but still backfill a
// folder assignment if the manifest has one and this conversation isn't
// filed anywhere locally yet. Without this, a conversation that was synced
// to this machine before folder sync existed (or before it was ever put in
// a folder on any machine) would never get filed here every conversation
// in a multi-machine setup already exists locally by the time folders.json
// starts carrying assignments, so this isn't an edge case, it's the normal
// case. Never overwrites an existing local folderId, so a conversation
// already filed (by this machine or a prior import) isn't silently moved.
if existingConversation.folderId == nil,
let assignedFolderId = folderAssignments[export.id].flatMap(UUID.init) {
try? db.moveConversation(id: existingId, toFolder: assignedFolderId)
}
log.debug("Skipping existing conversation: \(export.name)") log.debug("Skipping existing conversation: \(export.name)")
skipped += 1 skipped += 1
continue continue
@@ -282,13 +538,18 @@ class GitSyncService {
) )
} }
// Import to database with primaryModel // Import to database with primaryModel, plus its folder assignment (if any) from
// folders.json only applies here at first-import; an existing local conversation
// that's later moved to a different folder on another machine doesn't get updated,
// matching how its content/name aren't updated either once already imported.
let conversationId = UUID(uuidString: export.id) ?? UUID() let conversationId = UUID(uuidString: export.id) ?? UUID()
let folderId = folderAssignments[export.id].flatMap { UUID(uuidString: $0) }
_ = try db.saveConversation( _ = try db.saveConversation(
id: conversationId, id: conversationId,
name: export.name, name: export.name,
messages: messages, messages: messages,
primaryModel: export.primaryModel primaryModel: export.primaryModel,
folderId: folderId
) )
log.info("Imported: \(export.name)") log.info("Imported: \(export.name)")
imported += 1 imported += 1
@@ -299,6 +560,40 @@ class GitSyncService {
} }
} }
// Import per-conversation notes (see ConversationNotesService) runs after the
// conversations loop above so a conversation created in this same import pass already
// exists locally by the time we try to match notes.json against it. Missing/unparsable
// notes.json (older sync repos, or a fresh clone before the first export) is treated as
// "no notes to import," not an error.
let notesManifestPath = localPath + "/notes.json"
if let notesManifestData = try? Data(contentsOf: URL(fileURLWithPath: notesManifestPath)),
let notesManifest = try? JSONDecoder().decode(NotesSyncManifest.self, from: notesManifestData) {
var notesImported = 0
for (conversationIdString, entry) in notesManifest.notes {
guard let conversationId = UUID(uuidString: conversationIdString),
let (existingConversation, _) = try? db.loadConversation(id: conversationId)
else { continue }
// Never overwrite notes the user has already touched locally same
// never-clobber-existing-content philosophy as skipping already-imported
// conversation content, and the folder-assignment backfill-only-if-unset above.
guard existingConversation.notesFilename == nil, !existingConversation.notesEnabled else {
continue
}
guard let content = try? String(contentsOfFile: localPath + "/notes/" + entry.filename, encoding: .utf8) else {
continue
}
ConversationNotesService.shared.writeRaw(content: content, filename: entry.filename)
try? db.setNotesFilename(id: conversationId, filename: entry.filename)
try? db.setNotesEnabled(id: conversationId, enabled: entry.enabled)
notesImported += 1
}
if notesImported > 0 {
log.info("Imported notes for \(notesImported) conversations")
}
}
log.info("Import complete: \(imported) imported, \(skipped) skipped, \(errors) errors") log.info("Import complete: \(imported) imported, \(skipped) skipped, \(errors) errors")
return (imported, skipped, errors) return (imported, skipped, errors)
} }
@@ -337,23 +632,34 @@ class GitSyncService {
### Export (Automatic) ### Export (Automatic)
- Confab saves conversations to its local database - Confab saves conversations to its local database
- Auto-sync exports conversations to `conversations/*.md` - Auto-sync exports conversations to `conversations/*.md`
- Your folder structure (if you organize conversations into folders) is exported to `folders.json`
- Per-conversation notes (if you've turned on `/notes on` for a conversation) are exported to `notes/*.md`, tracked in `notes.json`
- Files are committed and pushed to this git repository - Files are committed and pushed to this git repository
### Import (On New Machine) ### Import (On New Machine)
- Clone this repository on a new machine - Clone this repository on a new machine
- Confab imports markdown files into its database - Confab imports markdown files, folders.json, and notes.json into its database
- Your conversation history is restored - Your conversation history, folder structure, and conversation notes are restored
### Sync Across Machines ### Sync Across Machines
- Machine A: Chat Auto-save Export Push to git - Machine A: Chat Auto-save Export Push to git
- Machine B: Pull from git Auto-import Database updated - Machine B: Pull from git Auto-import Database updated
- Conversations stay in sync across all machines - Conversations stay in sync across all machines
- Folder renames/moves also sync between machines; a conversation's folder is only set
the first time it's imported onto a new machine
- Conversation notes sync the same way; a conversation's notes are only adopted the first
time it's imported onto a new machine if that machine already has its own notes for the
same conversation, they're left alone rather than overwritten
## File Structure ## File Structure
``` ```
/ /
README.md # This file README.md # This file
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/ # Your conversations conversations/ # Your conversations
conversation-1.md conversation-1.md
conversation-2.md conversation-2.md
@@ -447,6 +753,20 @@ class GitSyncService {
return return
} }
// Guard against racing autoSync()/syncNow() on the same working tree this method and
// autoSync() are both fired from independent, uncoordinated Tasks (this one at app launch,
// autoSync() debounced off chat activity), so without this a pull here could run while
// autoSync() is mid-export, leaving a freshly-written untracked file (folders.json,
// notes.json) that the pull then refuses to merge over: "untracked working tree files
// would be overwritten by merge." Skipping outright (not waiting) is fine here since
// startup sync is a one-time best-effort fetch, not something the user is blocked on.
guard !isSyncing else {
log.debug("Skipping startup sync (another sync already in progress)")
return
}
isSyncing = true
defer { isSyncing = false }
log.info("Running startup sync (pull + import)...") log.info("Running startup sync (pull + import)...")
do { do {
@@ -485,6 +805,12 @@ class GitSyncService {
// Schedule new sync with 5 second delay // Schedule new sync with 5 second delay
pendingSyncTask = Task { pendingSyncTask = Task {
// Tracks whether *this* task is the one holding isSyncing, so the catch block below
// only ever releases a lock it actually acquired without this, a cancellation while
// still waiting in the loop below (i.e. before this task owns the lock at all) would
// incorrectly clear isSyncing out from under whichever other sync is still running.
var acquiredLock = false
do { do {
// Wait for debounce period // Wait for debounce period
try await Task.sleep(for: .seconds(5)) try await Task.sleep(for: .seconds(5))
@@ -492,11 +818,23 @@ class GitSyncService {
// Check if cancelled during sleep // Check if cancelled during sleep
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
// Wait for any other sync (startup pull, manual Sync Now) already in flight to
// finish rather than racing it on the same working tree see syncOnStartup()'s
// guard for what goes wrong otherwise. Waiting (not skipping) here, since
// auto-sync is how local changes actually reach the remote; silently dropping this
// round could leave a push pending indefinitely if nothing else triggers autoSync
// again soon.
while await MainActor.run(body: { isSyncing }) {
guard !Task.isCancelled else { return }
try await Task.sleep(for: .milliseconds(500))
}
// Set syncing state // Set syncing state
await MainActor.run { await MainActor.run {
isSyncing = true isSyncing = true
lastSyncError = nil lastSyncError = nil
} }
acquiredLock = true
log.info("Auto-sync starting (export + push)...") log.info("Auto-sync starting (export + push)...")
@@ -511,14 +849,17 @@ class GitSyncService {
isSyncing = false isSyncing = false
syncStatus.lastSyncTime = Date() syncStatus.lastSyncTime = Date()
} }
acquiredLock = false
log.info("Auto-sync completed successfully") log.info("Auto-sync completed successfully")
} catch { } catch {
// Error // Error only release the lock if this task actually acquired it
await MainActor.run { if acquiredLock {
isSyncing = false await MainActor.run {
lastSyncError = error.localizedDescription isSyncing = false
lastSyncError = error.localizedDescription
}
} }
log.error("Auto-sync failed: \(error.localizedDescription)") log.error("Auto-sync failed: \(error.localizedDescription)")
@@ -529,6 +870,27 @@ class GitSyncService {
await pendingSyncTask?.value await pendingSyncTask?.value
} }
/// Manual full sync (the Settings Sync "Sync Now" button): pull import export push,
/// in that order so the working tree is fully merged before Confab writes its own files back
/// out (see exportAllConversations's ordering note). Throws `.syncInProgress` rather than
/// racing autoSync()/syncOnStartup() if either is already running on the same working tree
/// same class of bug as the "untracked working tree files" failure those two guard against.
func syncNow() async throws -> (imported: Int, skipped: Int) {
guard !isSyncing else {
throw SyncError.syncInProgress
}
isSyncing = true
defer { isSyncing = false }
try await pull()
let result = try await importAllConversations()
try await exportAllConversations()
try await push()
await updateStatus()
return (result.imported, result.skipped)
}
// MARK: - Secret Scanning // MARK: - Secret Scanning
/// Scan for API keys and secrets in conversations /// Scan for API keys and secrets in conversations
@@ -707,36 +1069,70 @@ class GitSyncService {
return url // SSH or other protocol return url // SSH or other protocol
} }
private func runGit(_ args: [String], cwd: String? = nil) async throws -> String { /// Runs off the main thread with a hard timeout, rather than blocking synchronously on
let process = Process() /// `waitUntilExit()` on the (MainActor-isolated, per this project's default actor isolation)
process.executableURL = URL(fileURLWithPath: "/usr/bin/git") /// calling thread. A plain `git fetch`/`pull`/`push` over a connection that died silently
process.arguments = args /// during system sleep can otherwise hang indefinitely with no OS-level timeout of its own
/// since that used to block the main thread, it froze the entire app UI with nothing to show
/// for it in the logs (no error is ever produced by a command that never finishes). Every
/// MainActor-touching value (paths, the logger) is captured *before* dispatching to the
/// background queue the queue's closure must not touch `self` or other MainActor state.
private func runGit(_ args: [String], cwd: String? = nil, timeout: TimeInterval = 30) async throws -> String {
let workingDirectoryURL = cwd.map { URL(fileURLWithPath: expandPath($0)) }
let log = self.log
if let cwd = cwd { return try await withCheckedThrowingContinuation { continuation in
process.currentDirectoryURL = URL(fileURLWithPath: expandPath(cwd)) DispatchQueue.global(qos: .userInitiated).async {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
process.arguments = args
if let workingDirectoryURL {
process.currentDirectoryURL = workingDirectoryURL
}
let outputPipe = Pipe()
let errorPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = errorPipe
var timedOut = false
let timeoutItem = DispatchWorkItem {
if process.isRunning {
timedOut = true
process.terminate()
}
}
DispatchQueue.global().asyncAfter(deadline: .now() + timeout, execute: timeoutItem)
do {
try process.run()
process.waitUntilExit()
} catch {
timeoutItem.cancel()
continuation.resume(throwing: error)
return
}
timeoutItem.cancel()
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: outputData, encoding: .utf8) ?? ""
let error = String(data: errorData, encoding: .utf8) ?? ""
guard process.terminationStatus == 0 else {
let message = timedOut
? "Timed out after \(Int(timeout))s — this can happen if your Mac just woke from sleep and the network hasn't reconnected yet. Try again in a moment."
: (error.isEmpty ? "Unknown error" : error)
log.error("Git command failed: \(args.joined(separator: " "))")
log.error("Error: \(message)")
continuation.resume(throwing: SyncError.gitFailed(message))
return
}
continuation.resume(returning: output)
}
} }
let outputPipe = Pipe()
let errorPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = errorPipe
try process.run()
process.waitUntilExit()
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: outputData, encoding: .utf8) ?? ""
let error = String(data: errorData, encoding: .utf8) ?? ""
guard process.terminationStatus == 0 else {
log.error("Git command failed: \(args.joined(separator: " "))")
log.error("Error: \(error)")
throw SyncError.gitFailed(error.isEmpty ? "Unknown error" : error)
}
return output
} }
private func expandPath(_ path: String) -> String { private func expandPath(_ path: String) -> String {
@@ -751,9 +1147,7 @@ class GitSyncService {
} }
func sanitizeFilename(_ name: String) -> String { func sanitizeFilename(_ name: String) -> String {
// Remove invalid filename characters name.sanitizedForFilename()
let invalid = CharacterSet(charactersIn: "/\\:*?\"<>|")
return name.components(separatedBy: invalid).joined(separator: "-")
} }
static func extractProvider(from url: String) -> String { static func extractProvider(from url: String) -> String {
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import CoreLocation import CoreLocation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+69 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
@@ -27,6 +27,21 @@ import AppKit
#endif #endif
import Observation import Observation
/// A single Gitea release's display name + markdown body, for in-app viewing (Help menu, and the
/// "update available" alert) instead of opening the web releases page.
struct ReleaseNotes: Sendable, Equatable {
let versionTag: String
let title: String
let body: String
}
enum ReleaseNotesError: Error, Sendable {
/// No published release exists for this tag e.g. a development build ahead of the last
/// published version.
case notFound
case networkError
}
@Observable @Observable
final class UpdateCheckService { final class UpdateCheckService {
static let shared = UpdateCheckService() static let shared = UpdateCheckService()
@@ -41,6 +56,7 @@ final class UpdateCheckService {
private let apiURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/latest" private let apiURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/latest"
private let releasesURL = URL(string: "https://gitlab.pm/rune/oai-swift/releases")! private let releasesURL = URL(string: "https://gitlab.pm/rune/oai-swift/releases")!
private let releasesByTagBaseURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/tags/"
private init() {} private init() {}
@@ -125,4 +141,56 @@ final class UpdateCheckService {
NSWorkspace.shared.open(releasesURL) NSWorkspace.shared.open(releasesURL)
#endif #endif
} }
// MARK: - Release Notes
private static let releaseNotesCacheKeyPrefix = "releaseNotesCache_"
/// Fetches a specific release's title + markdown body by git tag (e.g. "v2.5.0"), for showing
/// in-app. Checked against a small on-disk cache first a published release's notes don't
/// change after the fact, so there's no need to hit the network every time the same version's
/// notes are viewed again.
func fetchReleaseNotes(forTag tag: String) async -> Result<ReleaseNotes, ReleaseNotesError> {
if let cached = Self.cachedReleaseNotes(forTag: tag) {
return .success(cached)
}
guard let url = URL(string: releasesByTagBaseURL + tag) else {
return .failure(.networkError)
}
var request = URLRequest(url: url)
request.timeoutInterval = 10
guard let (data, response) = try? await URLSession.shared.data(for: request) else {
return .failure(.networkError)
}
if let http = response as? HTTPURLResponse, http.statusCode == 404 {
return .failure(.notFound)
}
guard let release = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let tagName = release["tag_name"] as? String,
let body = release["body"] as? String else {
return .failure(.notFound)
}
let notes = ReleaseNotes(versionTag: tagName, title: release["name"] as? String ?? tagName, body: body)
Self.cacheReleaseNotes(notes)
return .success(notes)
}
private static func cachedReleaseNotes(forTag tag: String) -> ReleaseNotes? {
guard let json = DatabaseService.shared.getSetting(key: releaseNotesCacheKeyPrefix + tag),
let data = json.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: String],
let versionTag = obj["versionTag"], let title = obj["title"], let body = obj["body"]
else { return nil }
return ReleaseNotes(versionTag: versionTag, title: title, body: body)
}
private static func cacheReleaseNotes(_ notes: ReleaseNotes) {
guard let data = try? JSONSerialization.data(withJSONObject: [
"versionTag": notes.versionTag, "title": notes.title, "body": notes.body,
]), let json = String(data: data, encoding: .utf8) else { return }
DatabaseService.shared.setSetting(key: releaseNotesCacheKeyPrefix + notes.versionTag, value: json)
}
} }
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
@@ -18,12 +18,12 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
extension String { nonisolated extension String {
// MARK: - Command Parsing // MARK: - Command Parsing
var isSlashCommand: Bool { var isSlashCommand: Bool {
@@ -107,4 +107,12 @@ extension String {
let endIndex = index(startIndex, offsetBy: length - trailing.count) let endIndex = index(startIndex, offsetBy: length - trailing.count)
return String(self[..<endIndex]) + trailing return String(self[..<endIndex]) + trailing
} }
// MARK: - Filename Sanitization
/// Replaces characters invalid in filenames (on macOS/most filesystems) with "-".
func sanitizedForFilename() -> String {
let invalid = CharacterSet(charactersIn: "/\\:*?\"<>|")
return components(separatedBy: invalid).joined(separator: "-")
}
} }
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
+164 -8
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import Foundation import Foundation
@@ -136,6 +136,7 @@ class ChatViewModel {
var showHelp: Bool = false var showHelp: Bool = false
var showCredits: Bool = false var showCredits: Bool = false
var showHistory: Bool = false var showHistory: Bool = false
var releaseNotesRequest: ReleaseNotesRequest? = nil
var showShortcuts: Bool = false var showShortcuts: Bool = false
var showSkills: Bool = false var showSkills: Bool = false
var showJarvis: Bool = false var showJarvis: Bool = false
@@ -149,6 +150,10 @@ class ChatViewModel {
var currentConversationName: String? = nil var currentConversationName: String? = nil
private var savedMessageCount: Int = 0 private var savedMessageCount: Int = 0
// Per-conversation notes.md (see ConversationNotesService)
var notesEnabled: Bool = false
var notesFilename: String? = nil
var hasUnsavedChanges: Bool { var hasUnsavedChanges: Bool {
let chatCount = messages.filter { $0.role != .system }.count let chatCount = messages.filter { $0.role != .system }.count
return chatCount > 0 && chatCount != savedMessageCount return chatCount > 0 && chatCount != savedMessageCount
@@ -248,8 +253,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
settings.customPromptMode == .replace, settings.customPromptMode == .replace,
let customPrompt = settings.systemPrompt, let customPrompt = settings.systemPrompt,
!customPrompt.isEmpty { !customPrompt.isEmpty {
// BYOP: Use ONLY the custom prompt // BYOP: use ONLY the custom prompt, but conversation notes are a non-overridable
return customPrompt // instruction they must survive even when the user has replaced everything else.
return customPrompt + Self.buildNotesSection(body: currentNotesBody)
} }
// Otherwise, build the prompt: default + conditional sections + custom (if append mode) // Otherwise, build the prompt: default + conditional sections + custom (if append mode)
@@ -294,9 +300,83 @@ Don't narrate future actions ("Let me...") - just use the tools.
} }
} }
// Append conversation notes (see ConversationNotesService). Deliberately outside the
// modelSupportsTools gate above fenced-block writes exist specifically so tool-incapable
// models (e.g. Apple On-Device) can still use this.
prompt += Self.buildNotesSection(body: currentNotesBody)
return prompt return prompt
} }
/// The current conversation's notes body if notes are enabled, nil otherwise. Empty string
/// means notes are on but nothing has been written yet.
private var currentNotesBody: String? {
guard notesEnabled, let filename = notesFilename else { return nil }
return ConversationNotesService.shared.readBody(filename: filename) ?? ""
}
/// Builds the "## Conversation Notes" system prompt section. Pure/testable: nil body means
/// notes are off for this conversation and nothing is appended.
nonisolated static func buildNotesSection(body: String?) -> String {
guard let body else { return "" }
return """
---
## Conversation Notes
You maintain a persistent memory file for this specific conversation, saved outside the chat and re-read at the start of every turn. Use it for durable facts, preferences, or context worth keeping across the whole conversation not a transcript, not every detail.
To update it, include this block anywhere in your reply it is invisible to the user and will not appear in the chat:
```update-notes
<the complete new contents of the notes file>
```
Only include the block when you actually want to change the notes. Each one replaces the previous contents entirely, so include everything worth keeping, not just what changed. If the user directly asks you to add, change, or remove something from the notes, comply using this same mechanism.
Current notes:
\(body.isEmpty ? "(empty — nothing saved yet)" : body)
"""
}
/// Detects a ```update-notes fenced block in a finalized assistant message, strips it from
/// the text that will actually be displayed, and returns its body separately so it can be
/// persisted via ConversationNotesService. Only call this once a message is fully finalized
/// (never on in-flight streaming deltas) stripping mid-stream would flash the block and
/// then remove it.
nonisolated static func extractNotesUpdate(from content: String) -> (display: String, notesBody: String?) {
let pattern = #"```update-notes\s*\n([\s\S]*?)```"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: content, range: NSRange(content.startIndex..., in: content)),
let bodyRange = Range(match.range(at: 1), in: content),
let fullRange = Range(match.range(at: 0), in: content)
else {
return (content, nil)
}
let body = String(content[bodyRange]).trimmingCharacters(in: .whitespacesAndNewlines)
var display = content
display.removeSubrange(fullRange)
display = display.trimmingCharacters(in: .whitespacesAndNewlines)
return (display, body)
}
/// Applies extractNotesUpdate at message finalization: writes any extracted notes body to
/// disk and returns the content with the fenced block stripped. No-op (returns content
/// unchanged) unless notes are enabled and the conversation has an ID and filename.
private func applyNotesUpdateIfNeeded(_ content: String) -> String {
guard notesEnabled, let conversationId = currentConversationId, let filename = notesFilename else {
return content
}
let (display, notesBody) = Self.extractNotesUpdate(from: content)
if let notesBody {
ConversationNotesService.shared.write(body: notesBody, filename: filename, conversationId: conversationId)
}
return display
}
// MARK: - Initialization // MARK: - Initialization
init() { init() {
@@ -349,6 +429,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
currentConversationId = nil currentConversationId = nil
currentConversationName = nil currentConversationName = nil
savedMessageCount = 0 savedMessageCount = 0
notesEnabled = false
notesFilename = nil
} }
/// Re-sync local state from SettingsService (called when Settings sheet dismisses) /// Re-sync local state from SettingsService (called when Settings sheet dismisses)
@@ -498,7 +580,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
private func performLoadConversation(_ conversation: Conversation) { private func performLoadConversation(_ conversation: Conversation) {
do { do {
guard let (_, loadedMessages) = try DatabaseService.shared.loadConversation(id: conversation.id) else { guard let (loadedConversation, loadedMessages) = try DatabaseService.shared.loadConversation(id: conversation.id) else {
showSystemMessage("Could not load conversation '\(conversation.name)'") showSystemMessage("Could not load conversation '\(conversation.name)'")
return return
} }
@@ -512,6 +594,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
currentConversationId = conversation.id currentConversationId = conversation.id
currentConversationName = conversation.name currentConversationName = conversation.name
savedMessageCount = loadedMessages.filter { $0.role != .system }.count savedMessageCount = loadedMessages.filter { $0.role != .system }.count
notesEnabled = loadedConversation.notesEnabled
notesFilename = loadedConversation.notesFilename
// Rebuild session stats from loaded messages // Rebuild session stats from loaded messages
for msg in loadedMessages { for msg in loadedMessages {
@@ -663,6 +747,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
currentConversationId = saved.id currentConversationId = saved.id
currentConversationName = details.name currentConversationName = details.name
savedMessageCount = chatMessages.count savedMessageCount = chatMessages.count
notesEnabled = false
notesFilename = nil
showSystemMessage("Saved as \"\(details.name)\"") showSystemMessage("Saved as \"\(details.name)\"")
Task { await GitSyncService.shared.autoSync() } Task { await GitSyncService.shared.autoSync() }
} catch { } catch {
@@ -793,6 +879,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
currentConversationId = saved.id currentConversationId = saved.id
currentConversationName = name currentConversationName = name
savedMessageCount = chatMessages.count savedMessageCount = chatMessages.count
notesEnabled = false
notesFilename = nil
showSystemMessage("Conversation saved as '\(name)'") showSystemMessage("Conversation saved as '\(name)'")
} catch { } catch {
showSystemMessage("Failed to save: \(error.localizedDescription)") showSystemMessage("Failed to save: \(error.localizedDescription)")
@@ -858,6 +946,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
case "/mcp": case "/mcp":
handleMCPCommand(args: args) handleMCPCommand(args: args)
case "/notes":
handleNotesCommand(args: args)
default: default:
// Check user-defined shortcuts // Check user-defined shortcuts
if let shortcut = settings.userShortcuts.first(where: { $0.command == cmd.lowercased() }) { if let shortcut = settings.userShortcuts.first(where: { $0.command == cmd.lowercased() }) {
@@ -1044,7 +1135,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
let responseTime = Date().timeIntervalSince(startTime) let responseTime = Date().timeIntervalSince(startTime)
if let index = messages.firstIndex(where: { $0.id == messageId }) { if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = response.content messages[index].content = applyNotesUpdateIfNeeded(response.content)
messages[index].isStreaming = false messages[index].isStreaming = false
messages[index].generatedImages = response.generatedImages messages[index].generatedImages = response.generatedImages
messages[index].responseTime = responseTime messages[index].responseTime = responseTime
@@ -1105,7 +1196,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
let responseTime = Date().timeIntervalSince(startTime) let responseTime = Date().timeIntervalSince(startTime)
if let index = messages.firstIndex(where: { $0.id == messageId }) { if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = fullContent messages[index].content = applyNotesUpdateIfNeeded(fullContent)
messages[index].isStreaming = false messages[index].isStreaming = false
messages[index].responseTime = responseTime messages[index].responseTime = responseTime
messages[index].wasInterrupted = wasCancelled messages[index].wasInterrupted = wasCancelled
@@ -1378,6 +1469,67 @@ Don't narrate future actions ("Let me...") - just use the tools.
} }
} }
// MARK: - Notes Command Handling
private func handleNotesCommand(args: [String]) {
guard let sub = args.first?.lowercased() else {
showSystemMessage("Usage: /notes on|off|show")
return
}
switch sub {
case "on":
guard let conversationId = currentConversationId else {
showSystemMessage("Send a message first so this conversation is saved, then try /notes on")
return
}
do {
var filename = notesFilename
if filename == nil {
let newFilename = ConversationNotesService.shared.makeFilename(
conversationName: currentConversationName ?? "Untitled",
conversationId: conversationId
)
ConversationNotesService.shared.write(body: "", filename: newFilename, conversationId: conversationId)
try DatabaseService.shared.setNotesFilename(id: conversationId, filename: newFilename)
filename = newFilename
}
try DatabaseService.shared.setNotesEnabled(id: conversationId, enabled: true)
notesFilename = filename
notesEnabled = true
showSystemMessage("Notes enabled for this conversation")
} catch {
showSystemMessage("Failed to enable notes: \(error.localizedDescription)")
}
case "off":
guard let conversationId = currentConversationId else {
showSystemMessage("No active conversation")
return
}
do {
try DatabaseService.shared.setNotesEnabled(id: conversationId, enabled: false)
notesEnabled = false
showSystemMessage("Notes disabled for this conversation")
} catch {
showSystemMessage("Failed to disable notes: \(error.localizedDescription)")
}
case "show":
guard notesEnabled, let filename = notesFilename,
let body = ConversationNotesService.shared.readBody(filename: filename),
!body.isEmpty
else {
showSystemMessage("No notes yet for this conversation")
return
}
showSystemMessage("📝 Notes:\n\n\(body)")
default:
showSystemMessage("Usage: /notes on|off|show")
}
}
// MARK: - AI Response with Tool Calls // MARK: - AI Response with Tool Calls
// MARK: - Images API Generation // MARK: - Images API Generation
@@ -1702,7 +1854,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
} else { } else {
let assistantMessage = Message( let assistantMessage = Message(
role: .assistant, role: .assistant,
content: finalContent, content: applyNotesUpdateIfNeeded(finalContent),
tokens: totalUsage?.completionTokens, tokens: totalUsage?.completionTokens,
cost: nil, cost: nil,
timestamp: Date(), timestamp: Date(),
@@ -2142,7 +2294,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
alert.messageText = "Restore unsaved conversation?" alert.messageText = "Restore unsaved conversation?"
alert.informativeText = "Confab didn't close properly last time. Would you like to restore the conversation you were working on?" alert.informativeText = "Confab didn't close properly last time. Would you like to restore the conversation you were working on?"
alert.addButton(withTitle: "Restore") alert.addButton(withTitle: "Restore")
alert.addButton(withTitle: "Discard") let discardButton = alert.addButton(withTitle: "Discard")
discardButton.keyEquivalent = "d"
discardButton.keyEquivalentModifierMask = .command
if alert.runModal() == .alertFirstButtonReturn { if alert.runModal() == .alertFirstButtonReturn {
messages = draft.messages messages = draft.messages
@@ -2234,6 +2388,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
currentConversationId = saved.id currentConversationId = saved.id
currentConversationName = details.name currentConversationName = details.name
savedMessageCount = chatMessages.count savedMessageCount = chatMessages.count
notesEnabled = false
notesFilename = nil
showSystemMessage("Saved as \"\(details.name)\"") showSystemMessage("Saved as \"\(details.name)\"")
Task { await GitSyncService.shared.autoSync() } Task { await GitSyncService.shared.autoSync() }
return true return true
+22 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
@@ -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() }
)
}
} }
} }
+11 -2
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
@@ -129,6 +129,9 @@ struct ContentView: View {
chatViewModel.inputText = input chatViewModel.inputText = input
}) })
} }
.sheet(item: $vm.releaseNotesRequest) { request in
ReleaseNotesView(request: request)
}
.alert("Intel Mac Support Ending", isPresented: $showIntelWarning) { .alert("Intel Mac Support Ending", isPresented: $showIntelWarning) {
Button("Got It") { Button("Got It") {
UserDefaults.standard.set(true, forKey: "hasShownIntelWarning") UserDefaults.standard.set(true, forKey: "hasShownIntelWarning")
@@ -146,10 +149,16 @@ struct ContentView: View {
NSWorkspace.shared.open(url) NSWorkspace.shared.open(url)
} }
} }
Button("Release Page") { updateService.openReleasesPage() } if let latest = updateService.latestVersion {
Button("Read Release Notes") {
vm.releaseNotesRequest = ReleaseNotesRequest(versionTag: "v\(latest)", isCurrentlyInstalled: false)
}
}
Button("Later", role: .cancel) { } Button("Later", role: .cancel) { }
.keyboardShortcut(.cancelAction)
} else { } else {
Button("OK", role: .cancel) { } Button("OK", role: .cancel) { }
.keyboardShortcut(.cancelAction)
} }
} message: { } message: {
Text(updateService.manualCheckMessage ?? "") Text(updateService.manualCheckMessage ?? "")
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
+1 -1
View File
@@ -19,7 +19,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
+5 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
@@ -54,6 +54,7 @@ struct InputBar: View {
"/memory on", "/memory off", "/online on", "/online off", "/memory on", "/memory off", "/online on", "/online off",
"/mcp on", "/mcp off", "/mcp status", "/mcp list", "/mcp on", "/mcp off", "/mcp status", "/mcp list",
"/mcp write on", "/mcp write off", "/mcp write on", "/mcp write off",
"/notes on", "/notes off", "/notes show",
"/export md", "/export html", "/export pdf", "/export json", "/export md", "/export html", "/export pdf", "/export json",
] ]
@@ -303,6 +304,9 @@ struct CommandSuggestionsView: View {
("/mcp add", "Add folder for MCP"), ("/mcp add", "Add folder for MCP"),
("/mcp write on", "Enable MCP write permissions"), ("/mcp write on", "Enable MCP write permissions"),
("/mcp write off", "Disable MCP write permissions"), ("/mcp write off", "Disable MCP write permissions"),
("/notes on", "Enable persistent notes for this conversation"),
("/notes off", "Disable persistent notes for this conversation"),
("/notes show", "Show this conversation's notes"),
] ]
static func allCommands() -> [(command: String, description: LocalizedStringKey)] { static func allCommands() -> [(command: String, description: LocalizedStringKey)] {
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
+9 -3
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
@@ -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: {
@@ -231,6 +231,11 @@ struct SidebarView: View {
// conversation list modal live in its own @State refresh ours once it closes. // conversation list modal live in its own @State refresh ours once it closes.
if !isShowing { loadData() } if !isShowing { loadData() }
} }
.onChange(of: chatViewModel.showSettings) { _, isShowing in
// Git Sync (Settings Sync) can import conversations/folders directly into the
// database refresh ours once the sheet closes so they show up without a relaunch.
if !isShowing { loadData() }
}
} }
@ViewBuilder @ViewBuilder
@@ -315,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)
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
+4 -3
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import os import os
@@ -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)
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
@@ -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)
}
}
}
+21 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
@@ -178,6 +178,26 @@ private let helpCategories: [CommandCategory] = [
examples: ["/mcp write on", "/mcp write off"] examples: ["/mcp write on", "/mcp write off"]
), ),
]), ]),
CommandCategory(name: "Conversation Notes", icon: "note.text", commands: [
CommandDetail(
command: "/notes on",
brief: "Enable notes for this conversation",
detail: "Turns on a persistent notes.md file for this specific conversation. Once on, the AI reads it automatically on every turn and can update it on its own — no approval needed per write. This is the only consent step; turning it on is always a deliberate, explicit action.",
examples: ["/notes on"]
),
CommandDetail(
command: "/notes off",
brief: "Disable notes for this conversation",
detail: "Turns off automatic reading and writing of this conversation's notes. The file itself isn't deleted — turning notes back on later picks up where it left off.",
examples: ["/notes off"]
),
CommandDetail(
command: "/notes show",
brief: "Show this conversation's notes",
detail: "Displays the current contents of this conversation's notes file in the chat, without leaving the app. Notes files also live in Settings > Advanced, where you can open the folder directly in Finder.",
examples: ["/notes show"]
),
]),
CommandCategory(name: "Integrations", icon: "server.rack", commands: [ CommandCategory(name: "Integrations", icon: "server.rack", commands: [
CommandDetail( CommandDetail(
command: "/jarvis", command: "/jarvis",
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import os import os
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
+96
View File
@@ -0,0 +1,96 @@
//
// ReleaseNotesView.swift
// Confab
//
// Shows a Gitea release's markdown notes in-app, instead of opening the web releases page
//
// 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 ReleaseNotesView: View {
let request: ReleaseNotesRequest
@Environment(\.dismiss) var dismiss
@State private var notes: ReleaseNotes?
@State private var isLoading = true
@State private var error: ReleaseNotesError?
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Loading release notes...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error {
errorView(error)
} else if let notes {
ScrollView {
MarkdownContentView(content: notes.body, fontSize: 13)
.padding()
}
}
}
.navigationTitle(request.isCurrentlyInstalled ? "Release Notes" : "What's New")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
.frame(minWidth: 520, idealWidth: 600, minHeight: 480, idealHeight: 620)
.task {
await fetchNotes()
}
}
@ViewBuilder
private func errorView(_ error: ReleaseNotesError) -> some View {
VStack(spacing: 12) {
Image(systemName: "doc.text.magnifyingglass")
.font(.system(size: 40))
.foregroundStyle(.secondary)
switch error {
case .notFound:
Text("No release notes are available yet for \(request.versionTag).")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
case .networkError:
Text("Couldn't load release notes. Check your internet connection and try again.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
Button("Retry") {
Task { await fetchNotes() }
}
}
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func fetchNotes() async {
isLoading = true
error = nil
switch await UpdateCheckService.shared.fetchReleaseNotes(forTag: request.versionTag) {
case .success(let fetched):
notes = fetched
case .failure(let fetchError):
error = fetchError
}
isLoading = false
}
}
+53 -21
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
@@ -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],
@@ -1416,6 +1443,25 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4) .padding(.horizontal, 4)
// Conversation Notes
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Conversation Notes")
formSection {
row("Notes Folder") {
Button("Open Notes Folder") {
ConversationNotesService.shared.openNotesFolder()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
Text("Each conversation can keep its own persistent notes.md file, read and written automatically by the AI once turned on with /notes on. Use this to browse or edit notes files directly in Finder.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4)
// Semantic Search // Semantic Search
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
sectionHeader("Semantic Search") sectionHeader("Semantic Search")
@@ -1737,7 +1783,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
} else { } else {
Image(systemName: "arrow.down.circle") Image(systemName: "arrow.down.circle")
} }
Text("Initialize Repository") Text("Clone Repository")
} }
.frame(minWidth: 160) .frame(minWidth: 160)
} }
@@ -3020,27 +3066,13 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
private func syncNow() async { private func syncNow() async {
isSyncing = true isSyncing = true
syncTestResult = nil syncTestResult = "Syncing..."
do { do {
// Step 1: Export all conversations // Orchestration (pull import export push) and the guard against racing
syncTestResult = "Exporting conversations..." // autoSync()/syncOnStartup() on the same working tree both live in GitSyncService now
try await gitSync.exportAllConversations() // see its syncNow() for why.
let result = try await gitSync.syncNow()
// Step 2: Pull from remote
syncTestResult = "Pulling changes..."
try await gitSync.pull()
// Step 3: Import any new conversations
syncTestResult = "Importing conversations..."
let result = try await gitSync.importAllConversations()
// Step 4: Push to remote
syncTestResult = "Pushing changes..."
try await gitSync.push()
// Success
await gitSync.updateStatus()
syncTestResult = "✓ Sync complete: \(result.imported) imported, \(result.skipped) skipped" syncTestResult = "✓ Sync complete: \(result.imported) imported, \(result.skipped) skipped"
} catch { } catch {
syncTestResult = "✗ Sync failed: \(error.localizedDescription)" syncTestResult = "✗ Sync failed: \(error.localizedDescription)"
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
+1 -1
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
+18 -10
View File
@@ -18,7 +18,7 @@
// See the LICENSE file or // See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for // <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune // the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>. // Olsen via <https://confab.no>.
import SwiftUI import SwiftUI
@@ -206,9 +206,13 @@ 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") {
let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
chatViewModel.releaseNotesRequest = ReleaseNotesRequest(versionTag: "v\(current)", isCurrentlyInstalled: true)
}
Button(UpdateCheckService.shared.isCheckingManually ? "Checking…" : "Check for Updates…") { Button(UpdateCheckService.shared.isCheckingManually ? "Checking…" : "Check for Updates…") {
UpdateCheckService.shared.checkForUpdatesManually() UpdateCheckService.shared.checkForUpdatesManually()
} }
@@ -219,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
} }
@@ -104,4 +104,56 @@ struct ChatViewModelPureLogicTests {
func draftFingerprintEmptyIsStable() { func draftFingerprintEmptyIsStable() {
#expect(ChatViewModel.draftFingerprint(for: []) == ChatViewModel.draftFingerprint(for: [])) #expect(ChatViewModel.draftFingerprint(for: []) == ChatViewModel.draftFingerprint(for: []))
} }
// MARK: - buildNotesSection
@Test("Nil body produces no notes section at all")
func buildNotesSectionNilBodyIsEmpty() {
#expect(ChatViewModel.buildNotesSection(body: nil) == "")
}
@Test("Empty body still produces a section, with a placeholder for the notes content")
func buildNotesSectionEmptyBodyShowsPlaceholder() {
let section = ChatViewModel.buildNotesSection(body: "")
#expect(section.contains("## Conversation Notes"))
#expect(section.contains("nothing saved yet"))
}
@Test("Non-empty body is included verbatim")
func buildNotesSectionIncludesBody() {
let section = ChatViewModel.buildNotesSection(body: "User prefers dark roast coffee.")
#expect(section.contains("User prefers dark roast coffee."))
}
// MARK: - extractNotesUpdate
@Test("No fenced block leaves content untouched and returns no notes body")
func extractNotesUpdateNoBlock() {
let (display, body) = ChatViewModel.extractNotesUpdate(from: "Just a normal reply.")
#expect(display == "Just a normal reply.")
#expect(body == nil)
}
@Test("A fenced update-notes block is stripped from the display text and its body extracted")
func extractNotesUpdateStripsBlock() {
let content = "Sure, noted!\n\n```update-notes\nUser prefers dark roast coffee.\n```"
let (display, body) = ChatViewModel.extractNotesUpdate(from: content)
#expect(display == "Sure, noted!")
#expect(body == "User prefers dark roast coffee.")
}
@Test("Text surrounding the fenced block on both sides is preserved")
func extractNotesUpdatePreservesSurroundingText() {
let content = "Before text.\n```update-notes\nRemember this.\n```\nAfter text."
let (display, body) = ChatViewModel.extractNotesUpdate(from: content)
#expect(display == "Before text.\n\nAfter text.")
#expect(body == "Remember this.")
}
@Test("A block with the language tag but an empty body extracts an empty string, not nil")
func extractNotesUpdateEmptyBody() {
let content = "```update-notes\n```"
let (_, body) = ChatViewModel.extractNotesUpdate(from: content)
#expect(body == "")
}
} }
@@ -0,0 +1,88 @@
//
// ConversationNotesServiceTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import Confab
@Suite("ConversationNotesService")
struct ConversationNotesServiceTests {
@Test("makeFilename sanitizes the conversation name and appends a short ID suffix")
func makeFilenameSanitizesAndSuffixes() {
let id = UUID(uuidString: "A3F2B1C0-0000-0000-0000-000000000000")!
let filename = ConversationNotesService.shared.makeFilename(conversationName: "My Recipe Chat", conversationId: id)
#expect(filename == "My Recipe Chat-a3f2.md")
}
@Test("makeFilename sanitizes invalid filename characters in the conversation name")
func makeFilenameSanitizesInvalidChars() {
let id = UUID(uuidString: "A3F2B1C0-0000-0000-0000-000000000000")!
let filename = ConversationNotesService.shared.makeFilename(conversationName: "Q&A: What/Why?", conversationId: id)
#expect(filename == "Q&A- What-Why--a3f2.md")
}
@Test("makeFilename falls back to Untitled for an empty conversation name")
func makeFilenameFallsBackForEmptyName() {
let id = UUID(uuidString: "A3F2B1C0-0000-0000-0000-000000000000")!
let filename = ConversationNotesService.shared.makeFilename(conversationName: "", conversationId: id)
#expect(filename == "Untitled-a3f2.md")
}
@Test("write then readBody round-trips the body with the ID header stripped")
func writeReadRoundTrip() {
let id = UUID()
let filename = "test-notes-\(UUID().uuidString).md"
defer { ConversationNotesService.shared.delete(filename: filename) }
ConversationNotesService.shared.write(body: "User prefers dark roast coffee.", filename: filename, conversationId: id)
let body = ConversationNotesService.shared.readBody(filename: filename)
#expect(body == "User prefers dark roast coffee.")
}
@Test("readBody on a nonexistent file returns nil, not an error")
func readBodyMissingFileReturnsNil() {
let body = ConversationNotesService.shared.readBody(filename: "definitely-does-not-exist-\(UUID().uuidString).md")
#expect(body == nil)
}
@Test("readRaw returns the exact on-disk content, ID header included")
func readRawIncludesHeader() {
let id = UUID()
let filename = "test-notes-\(UUID().uuidString).md"
defer { ConversationNotesService.shared.delete(filename: filename) }
ConversationNotesService.shared.write(body: "User prefers dark roast coffee.", filename: filename, conversationId: id)
let raw = ConversationNotesService.shared.readRaw(filename: filename)
#expect(raw == "**ID**: `\(id.uuidString)`\n\nUser prefers dark roast coffee.")
}
@Test("writeRaw then readRaw round-trips content byte-for-byte, with no header wrapping added")
func writeRawReadRawRoundTrip() {
let filename = "test-notes-\(UUID().uuidString).md"
defer { ConversationNotesService.shared.delete(filename: filename) }
let content = "**ID**: `12345678-1234-1234-1234-123456789012`\n\nImported from another machine."
ConversationNotesService.shared.writeRaw(content: content, filename: filename)
#expect(ConversationNotesService.shared.readRaw(filename: filename) == content)
}
@Test("stripIDHeader removes the embedded ID line and following blank line")
func stripIDHeaderRemovesHeader() {
let content = "**ID**: `12345678-1234-1234-1234-123456789012`\n\nActual notes content."
#expect(ConversationNotesService.stripIDHeader(from: content) == "Actual notes content.")
}
@Test("stripIDHeader leaves content without the header untouched")
func stripIDHeaderLeavesUnheaderedContentUntouched() {
let content = "Just some content, no header."
#expect(ConversationNotesService.stripIDHeader(from: content) == content)
}
}
+150
View File
@@ -237,6 +237,98 @@ struct DatabaseServiceFolderTests {
#expect(db.columnNames(in: "folders").contains("parentId")) #expect(db.columnNames(in: "folders").contains("parentId"))
} }
@Test("v11 adds updatedAt to folders")
func v11AddsUpdatedAt() {
let db = DatabaseService.makeInMemory()
#expect(db.columnNames(in: "folders").contains("updatedAt"))
}
@Test("renameFolder bumps updatedAt")
func renameFolderBumpsUpdatedAt() throws {
let db = DatabaseService.makeInMemory()
let folder = try db.createFolder(name: "Work")
// Round-trip through the DB for the "before" value too, so both sides go through the same
// fractional-seconds truncation as the "after" read below comparing a raw in-memory
// Date() (full precision) against a DB-round-tripped one can flake when both timestamps
// land in the same millisecond window.
let originalUpdatedAt = try #require(db.listFolders().first(where: { $0.id == folder.id })?.updatedAt)
try db.renameFolder(id: folder.id, name: "Projects")
let updated = try db.listFolders().first(where: { $0.id == folder.id })
#expect(updated?.updatedAt ?? .distantPast >= originalUpdatedAt)
}
@Test("moveFolder bumps updatedAt")
func moveFolderBumpsUpdatedAt() throws {
let db = DatabaseService.makeInMemory()
let parent = try db.createFolder(name: "Work")
let child = try db.createFolder(name: "Personal")
// See renameFolderBumpsUpdatedAt's comment: round-trip through the DB for the "before"
// value so it's truncated the same way as the "after" read.
let originalUpdatedAt = try #require(db.listFolders().first(where: { $0.id == child.id })?.updatedAt)
try db.moveFolder(id: child.id, toParent: parent.id)
let updated = try db.listFolders().first(where: { $0.id == child.id })
#expect(updated?.updatedAt ?? .distantPast >= originalUpdatedAt)
}
@Test("upsertSyncedFolder creates a folder that doesn't exist locally yet")
func upsertSyncedFolderCreatesNew() throws {
let db = DatabaseService.makeInMemory()
let id = UUID()
let createdAt = Date(timeIntervalSince1970: 1_000)
let updatedAt = Date(timeIntervalSince1970: 2_000)
try db.upsertSyncedFolder(id: id, name: "Work", parentId: nil, createdAt: createdAt, updatedAt: updatedAt)
let folders = try db.listFolders()
let created = try #require(folders.first(where: { $0.id == id }))
#expect(created.name == "Work")
#expect(created.parentId == nil)
#expect(created.updatedAt == updatedAt)
}
@Test("upsertSyncedFolder is a no-op when the local version is the same age or newer")
func upsertSyncedFolderNoOpWhenLocalNotOlder() throws {
let db = DatabaseService.makeInMemory()
let id = UUID()
let createdAt = Date(timeIntervalSince1970: 1_000)
let localUpdatedAt = Date(timeIntervalSince1970: 5_000)
try db.upsertSyncedFolder(id: id, name: "Work", parentId: nil, createdAt: createdAt, updatedAt: localUpdatedAt)
// Incoming manifest entry is older than what's already local.
let staleIncomingUpdatedAt = Date(timeIntervalSince1970: 2_000)
try db.upsertSyncedFolder(id: id, name: "Renamed Elsewhere", parentId: nil, createdAt: createdAt, updatedAt: staleIncomingUpdatedAt)
let folders = try db.listFolders()
let unchanged = try #require(folders.first(where: { $0.id == id }))
#expect(unchanged.name == "Work")
#expect(unchanged.updatedAt == localUpdatedAt)
}
@Test("upsertSyncedFolder updates name and parent when the incoming version is newer")
func upsertSyncedFolderUpdatesWhenIncomingNewer() throws {
let db = DatabaseService.makeInMemory()
let id = UUID()
let otherParent = try db.createFolder(name: "Other Parent")
let createdAt = Date(timeIntervalSince1970: 1_000)
let localUpdatedAt = Date(timeIntervalSince1970: 2_000)
try db.upsertSyncedFolder(id: id, name: "Work", parentId: nil, createdAt: createdAt, updatedAt: localUpdatedAt)
let newerIncomingUpdatedAt = Date(timeIntervalSince1970: 9_000)
try db.upsertSyncedFolder(
id: id, name: "Projects", parentId: otherParent.id, createdAt: createdAt, updatedAt: newerIncomingUpdatedAt
)
let folders = try db.listFolders()
let updated = try #require(folders.first(where: { $0.id == id }))
#expect(updated.name == "Projects")
#expect(updated.parentId == otherParent.id)
#expect(updated.updatedAt == newerIncomingUpdatedAt)
}
@Test("createFolder(parentId:) nests the new folder under its parent") @Test("createFolder(parentId:) nests the new folder under its parent")
func createFolderWithParent() throws { func createFolderWithParent() throws {
let db = DatabaseService.makeInMemory() let db = DatabaseService.makeInMemory()
@@ -395,3 +487,61 @@ struct DatabaseServiceFolderTests {
#expect(conversations.first?.folderId == folder.id) #expect(conversations.first?.folderId == folder.id)
} }
} }
@Suite("DatabaseService per-conversation notes (v12), against a throwaway in-memory queue")
struct DatabaseServiceNotesTests {
@Test("conversations table has notesEnabled and notesFilename columns after v12")
func v12AddsNotesColumns() {
let db = DatabaseService.makeInMemory()
let columns = Set(db.columnNames(in: "conversations"))
#expect(columns.contains("notesEnabled"))
#expect(columns.contains("notesFilename"))
}
@Test("A newly saved conversation defaults to notes disabled with no filename")
func newConversationDefaultsToNotesDisabled() throws {
let db = DatabaseService.makeInMemory()
let saved = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
#expect(saved.notesEnabled == false)
#expect(saved.notesFilename == nil)
let loaded = try db.loadConversation(id: saved.id)
#expect(loaded?.0.notesEnabled == false)
#expect(loaded?.0.notesFilename == nil)
}
@Test("setNotesEnabled persists and round-trips through loadConversation")
func setNotesEnabledRoundTrips() throws {
let db = DatabaseService.makeInMemory()
let saved = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
try db.setNotesEnabled(id: saved.id, enabled: true)
let loaded = try db.loadConversation(id: saved.id)
#expect(loaded?.0.notesEnabled == true)
}
@Test("setNotesFilename persists and round-trips through loadConversation")
func setNotesFilenameRoundTrips() throws {
let db = DatabaseService.makeInMemory()
let saved = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
try db.setNotesFilename(id: saved.id, filename: "Chat-a3f2.md")
let loaded = try db.loadConversation(id: saved.id)
#expect(loaded?.0.notesFilename == "Chat-a3f2.md")
}
@Test("listConversations reflects notesEnabled and notesFilename")
func listConversationsReflectsNotes() throws {
let db = DatabaseService.makeInMemory()
let saved = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
try db.setNotesEnabled(id: saved.id, enabled: true)
try db.setNotesFilename(id: saved.id, filename: "Chat-a3f2.md")
let conversations = try db.listConversations()
#expect(conversations.first?.notesEnabled == true)
#expect(conversations.first?.notesFilename == "Chat-a3f2.md")
}
}
+187
View File
@@ -165,4 +165,191 @@ struct GitSyncServiceTests {
let orphans = GitSyncService.orphanedExportFilenames(currentIds: [], files: files) let orphans = GitSyncService.orphanedExportFilenames(currentIds: [], files: files)
#expect(orphans.isEmpty) #expect(orphans.isEmpty)
} }
// MARK: - orphanedLocalFolderIds
@Test("A local folder still present in the manifest is not orphaned")
func keepsLocalFoldersStillInManifest() {
let id = UUID().uuidString
let orphans = GitSyncService.orphanedLocalFolderIds(manifestFolderIds: [id], localFolderIds: [id])
#expect(orphans.isEmpty)
}
@Test("A local folder missing from the manifest is orphaned")
func flagsLocalFoldersMissingFromManifest() {
let keptId = UUID().uuidString
let deletedId = UUID().uuidString
let orphans = GitSyncService.orphanedLocalFolderIds(
manifestFolderIds: [keptId], localFolderIds: [keptId, deletedId]
)
#expect(orphans == [deletedId])
}
@Test("An empty manifest never orphans existing local folders")
func emptyManifestNeverOrphansLocalFolders() {
// Same safety guard as orphanedExportFilenames: an empty manifest is indistinguishable
// from "folders.json hasn't been imported yet" (older sync repo, or a fresh clone before
// the first export), so treating it as "delete every local folder" would repeat the exact
// class of mass-deletion bug that hit conversation sync.
let orphans = GitSyncService.orphanedLocalFolderIds(
manifestFolderIds: [], localFolderIds: [UUID().uuidString, UUID().uuidString]
)
#expect(orphans.isEmpty)
}
@Test("Empty local folder list produces no orphans")
func emptyLocalFolderListProducesNoOrphans() {
let orphans = GitSyncService.orphanedLocalFolderIds(manifestFolderIds: [UUID().uuidString], localFolderIds: [])
#expect(orphans.isEmpty)
}
// MARK: - FolderSyncManifest round-trip
@Test("FolderSyncManifest round-trips through JSON encode/decode")
func folderSyncManifestRoundTrips() throws {
let folderId = UUID().uuidString
let conversationId = UUID().uuidString
let manifest = FolderSyncManifest(
folders: [
FolderSyncManifest.FolderEntry(
id: folderId, name: "Work", parentId: nil,
createdAt: Date(timeIntervalSince1970: 1_000), updatedAt: Date(timeIntervalSince1970: 2_000)
)
],
assignments: [conversationId: folderId]
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let data = try encoder.encode(manifest)
let decoded = try decoder.decode(FolderSyncManifest.self, from: data)
#expect(decoded.folders.count == 1)
#expect(decoded.folders.first?.id == folderId)
#expect(decoded.folders.first?.name == "Work")
#expect(decoded.assignments[conversationId] == folderId)
}
// MARK: - orphanedNoteFilenames
@Test("A note file still referenced by a current conversation is not orphaned")
func keepsNoteFilesForExistingConversations() {
let orphans = GitSyncService.orphanedNoteFilenames(currentFilenames: ["chat-a3f2.md"], existingFiles: ["chat-a3f2.md"])
#expect(orphans.isEmpty)
}
@Test("A note file no longer referenced by any current conversation is orphaned")
func flagsNoteFilesForDeletedConversations() {
let orphans = GitSyncService.orphanedNoteFilenames(
currentFilenames: ["kept-b1c2.md"],
existingFiles: ["kept-b1c2.md", "deleted-d3e4.md"]
)
#expect(orphans == ["deleted-d3e4.md"])
}
@Test("Non-markdown files in the notes directory are never flagged as orphans")
func nonMarkdownFilesAreNotFlaggedAsOrphans() {
let orphans = GitSyncService.orphanedNoteFilenames(currentFilenames: ["kept.md"], existingFiles: [".DS_Store"])
#expect(orphans.isEmpty)
}
@Test("Empty existing file list produces no orphans")
func emptyExistingNoteFilesProducesNoOrphans() {
#expect(GitSyncService.orphanedNoteFilenames(currentFilenames: ["a.md"], existingFiles: []).isEmpty)
}
@Test("Empty current filenames set never orphans existing note files, even when files exist")
func emptyCurrentNoteFilenamesNeverOrphansExistingFiles() {
// Same class of regression guard as emptyCurrentIdsNeverOrphansExistingFiles /
// emptyManifestNeverOrphansLocalFolders: an empty local-state snapshot must never be
// read as "delete everything in the sync repo."
let orphans = GitSyncService.orphanedNoteFilenames(currentFilenames: [], existingFiles: ["a.md", "b.md"])
#expect(orphans.isEmpty)
}
// MARK: - NotesSyncManifest round-trip
@Test("NotesSyncManifest round-trips through JSON encode/decode")
func notesSyncManifestRoundTrips() throws {
let conversationId = UUID().uuidString
let manifest = NotesSyncManifest(
notes: [conversationId: NotesSyncManifest.Entry(filename: "Chat-a3f2.md", enabled: true)]
)
let data = try JSONEncoder().encode(manifest)
let decoded = try JSONDecoder().decode(NotesSyncManifest.self, from: data)
#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)
}
} }
+29
View File
@@ -0,0 +1,29 @@
//
// StringExtensionsTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
@testable import Confab
@Suite("String.sanitizedForFilename")
struct StringExtensionsTests {
@Test("Invalid filename characters are replaced with a dash")
func sanitizedForFilenameStripsInvalidChars() {
#expect("a/b\\c:d*e?f\"g<h>i|j".sanitizedForFilename() == "a-b-c-d-e-f-g-h-i-j")
}
@Test("A valid filename passes through unchanged")
func sanitizedForFilenameValidInput() {
#expect("My Chat 2026-01-15".sanitizedForFilename() == "My Chat 2026-01-15")
}
@Test("GitSyncService.sanitizeFilename delegates to the shared extension")
func gitSyncServiceDelegatesToExtension() {
let service = GitSyncService.shared
#expect(service.sanitizeFilename("a/b\\c:d*e?f\"g<h>i|j") == "a-b-c-d-e-f-g-h-i-j".sanitizedForFilename())
}
}