diff --git a/macos/Onit/Accessibility/Notifications/AccessibilityNotificationsManager.swift b/macos/Onit/Accessibility/Notifications/AccessibilityNotificationsManager.swift
index a9b2f1996..a1df4f859 100644
--- a/macos/Onit/Accessibility/Notifications/AccessibilityNotificationsManager.swift
+++ b/macos/Onit/Accessibility/Notifications/AccessibilityNotificationsManager.swift
@@ -359,6 +359,7 @@ class AccessibilityNotificationsManager: ObservableObject {
AccessibilityParsedElements.applicationName: appName,
AccessibilityParsedElements.applicationTitle: appTitle,
AccessibilityParsedElements.elapsedTime: "\(CFAbsoluteTimeGetCurrent() - startTime)",
+ AccessibilityParsedElements.documentUrl: "Google drive document's url: " + url.absoluteString,
"document": documentContent
]
handleWindowContent(
diff --git a/macos/Onit/Accessibility/Parser/AccessibilityParsedElements.swift b/macos/Onit/Accessibility/Parser/AccessibilityParsedElements.swift
index ce62b5ef8..953818075 100644
--- a/macos/Onit/Accessibility/Parser/AccessibilityParsedElements.swift
+++ b/macos/Onit/Accessibility/Parser/AccessibilityParsedElements.swift
@@ -10,8 +10,11 @@ struct AccessibilityParsedElements {
static let applicationTitle = "applicationTitle"
static let elapsedTime = "elapsedTime"
+ static let documentContent = "Document's content"
+ static let documentUrl = "Document's URL"
+
static let highlightedText = "highlightedText"
- static let screen = "screen"
+ static let screen = "Plain text content"
struct Xcode {
static let editor = "editor"
diff --git a/macos/Onit/Assets.xcassets/Colors/AcceptBG.colorset/Contents.json b/macos/Onit/Assets.xcassets/Colors/AcceptBG.colorset/Contents.json
new file mode 100644
index 000000000..648f83d2a
--- /dev/null
+++ b/macos/Onit/Assets.xcassets/Colors/AcceptBG.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x38",
+ "green" : "0x68",
+ "red" : "0x2D"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/macos/Onit/Assets.xcassets/Colors/LimeGreen.colorset/Contents.json b/macos/Onit/Assets.xcassets/Colors/LimeGreen.colorset/Contents.json
index 5d8341dc0..a713db9eb 100644
--- a/macos/Onit/Assets.xcassets/Colors/LimeGreen.colorset/Contents.json
+++ b/macos/Onit/Assets.xcassets/Colors/LimeGreen.colorset/Contents.json
@@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
- "blue" : "0.573",
- "green" : "1.000",
- "red" : "0.475"
+ "blue" : "0x92",
+ "green" : "0xFF",
+ "red" : "0x79"
}
},
"idiom" : "universal"
diff --git a/macos/Onit/Assets.xcassets/Colors/RedPale400.colorset/Contents.json b/macos/Onit/Assets.xcassets/Colors/RedPale400.colorset/Contents.json
new file mode 100644
index 000000000..e1b895b48
--- /dev/null
+++ b/macos/Onit/Assets.xcassets/Colors/RedPale400.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x61",
+ "green" : "0x5A",
+ "red" : "0xFF"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/macos/Onit/Assets.xcassets/Colors/RejectBG.colorset/Contents.json b/macos/Onit/Assets.xcassets/Colors/RejectBG.colorset/Contents.json
new file mode 100644
index 000000000..dad8cc1ba
--- /dev/null
+++ b/macos/Onit/Assets.xcassets/Colors/RejectBG.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x2B",
+ "green" : "0x27",
+ "red" : "0x8E"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/macos/Onit/Assets.xcassets/Colors/diffBgGreen.colorset/Contents.json b/macos/Onit/Assets.xcassets/Colors/diffBgGreen.colorset/Contents.json
new file mode 100644
index 000000000..6ec8af3e2
--- /dev/null
+++ b/macos/Onit/Assets.xcassets/Colors/diffBgGreen.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "0.150",
+ "blue" : "0x00",
+ "green" : "0xFF",
+ "red" : "0x62"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/macos/Onit/Assets.xcassets/Colors/diffBgRed.colorset/Contents.json b/macos/Onit/Assets.xcassets/Colors/diffBgRed.colorset/Contents.json
new file mode 100644
index 000000000..9e3ca50e1
--- /dev/null
+++ b/macos/Onit/Assets.xcassets/Colors/diffBgRed.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "0.180",
+ "blue" : "0x00",
+ "green" : "0x00",
+ "red" : "0xFF"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/macos/Onit/Assets.xcassets/Colors/diffRed.colorset/Contents.json b/macos/Onit/Assets.xcassets/Colors/diffRed.colorset/Contents.json
new file mode 100644
index 000000000..489556f06
--- /dev/null
+++ b/macos/Onit/Assets.xcassets/Colors/diffRed.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x25",
+ "green" : "0x25",
+ "red" : "0xFF"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/macos/Onit/Assets.xcassets/Icons/lucide_diff.imageset/Contents.json b/macos/Onit/Assets.xcassets/Icons/lucide_diff.imageset/Contents.json
new file mode 100644
index 000000000..f010d77a7
--- /dev/null
+++ b/macos/Onit/Assets.xcassets/Icons/lucide_diff.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "lucide_diff.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/macos/Onit/Assets.xcassets/Icons/lucide_diff.imageset/lucide_diff.pdf b/macos/Onit/Assets.xcassets/Icons/lucide_diff.imageset/lucide_diff.pdf
new file mode 100644
index 000000000..8e7b0a994
Binary files /dev/null and b/macos/Onit/Assets.xcassets/Icons/lucide_diff.imageset/lucide_diff.pdf differ
diff --git a/macos/Onit/Assets.xcassets/Icons/notes.imageset/Contents.json b/macos/Onit/Assets.xcassets/Icons/notes.imageset/Contents.json
new file mode 100644
index 000000000..401841788
--- /dev/null
+++ b/macos/Onit/Assets.xcassets/Icons/notes.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "Icon.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/macos/Onit/Assets.xcassets/Icons/notes.imageset/Icon.svg b/macos/Onit/Assets.xcassets/Icons/notes.imageset/Icon.svg
new file mode 100644
index 000000000..b316db237
--- /dev/null
+++ b/macos/Onit/Assets.xcassets/Icons/notes.imageset/Icon.svg
@@ -0,0 +1,3 @@
+
diff --git a/macos/Onit/Data/Fetching/ChatEndpointMessagesBuilder.swift b/macos/Onit/Data/Fetching/ChatEndpointMessagesBuilder.swift
index a61327eae..849172a52 100644
--- a/macos/Onit/Data/Fetching/ChatEndpointMessagesBuilder.swift
+++ b/macos/Onit/Data/Fetching/ChatEndpointMessagesBuilder.swift
@@ -15,51 +15,57 @@ struct ChatEndpointMessagesBuilder {
static func user(instructions: [String], inputs: [Input?], files: [[URL]], autoContexts: [[String: String]], webSearchContexts: [[(title: String, content: String, source: String, url: URL?)]]) -> [String] {
var userMessages: [String] = []
for (index, instruction) in instructions.enumerated() {
- var message = ""
-
-
- // TODO: add error handling for contexts too long & incorrect file types
- if !files[index].isEmpty {
- message += "\n\nUse the following files as context:"
- for file in files[index] {
- if let fileContent = try? String(contentsOf: file, encoding: .utf8) {
- message += "\n\nFile: \(file.lastPathComponent)\nContent:\n\(fileContent)"
- }
- }
- }
-
+ var message = """
+ You are provided with multiple context sources below. Use them to fulfill the final instruction.
+ **Important rules:**
+ - If there is "Selected Text", prioritize it over all other content.
+ - Use only relevant information to fulfill the task;
+
+ """
+
+ // 1. Application Contexts
if !autoContexts[index].isEmpty {
- message += "\n\nUse the following application content as context:"
+ message += "\n---\n**Application Context**\n"
for (appName, appContent) in autoContexts[index] {
- message += "\n\nContent from application \(appName):\n\(appContent)"
+ message += "\n[App: \(appName)]\n\(appContent)"
}
}
-
- // Add web contexts
- if index < webSearchContexts.count && !webSearchContexts[index].isEmpty {
- message += "\n\nUse the following web search results as context:"
- for webSearchContext in webSearchContexts[index] {
- message += "\n\nWeb Search Result: \(webSearchContext.title)"
- if !webSearchContext.source.isEmpty {
- message += " (Source: \(webSearchContext.source))"
+
+ // 2. Web Search Results
+ if index < webSearchContexts.count, !webSearchContexts[index].isEmpty {
+ message += "\n---\n**Web Search Results**\n"
+ for web in webSearchContexts[index] {
+ message += "\n- Title: \(web.title)"
+ if !web.source.isEmpty {
+ message += " (Source: \(web.source))"
+ }
+ message += "\n\(web.content)"
+ }
+ }
+
+ // 3. Files
+ if !files[index].isEmpty {
+ message += "\n---\n**Attached Files**\n"
+ for file in files[index] {
+ if let content = try? String(contentsOf: file, encoding: .utf8) {
+ message += "\n[File: \(file.lastPathComponent)]\n\(content)"
}
- message += "\n\(webSearchContext.content)"
}
}
- if let input = inputs[index], !input.selectedText.isEmpty {
- message += "\n\nUse the following selected text as context. When present, selected text should take priority over other context."
- if let application = input.application {
- message += "\n\nSelected Text from \(application): \(input.selectedText)"
+ // 4. Selected Text
+ if let input = inputs[index], !input.selectedText.isEmpty {
+ message += "\n---\n**Selected Text** (Highest Priority)\n"
+ if let app = input.application {
+ message += "From application \(app):\n\(input.selectedText)"
} else {
- message += "\n\nSelected Text: \(input.selectedText)"
+ message += input.selectedText
}
}
-
- // Intuitively, I (tim) think the message should be the last thing.
- // TODO: evaluate this
- message += "\n\n\(instruction)"
+ // 5. Final Instruction
+ message += "\n---\n**Instruction**\n"
+ message += instruction
userMessages.append(message)
}
diff --git a/macos/Onit/Data/Fetching/Endpoints/StreamToolAccumulator.swift b/macos/Onit/Data/Fetching/Endpoints/StreamToolAccumulator.swift
index 6dc5ff8f2..fb1f9ba29 100644
--- a/macos/Onit/Data/Fetching/Endpoints/StreamToolAccumulator.swift
+++ b/macos/Onit/Data/Fetching/Endpoints/StreamToolAccumulator.swift
@@ -24,9 +24,12 @@ class StreamToolAccumulator: @unchecked Sendable {
func addArguments(_ fragment: String) -> StreamingEndpointResponse {
accumulatedArguments += fragment
+ // Create a valid JSON by completing the partial JSON
+ let validJSON = makeValidJSON(from: accumulatedArguments)
+
return StreamingEndpointResponse(content: nil,
toolName: currentToolName,
- toolArguments: accumulatedArguments,
+ toolArguments: validJSON,
isToolComplete: false)
}
@@ -46,4 +49,129 @@ class StreamToolAccumulator: @unchecked Sendable {
func hasActiveTool() -> Bool {
return currentToolName != nil
}
+
+ // MARK: - JSON Validation
+
+ private func makeValidJSON(from partialJSON: String) -> String {
+ if isValidJSON(partialJSON) {
+ return partialJSON
+ }
+
+ var cleanedJSON = partialJSON.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ if !cleanedJSON.hasPrefix("{") && !cleanedJSON.hasPrefix("[") {
+ cleanedJSON = "{" + cleanedJSON
+ }
+
+ var openBraces = 0
+ var openBrackets = 0
+ var openQuotes = false
+ var i = cleanedJSON.startIndex
+
+ while i < cleanedJSON.endIndex {
+ let char = cleanedJSON[i]
+
+ if char == "\"" {
+ let previousIndex = cleanedJSON.index(before: i)
+ let isEscaped = previousIndex > cleanedJSON.startIndex && cleanedJSON[previousIndex] == "\\" && cleanedJSON[cleanedJSON.index(before: previousIndex)] != "\\"
+ if !isEscaped {
+ openQuotes.toggle()
+ }
+ }
+
+ if !openQuotes {
+ switch char {
+ case "{":
+ openBraces += 1
+ case "}":
+ openBraces = max(0, openBraces - 1)
+ case "[":
+ openBrackets += 1
+ case "]":
+ openBrackets = max(0, openBrackets - 1)
+ default:
+ break
+ }
+ }
+
+ i = cleanedJSON.index(after: i)
+ }
+
+ if openQuotes {
+ cleanedJSON += "\""
+ }
+
+ let lastNonWS = cleanedJSON.last { !$0.isWhitespace }
+
+ if let lastColonIndex = cleanedJSON.lastIndex(of: ":") {
+ let suffix = cleanedJSON[lastColonIndex...].trimmingCharacters(in: .whitespacesAndNewlines)
+ if suffix == ":" {
+ cleanedJSON += "\"\""
+ } else if suffix.hasSuffix(":\"") && !suffix.hasSuffix(":\"\"") {
+ cleanedJSON += "\""
+ }
+ } else {
+ if lastNonWS == "\"" {
+ if let lastQuoteIndex = cleanedJSON.lastIndex(of: "\"") {
+ let beforeQuote = cleanedJSON[.. Bool {
+ guard let data = string.data(using: .utf8) else { return false }
+ return (try? JSONSerialization.jsonObject(with: data)) != nil
+ }
+
+ private func escapeJSONString(_ string: String) -> String {
+ return string
+ .replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\"", with: "\\\"")
+ .replacingOccurrences(of: "\n", with: "\\n")
+ .replacingOccurrences(of: "\r", with: "\\r")
+ .replacingOccurrences(of: "\t", with: "\\t")
+ }
}
diff --git a/macos/Onit/Data/Persistence/Defaults.swift b/macos/Onit/Data/Persistence/Defaults.swift
index 7a112b20e..05d2675ae 100644
--- a/macos/Onit/Data/Persistence/Defaults.swift
+++ b/macos/Onit/Data/Persistence/Defaults.swift
@@ -108,6 +108,7 @@ extension Defaults.Keys {
// Window state
static let panelWidth = Key("panelWidth", default: 400)
+ static let notepadWidth = Key("notepadWidth", default: 400)
// General settings
static let launchOnStartupRequested = Key("launchOnStartupRequested", default: false)
diff --git a/macos/Onit/Data/Persistence/DiffChangeState.swift b/macos/Onit/Data/Persistence/DiffChangeState.swift
new file mode 100644
index 000000000..112884a04
--- /dev/null
+++ b/macos/Onit/Data/Persistence/DiffChangeState.swift
@@ -0,0 +1,95 @@
+//
+// DiffChangeState.swift
+// Onit
+//
+// Created by Kévin Naudin on 07/07/2025.
+//
+
+import Foundation
+import SwiftData
+
+@Model
+class DiffRevision {
+ var index: Int
+ var createdAt: Date
+ @Relationship(deleteRule: .cascade, inverse: \DiffChangeState.revision)
+ var diffChanges: [DiffChangeState] = []
+
+ var response: Response?
+
+ init(index: Int) {
+ self.index = index
+ self.createdAt = Date()
+ }
+}
+
+@Model
+class DiffChangeState {
+ var operationIndex: Int
+ var operationType: DiffOperationType
+ var status: DiffChangeStatus
+ var timestamp: Date
+ var operationText: String?
+ var operationStartIndex: Int?
+ var operationEndIndex: Int?
+
+ var revision: DiffRevision?
+
+ init(operationIndex: Int, operationType: DiffOperationType, status: DiffChangeStatus = .pending, operationText: String? = nil, operationStartIndex: Int? = nil, operationEndIndex: Int? = nil) {
+ self.operationIndex = operationIndex
+ self.operationType = operationType
+ self.status = status
+ self.timestamp = Date()
+ self.operationText = operationText
+ self.operationStartIndex = operationStartIndex
+ self.operationEndIndex = operationEndIndex
+ }
+}
+
+// MARK: - Sendable Data Transfer Object
+
+struct DiffChangeData: Sendable {
+ let operationIndex: Int
+ let operationType: DiffOperationType
+ let status: DiffChangeStatus
+ let operationText: String?
+ let operationStartIndex: Int?
+ let operationEndIndex: Int?
+
+ init(operationIndex: Int, operationType: DiffOperationType, status: DiffChangeStatus, operationText: String? = nil, operationStartIndex: Int? = nil, operationEndIndex: Int? = nil) {
+ self.operationIndex = operationIndex
+ self.operationType = operationType
+ self.status = status
+ self.operationText = operationText
+ self.operationStartIndex = operationStartIndex
+ self.operationEndIndex = operationEndIndex
+ }
+}
+
+enum DiffOperationType: String, Codable, CaseIterable, Sendable {
+ case insertText = "insertText"
+ case deleteContentRange = "deleteContentRange"
+ case replaceText = "replaceText"
+
+ var priority: Int {
+ switch self {
+ case .deleteContentRange: return 0 // Highest priority
+ case .replaceText: return 1 // Medium priority
+ case .insertText: return 2 // Lowest priority
+ }
+ }
+}
+
+enum DiffChangeStatus: String, Codable, CaseIterable, Sendable {
+ case pending
+ case approved
+ case rejected
+
+ var displayName: String {
+ switch self {
+ case .pending: return "Pending"
+ case .approved: return "Approved"
+ case .rejected: return "Rejected"
+ }
+ }
+}
diff --git a/macos/Onit/Data/Persistence/Prompt.swift b/macos/Onit/Data/Persistence/Prompt.swift
index 7ca0ab13d..051a18b2d 100644
--- a/macos/Onit/Data/Persistence/Prompt.swift
+++ b/macos/Onit/Data/Persistence/Prompt.swift
@@ -46,7 +46,8 @@ import SwiftData
}
var currentResponse: Response? {
- guard generationIndex >= 0,
+ guard sortedResponses.count > 0,
+ generationIndex >= 0,
generationIndex < sortedResponses.count else {
return nil
}
@@ -72,8 +73,14 @@ import SwiftData
var generation: String? {
guard case .done = generationState else { return nil }
- guard sortedResponses.count > 0 && sortedResponses.count > generationIndex else { return nil }
- return sortedResponses[generationIndex].text
+
+ guard let currentResponse = currentResponse else { return nil }
+
+ if currentResponse.isDiffResponse, let previewText = currentResponse.diffPreview {
+ return previewText
+ }
+
+ return currentResponse.text
}
var generationCount: Int? {
diff --git a/macos/Onit/Data/Persistence/Response.swift b/macos/Onit/Data/Persistence/Response.swift
index a66677049..f0d19288c 100644
--- a/macos/Onit/Data/Persistence/Response.swift
+++ b/macos/Onit/Data/Persistence/Response.swift
@@ -21,6 +21,13 @@ class Response {
var toolCallArguments: String?
var toolCallResult: String?
var toolCallSuccess: Bool?
+
+ // Diff view
+ var currentDiffRevisionIndex: Int = 0
+ var shouldDisplayDiffToolView = true
+
+ @Relationship(deleteRule: .cascade, inverse: \DiffRevision.response)
+ var diffRevisions: [DiffRevision] = []
init(text: String, instruction: String?, type: ResponseType, model: String, time: Date = .now) {
self.text = text
@@ -48,3 +55,167 @@ enum ResponseType: String, Codable {
case success
case error
}
+
+// MARK: - Diff tool
+
+extension Response {
+ var isDiffResponse: Bool {
+ return toolCallName?.hasPrefix("diff_") == true
+ }
+
+ var diffArguments: DiffTool.PlainTextDiffArguments? {
+ guard let argumentsData = toolCallArguments?.data(using: .utf8) else { return nil }
+
+ return try? JSONDecoder().decode(DiffTool.PlainTextDiffArguments.self, from: argumentsData)
+ }
+
+ var diffResult: DiffTool.PlainTextDiffResult? {
+ guard let resultData = toolCallResult?.data(using: .utf8) else { return nil }
+
+ return try? JSONDecoder().decode(DiffTool.PlainTextDiffResult.self, from: resultData)
+ }
+
+ var diffPreview: String? {
+ guard let diffArguments = diffArguments,
+ let diffResult = diffResult else { return nil }
+
+ let segments = DiffSegmentUtils.generateDiffSegments(
+ originalText: diffArguments.original_content,
+ operations: diffResult.operations
+ )
+
+ let currentChanges = currentDiffChanges
+ let effectiveChanges = currentChanges.map { change in
+ DiffChangeData(
+ operationIndex: change.operationIndex,
+ operationType: change.operationType,
+ status: change.status == .pending ? .rejected : change.status,
+ operationText: change.operationText,
+ operationStartIndex: change.operationStartIndex,
+ operationEndIndex: change.operationEndIndex
+ )
+ }
+
+ let result = DiffSegmentUtils.generateTextFromSegments(
+ segments: segments,
+ effectiveChanges: effectiveChanges
+ )
+
+ return result.isEmpty ? nil : result
+ }
+}
+
+// MARK: - Diff Revisions
+extension Response {
+ var sortedDiffRevisions: [DiffRevision] {
+ return diffRevisions.sorted { $0.index < $1.index }
+ }
+
+ var currentDiffChanges: [DiffChangeState] {
+ guard let currentRevision = currentDiffRevision else { return [] }
+ return currentRevision.diffChanges
+ }
+
+ var currentDiffRevision: DiffRevision? {
+ return diffRevisions.first { $0.index == currentDiffRevisionIndex }
+ }
+
+ var totalDiffRevisions: Int {
+ return diffRevisions.count
+ }
+
+ private var nextRevisionIndex: Int {
+ return (diffRevisions.map { $0.index }.max() ?? -1) + 1
+ }
+
+ func createNewDiffRevision(with diffChanges: [DiffChangeState]) {
+ let newIndex = nextRevisionIndex
+ let newRevision = DiffRevision(index: newIndex)
+ newRevision.diffChanges = diffChanges
+ newRevision.response = self
+ diffRevisions.append(newRevision)
+ currentDiffRevisionIndex = newIndex
+ }
+
+ func addDiffChangeToCurrentRevision(_ diffChange: DiffChangeState) {
+ if diffRevisions.isEmpty {
+ let newRevision = DiffRevision(index: 0)
+ newRevision.response = self
+ diffRevisions.append(newRevision)
+ currentDiffRevisionIndex = 0
+ }
+
+ if let currentRevision = currentDiffRevision {
+ currentRevision.diffChanges.append(diffChange)
+ }
+ }
+
+ func setCurrentRevision(index: Int) {
+ guard diffRevisions.contains(where: { $0.index == index }) else { return }
+ currentDiffRevisionIndex = index
+ }
+
+ func addDiffChangesToCurrentRevision(_ diffChanges: [DiffChangeState]) {
+ if diffRevisions.isEmpty {
+ let newRevision = DiffRevision(index: 0)
+ newRevision.response = self
+ diffRevisions.append(newRevision)
+ currentDiffRevisionIndex = 0
+ }
+
+ if let currentRevision = currentDiffRevision {
+ currentRevision.diffChanges.append(contentsOf: diffChanges)
+ }
+ }
+
+ func clearCurrentDiffRevision() {
+ if let currentRevision = currentDiffRevision {
+ currentRevision.diffChanges.removeAll()
+ }
+ }
+
+ func createNewRevisionFromOriginal() -> [DiffChangeState] {
+ guard let diffResult = diffResult else { return [] }
+
+ var newRevisionChanges: [DiffChangeState] = []
+
+ for (index, operation) in diffResult.operations.enumerated() {
+ let diffChange = DiffChangeState(
+ operationIndex: index,
+ operationType: operation.type,
+ status: .pending,
+ operationText: operation.text ?? operation.newText,
+ operationStartIndex: operation.startIndex ?? operation.index,
+ operationEndIndex: operation.endIndex
+ )
+ newRevisionChanges.append(diffChange)
+ }
+
+ let newIndex = nextRevisionIndex
+ let newRevision = DiffRevision(index: newIndex)
+ newRevision.diffChanges = newRevisionChanges
+ newRevision.response = self
+ diffRevisions.append(newRevision)
+ currentDiffRevisionIndex = newIndex
+
+ return newRevisionChanges
+ }
+
+ func createVariantWithContext(_ modelContext: ModelContext) {
+ let newChanges = createNewRevisionFromOriginal()
+
+ if let newRevision = diffRevisions.last {
+ modelContext.insert(newRevision)
+ }
+
+ for change in newChanges {
+ modelContext.insert(change)
+ }
+
+ do {
+ try modelContext.save()
+ } catch {
+ print("Error saving variant: \(error)")
+ }
+ }
+}
diff --git a/macos/Onit/Data/Services/Google API/GoogleDocsParser.swift b/macos/Onit/Data/Services/Google API/GoogleDocsParser.swift
new file mode 100644
index 000000000..967381084
--- /dev/null
+++ b/macos/Onit/Data/Services/Google API/GoogleDocsParser.swift
@@ -0,0 +1,240 @@
+//
+// GoogleDocsParser.swift
+// Onit
+//
+// Created by Kévin Naudin on 07/11/2025.
+//
+
+import Foundation
+
+class GoogleDocsParser {
+ func parseGoogleDocsDocument(from data: [String: Any]) throws -> GoogleDocsDocument {
+ guard let documentId = data["documentId"] as? String,
+ let title = data["title"] as? String,
+ let bodyData = data["body"] as? [String: Any],
+ let revisionId = data["revisionId"] as? String else {
+ throw GoogleDriveServiceError.invalidResponse("Invalid Google Docs document structure")
+ }
+
+ let body = try parseGoogleDocsBody(from: bodyData)
+
+ return GoogleDocsDocument(
+ documentId: documentId,
+ title: title,
+ body: body,
+ revisionId: revisionId
+ )
+ }
+
+ private func parseGoogleDocsBody(from data: [String: Any]) throws -> GoogleDocsBody {
+ guard let contentArray = data["content"] as? [[String: Any]] else {
+ throw GoogleDriveServiceError.invalidResponse("Invalid Google Docs body structure")
+ }
+
+ let content = contentArray.compactMap { elementData in
+ return parseGoogleDocsStructuralElement(from: elementData)
+ }
+
+ return GoogleDocsBody(content: content)
+ }
+
+ private func parseGoogleDocsStructuralElement(from data: [String: Any]) -> GoogleDocsStructuralElement? {
+ let startIndex = data["startIndex"] as? Int
+ let endIndex = data["endIndex"] as? Int
+
+ var paragraph: GoogleDocsParagraph?
+ var table: GoogleDocsTable?
+ var pageBreak: GoogleDocsPageBreak?
+ var sectionBreak: GoogleDocsSectionBreak?
+ var tableOfContents: GoogleDocsTableOfContents?
+
+ if let paragraphData = data["paragraph"] as? [String: Any] {
+ paragraph = parseGoogleDocsParagraph(from: paragraphData)
+ }
+
+ if let tableData = data["table"] as? [String: Any] {
+ table = parseGoogleDocsTable(from: tableData)
+ }
+
+ if data["pageBreak"] != nil {
+ pageBreak = GoogleDocsPageBreak()
+ }
+
+ if let sectionBreakData = data["sectionBreak"] as? [String: Any] {
+ sectionBreak = parseGoogleDocsSectionBreak(from: sectionBreakData)
+ }
+
+ if let tableOfContentsData = data["tableOfContents"] as? [String: Any] {
+ tableOfContents = parseGoogleDocsTableOfContents(from: tableOfContentsData)
+ }
+
+ return GoogleDocsStructuralElement(
+ startIndex: startIndex,
+ endIndex: endIndex,
+ paragraph: paragraph,
+ table: table,
+ pageBreak: pageBreak,
+ sectionBreak: sectionBreak,
+ tableOfContents: tableOfContents
+ )
+ }
+
+ private func parseGoogleDocsParagraphElement(from data: [String: Any]) -> GoogleDocsParagraphElement? {
+ let startIndex = data["startIndex"] as? Int
+ let endIndex = data["endIndex"] as? Int
+
+ var textRun: GoogleDocsTextRun?
+ var inlineObjectElement: GoogleDocsInlineObjectElement?
+ var autoText: GoogleDocsAutoText?
+ var columnBreak: GoogleDocsColumnBreak?
+ var footnoteReference: GoogleDocsFootnoteReference?
+ var horizontalRule: GoogleDocsHorizontalRule?
+ var equation: GoogleDocsEquation?
+ var person: GoogleDocsPerson?
+ var richLink: GoogleDocsRichLink?
+
+ if let textRunData = data["textRun"] as? [String: Any],
+ let content = textRunData["content"] as? String {
+ textRun = GoogleDocsTextRun(content: content)
+ }
+
+ if let inlineObjectData = data["inlineObjectElement"] as? [String: Any] {
+ let inlineObjectId = inlineObjectData["inlineObjectId"] as? String
+ inlineObjectElement = GoogleDocsInlineObjectElement(inlineObjectId: inlineObjectId)
+ }
+
+ if let autoTextData = data["autoText"] as? [String: Any] {
+ let type = autoTextData["type"] as? String
+ autoText = GoogleDocsAutoText(type: type)
+ }
+
+ if data["columnBreak"] != nil {
+ columnBreak = GoogleDocsColumnBreak()
+ }
+
+ if let footnoteRefData = data["footnoteReference"] as? [String: Any] {
+ let footnoteId = footnoteRefData["footnoteId"] as? String
+ footnoteReference = GoogleDocsFootnoteReference(footnoteId: footnoteId)
+ }
+
+ if data["horizontalRule"] != nil {
+ horizontalRule = GoogleDocsHorizontalRule()
+ }
+
+ if data["equation"] != nil {
+ equation = GoogleDocsEquation()
+ }
+
+ if let personData = data["person"] as? [String: Any] {
+ let personId = personData["personId"] as? String
+ var personProperties: GoogleDocsPersonProperties?
+ if let personPropsData = personData["personProperties"] as? [String: Any] {
+ let name = personPropsData["name"] as? String
+ let email = personPropsData["email"] as? String
+ personProperties = GoogleDocsPersonProperties(name: name, email: email)
+ }
+ person = GoogleDocsPerson(personId: personId, personProperties: personProperties)
+ }
+
+ if let richLinkData = data["richLink"] as? [String: Any] {
+ let richLinkId = richLinkData["richLinkId"] as? String
+ var richLinkProperties: GoogleDocsRichLinkProperties?
+ if let richLinkPropsData = richLinkData["richLinkProperties"] as? [String: Any] {
+ let title = richLinkPropsData["title"] as? String
+ let mimeType = richLinkPropsData["mimeType"] as? String
+ richLinkProperties = GoogleDocsRichLinkProperties(title: title, mimeType: mimeType)
+ }
+ richLink = GoogleDocsRichLink(richLinkId: richLinkId, richLinkProperties: richLinkProperties)
+ }
+
+ if textRun == nil && inlineObjectElement == nil && autoText == nil && columnBreak == nil &&
+ footnoteReference == nil && horizontalRule == nil && equation == nil && person == nil && richLink == nil {
+ return nil
+ }
+
+ return GoogleDocsParagraphElement(
+ startIndex: startIndex,
+ endIndex: endIndex,
+ textRun: textRun,
+ inlineObjectElement: inlineObjectElement,
+ autoText: autoText,
+ columnBreak: columnBreak,
+ footnoteReference: footnoteReference,
+ horizontalRule: horizontalRule,
+ equation: equation,
+ person: person,
+ richLink: richLink
+ )
+ }
+
+ private func parseGoogleDocsParagraph(from data: [String: Any]) -> GoogleDocsParagraph? {
+ guard let elementsArray = data["elements"] as? [[String: Any]] else {
+ return nil
+ }
+
+ let elements = elementsArray.compactMap { elementData in
+ return parseGoogleDocsParagraphElement(from: elementData)
+ }
+
+ return GoogleDocsParagraph(elements: elements)
+ }
+
+ private func parseGoogleDocsTable(from data: [String: Any]) -> GoogleDocsTable? {
+ let rows = data["rows"] as? Int ?? 0
+ let columns = data["columns"] as? Int ?? 0
+
+ var tableRows: [GoogleDocsTableRow] = []
+ if let tableRowsData = data["tableRows"] as? [[String: Any]] {
+ tableRows = tableRowsData.compactMap { parseGoogleDocsTableRow(from: $0) }
+ }
+
+ return GoogleDocsTable(
+ rows: rows,
+ columns: columns,
+ tableRows: tableRows
+ )
+ }
+
+ private func parseGoogleDocsTableRow(from data: [String: Any]) -> GoogleDocsTableRow? {
+ var tableCells: [GoogleDocsTableCell] = []
+ if let tableCellsData = data["tableCells"] as? [[String: Any]] {
+ tableCells = tableCellsData.compactMap { parseGoogleDocsTableCell(from: $0) }
+ }
+
+ return GoogleDocsTableRow(tableCells: tableCells)
+ }
+
+ private func parseGoogleDocsTableCell(from data: [String: Any]) -> GoogleDocsTableCell? {
+ var content: [GoogleDocsStructuralElement] = []
+ if let contentData = data["content"] as? [[String: Any]] {
+ content = contentData.compactMap { parseGoogleDocsStructuralElement(from: $0) }
+ }
+
+ return GoogleDocsTableCell(content: content)
+ }
+
+ private func parseGoogleDocsSectionBreak(from data: [String: Any]) -> GoogleDocsSectionBreak? {
+ var sectionStyle: GoogleDocsSectionStyle?
+ if let sectionStyleData = data["sectionStyle"] as? [String: Any] {
+ let columnSeparatorStyle = sectionStyleData["columnSeparatorStyle"] as? String
+ let contentDirection = sectionStyleData["contentDirection"] as? String
+ let sectionType = sectionStyleData["sectionType"] as? String
+ sectionStyle = GoogleDocsSectionStyle(
+ columnSeparatorStyle: columnSeparatorStyle,
+ contentDirection: contentDirection,
+ sectionType: sectionType
+ )
+ }
+
+ return GoogleDocsSectionBreak(sectionStyle: sectionStyle)
+ }
+
+ private func parseGoogleDocsTableOfContents(from data: [String: Any]) -> GoogleDocsTableOfContents? {
+ var content: [GoogleDocsStructuralElement] = []
+ if let contentData = data["content"] as? [[String: Any]] {
+ content = contentData.compactMap { parseGoogleDocsStructuralElement(from: $0) }
+ }
+
+ return GoogleDocsTableOfContents(content: content)
+ }
+}
diff --git a/macos/Onit/Data/Services/Google API/GoogleDocsService.swift b/macos/Onit/Data/Services/Google API/GoogleDocsService.swift
new file mode 100644
index 000000000..dfe8e90f9
--- /dev/null
+++ b/macos/Onit/Data/Services/Google API/GoogleDocsService.swift
@@ -0,0 +1,537 @@
+//
+// GoogleDocsService.swift
+// Onit
+//
+// Created by Kévin Naudin on 07/01/2025.
+//
+
+import Foundation
+import GoogleSignIn
+import Defaults
+
+struct TextWithOffsets {
+ let reconstructedText: String
+ let offsetToGoogleIndexMap: [Int: Int]
+}
+
+class GoogleDocsService: GoogleDocumentServiceProtocol {
+
+ private let parser = GoogleDocsParser()
+
+ var plainTextMimeType: String {
+ return "text/plain"
+ }
+
+ func getPlainTextContent(fileId: String) async throws -> String {
+ let structuredData = try await readStructuredFile(fileId: fileId)
+ let document = try parser.parseGoogleDocsDocument(from: structuredData)
+ let textWithOffsets = reconstructDocsTextWithOffsets(document: document)
+
+ return textWithOffsets.reconstructedText
+ }
+
+ func updateFile(fileId: String, operations: [GoogleDocsOperation]) async throws {
+ guard let user = GIDSignIn.sharedInstance.currentUser else {
+ throw GoogleDriveServiceError.notAuthenticated("Not authenticated with Google Drive")
+ }
+
+ let accessToken = user.accessToken.tokenString
+ let urlString = "https://docs.googleapis.com/v1/documents/\(fileId):batchUpdate"
+
+ guard let url = URL(string: urlString) else {
+ let error = "Invalid batchUpdate URL"
+
+ throw GoogleDriveServiceError.invalidUrl(error)
+ }
+
+ var request = URLRequest(url: url)
+
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ let innerRequests = convertToAPIRequests(operations: operations)
+ let body: [String: Any] = [ "requests": innerRequests ]
+
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+ let (_, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
+ throw GoogleDriveServiceError.invalidResponse("Invalid response")
+ }
+ }
+
+ func convertToAPIRequests(operations: [GoogleDocsOperation]) -> [[String: Any]] {
+ var requests: [[String: Any]] = []
+
+ for operation in operations {
+ switch operation {
+ case .insertText(let index, let text):
+ requests.append([
+ "insertText": [
+ "location": ["index": index],
+ "text": text
+ ]
+ ])
+ case .deleteContentRange(let startIndex, let endIndex):
+ requests.append([
+ "deleteContentRange": [
+ "range": [
+ "startIndex": startIndex,
+ "endIndex": endIndex
+ ]
+ ]
+ ])
+ case .replaceText(let startIndex, let endIndex, let newText):
+ requests.append([
+ "deleteContentRange": [
+ "range": [
+ "startIndex": startIndex,
+ "endIndex": endIndex
+ ]
+ ]
+ ])
+ requests.append([
+ "insertText": [
+ "location": ["index": startIndex],
+ "text": newText
+ ]
+ ])
+ }
+ }
+
+ return requests
+ }
+
+ func applyDiffChanges(fileId: String, diffChanges: [DiffChangeData]) async throws {
+ let structuredData = try await readStructuredFile(fileId: fileId)
+ let document = try parser.parseGoogleDocsDocument(from: structuredData)
+ let textWithOffsets = reconstructDocsTextWithOffsets(document: document)
+
+ let sortedChanges = diffChanges.sorted { (change1, change2) in
+ let pos1 = change1.operationStartIndex ?? change1.operationEndIndex ?? 0
+ let pos2 = change2.operationStartIndex ?? change2.operationEndIndex ?? 0
+
+ if pos1 == pos2 {
+ return change1.operationType.priority < change2.operationType.priority
+ }
+
+ return pos1 > pos2
+ }
+
+ let resolvedChanges = resolveOperationConflicts(sortedChanges)
+
+ let googleDocsOperations = try mapDiffChangesToDocsOperations(
+ diffChanges: resolvedChanges,
+ offsetMap: textWithOffsets.offsetToGoogleIndexMap
+ )
+
+ try await updateFile(fileId: fileId, operations: googleDocsOperations)
+ }
+
+ private func readStructuredFile(fileId: String) async throws -> [String: Any] {
+ guard var user = GIDSignIn.sharedInstance.currentUser else {
+ throw GoogleDriveServiceError.notAuthenticated("Not authenticated with Google Drive")
+ }
+
+ do {
+ user = try await user.refreshTokensIfNeeded()
+ } catch {
+ print("Token refresh was unsuccessful: \(error)")
+ }
+
+ let accessToken = user.accessToken.tokenString
+ let apiUrl = "https://docs.googleapis.com/v1/documents/\(fileId)"
+
+ guard let url = URL(string: apiUrl) else {
+ throw GoogleDriveServiceError.invalidUrl("Invalid Google Docs API URL")
+ }
+
+ var request = URLRequest(url: url)
+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw GoogleDriveServiceError.invalidResponse("Invalid response")
+ }
+
+ if httpResponse.statusCode == 404 {
+ throw GoogleDriveServiceError.notFound("Onit needs permission to access this file.")
+ } else if httpResponse.statusCode == 403 {
+ var extractionError = "Onit can't access this file."
+
+ if let errorMessage = await GoogleDriveService.extractApiErrorMessage(from: data) {
+ extractionError += "\n\nError message: \(errorMessage)"
+ }
+
+ throw GoogleDriveServiceError.accessDenied(extractionError)
+ } else if httpResponse.statusCode != 200 {
+ var extractionError = "Failed to retrieve document"
+
+ if let errorMessage = await GoogleDriveService.extractApiErrorMessage(from: data) {
+ extractionError += "\n\nError message: \(errorMessage)"
+ }
+
+ throw GoogleDriveServiceError.httpError(httpResponse.statusCode, extractionError)
+ }
+
+ guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw GoogleDriveServiceError.decodingError("Failed to decode document content")
+ }
+
+ return json
+ }
+
+ // MARK: - Text Reconstruction with Offsets
+
+ private func reconstructDocsTextWithOffsets(document: GoogleDocsDocument) -> TextWithOffsets {
+ var reconstructedText = ""
+ var offsetToGoogleIndexMap: [Int: Int] = [:]
+
+ for element in document.body.content {
+ let (elementText, elementMapping) = reconstructStructuralElement(
+ element: element,
+ startPosition: reconstructedText.count
+ )
+
+ for (textPos, googleIndex) in elementMapping {
+ offsetToGoogleIndexMap[textPos] = googleIndex
+ }
+
+ reconstructedText += elementText
+ }
+
+ return TextWithOffsets(
+ reconstructedText: reconstructedText,
+ offsetToGoogleIndexMap: offsetToGoogleIndexMap
+ )
+ }
+
+ private func reconstructStructuralElement(
+ element: GoogleDocsStructuralElement,
+ startPosition: Int
+ ) -> (text: String, mapping: [Int: Int]) {
+ var reconstructedText = ""
+ var offsetToGoogleIndexMap: [Int: Int] = [:]
+
+ if let paragraph = element.paragraph {
+ for paragraphElement in paragraph.elements {
+ if let textRun = paragraphElement.textRun {
+ let content = textRun.content
+ let currentStartPosition = startPosition + reconstructedText.count
+
+ if let elementStartIndex = paragraphElement.startIndex {
+ let mappingResult = createTextToGoogleMapping(
+ content: content,
+ startPosition: currentStartPosition,
+ elementStartIndex: elementStartIndex
+ )
+
+ for (textPos, googleIndex) in mappingResult.mapping {
+ offsetToGoogleIndexMap[textPos] = googleIndex
+ }
+
+ reconstructedText += mappingResult.processedContent
+ } else {
+ reconstructedText += content
+ }
+ } else {
+ if let elementStartIndex = paragraphElement.startIndex,
+ let placeholderChar = getPlaceholderCharacter(for: paragraphElement) {
+ let currentStartPosition = startPosition + reconstructedText.count
+ let mappingResult = createTextToGoogleMapping(
+ content: placeholderChar,
+ startPosition: currentStartPosition,
+ elementStartIndex: elementStartIndex
+ )
+
+ for (textPos, googleIndex) in mappingResult.mapping {
+ offsetToGoogleIndexMap[textPos] = googleIndex
+ }
+
+ reconstructedText += mappingResult.processedContent
+ }
+ }
+ }
+ }
+
+ if let table = element.table {
+ for tableRow in table.tableRows {
+ for tableCell in tableRow.tableCells {
+ for cellElement in tableCell.content {
+ let currentStartPosition = startPosition + reconstructedText.count
+ let (cellText, cellMapping) = reconstructStructuralElement(
+ element: cellElement,
+ startPosition: currentStartPosition
+ )
+
+ for (textPos, googleIndex) in cellMapping {
+ offsetToGoogleIndexMap[textPos] = googleIndex
+ }
+
+ reconstructedText += cellText
+ }
+ }
+ }
+ }
+
+ if let elementStartIndex = element.startIndex,
+ let placeholderChar = getStructuralElementPlaceholderCharacter(for: element) {
+ let currentStartPosition = startPosition + reconstructedText.count
+
+ offsetToGoogleIndexMap[currentStartPosition] = elementStartIndex
+
+ let elementEndIndex = elementStartIndex + placeholderChar.count
+ let currentEndPosition = currentStartPosition + placeholderChar.count
+
+ offsetToGoogleIndexMap[currentEndPosition] = elementEndIndex
+ }
+
+ return (reconstructedText, offsetToGoogleIndexMap)
+ }
+
+ private func createTextToGoogleMapping(
+ content: String,
+ startPosition: Int,
+ elementStartIndex: Int
+ ) -> (processedContent: String, mapping: [Int: Int]) {
+ var mapping: [Int: Int] = [:]
+ var processedContent = ""
+ var textPosition = startPosition
+ var googleIndex = elementStartIndex
+
+ for char in content {
+ if isInvisibleCharacter(char) {
+ mapping[textPosition] = googleIndex
+ googleIndex += 1
+ } else if isGoogleDocsPlaceholderCharacter(char) {
+ mapping[textPosition] = googleIndex
+ googleIndex += 1
+ } else {
+ mapping[textPosition] = googleIndex
+ processedContent.append(char)
+ textPosition += 1
+ googleIndex += 1
+ }
+ }
+
+ mapping[textPosition] = googleIndex
+
+ return (processedContent, mapping)
+ }
+
+ private func isInvisibleCharacter(_ char: Character) -> Bool {
+ let unicodeScalars = char.unicodeScalars
+ for scalar in unicodeScalars {
+ switch scalar.value {
+ case 0xFEFF, // BOM (Byte Order Mark) / Zero Width No-Break Space
+ 0x200B, // Zero Width Space
+ 0x200C, // Zero Width Non-Joiner
+ 0x200D, // Zero Width Joiner
+ 0x2060, // Word Joiner
+ 0x2063, // Invisible Separator
+ 0x200E, // Left-to-right Mark
+ 0x200F: // Right-to-left Mark
+ return true
+ default:
+ continue
+ }
+ }
+ return false
+ }
+
+ private func isGoogleDocsPlaceholderCharacter(_ char: Character) -> Bool {
+ let unicodeScalars = char.unicodeScalars
+ for scalar in unicodeScalars {
+ switch scalar.value {
+ case 0xE907, 0xE000, 0xE001, 0xE002, 0xE003, 0xE004, 0xE005, 0xE006, 0xE008, 0xE009:
+ return true
+ default:
+ continue
+ }
+ }
+ return false
+ }
+
+ private func getPlaceholderCharacter(for paragraphElement: GoogleDocsParagraphElement) -> String? {
+ if paragraphElement.inlineObjectElement != nil {
+ return "\u{E907}" // Images
+ } else if paragraphElement.autoText != nil {
+ return "\u{E000}" // AutoText (page numbers, etc.)
+ } else if paragraphElement.columnBreak != nil {
+ return "\u{E001}" // Column Break
+ } else if paragraphElement.footnoteReference != nil {
+ return "\u{E002}" // Footnote Reference
+ } else if paragraphElement.horizontalRule != nil {
+ return "\u{E003}" // Horizontal Rule
+ } else if paragraphElement.equation != nil {
+ return "\u{E004}" // Equation
+ } else if paragraphElement.person != nil {
+ return "\u{E005}" // Person
+ } else if paragraphElement.richLink != nil {
+ return "\u{E006}" // Rich Link
+ }
+ return nil
+ }
+
+ private func getStructuralElementPlaceholderCharacter(for element: GoogleDocsStructuralElement) -> String? {
+ if element.pageBreak != nil {
+ return "\u{000C}" // Page Break (form feed character)
+ } else if element.sectionBreak != nil {
+ return "\u{E008}" // Section Break
+ } else if element.tableOfContents != nil {
+ return "\u{E009}" // Table of Contents
+ }
+ return nil
+ }
+
+ // MARK: - Diff Changes to Google Docs Operations Mapping
+
+ private func mapDiffChangesToDocsOperations(
+ diffChanges: [DiffChangeData],
+ offsetMap: [Int: Int]
+ ) throws -> [GoogleDocsOperation] {
+ var operations: [GoogleDocsOperation] = []
+
+ for (_, change) in diffChanges.enumerated() {
+ switch change.operationType {
+ case .insertText:
+ if let textPosition = change.operationStartIndex,
+ let text = change.operationText {
+ let googleIndex = getGoogleDocsIndex(for: textPosition, offsetMap: offsetMap)
+
+ operations.append(.insertText(index: googleIndex, text: text))
+ }
+
+ case .deleteContentRange:
+ if let startPosition = change.operationStartIndex,
+ let endPosition = change.operationEndIndex {
+ let startGoogleIndex = getGoogleDocsIndex(for: startPosition, offsetMap: offsetMap)
+ let endGoogleIndex = getGoogleDocsIndex(for: endPosition, offsetMap: offsetMap)
+
+ operations.append(.deleteContentRange(startIndex: startGoogleIndex, endIndex: endGoogleIndex))
+ }
+
+ case .replaceText:
+ if let startPosition = change.operationStartIndex,
+ let endPosition = change.operationEndIndex,
+ let newText = change.operationText {
+ let startGoogleIndex = getGoogleDocsIndex(for: startPosition, offsetMap: offsetMap)
+ let endGoogleIndex = getGoogleDocsIndex(for: endPosition, offsetMap: offsetMap)
+
+ operations.append(.replaceText(startIndex: startGoogleIndex, endIndex: endGoogleIndex, newText: newText))
+ }
+ }
+ }
+
+ return operations
+ }
+
+ // MARK: - Operation Conflict Resolution
+
+ private func resolveOperationConflicts(_ changes: [DiffChangeData]) -> [DiffChangeData] {
+ var resolvedChanges: [DiffChangeData] = []
+
+ for change in changes {
+ switch change.operationType {
+ case .deleteContentRange:
+ resolvedChanges.append(change)
+
+ case .insertText, .replaceText:
+ let conflictsWithDelete = resolvedChanges.contains { deleteOp in
+ guard deleteOp.operationType == .deleteContentRange,
+ let deleteStart = deleteOp.operationStartIndex,
+ let deleteEnd = deleteOp.operationEndIndex,
+ let insertPos = change.operationStartIndex else {
+ return false
+ }
+
+ return insertPos >= deleteStart && insertPos < deleteEnd
+ }
+
+ if conflictsWithDelete {
+ if let conflictingDelete = resolvedChanges.first(where: { deleteOp in
+ guard deleteOp.operationType == .deleteContentRange,
+ let deleteStart = deleteOp.operationStartIndex,
+ let deleteEnd = deleteOp.operationEndIndex,
+ let insertPos = change.operationStartIndex else {
+ return false
+ }
+ return insertPos >= deleteStart && insertPos < deleteEnd
+ }) {
+ let adjustedChange = DiffChangeData(
+ operationIndex: change.operationIndex,
+ operationType: change.operationType,
+ status: change.status,
+ operationText: change.operationText,
+ operationStartIndex: conflictingDelete.operationStartIndex,
+ operationEndIndex: change.operationEndIndex
+ )
+
+ resolvedChanges.append(adjustedChange)
+ }
+ } else {
+ resolvedChanges.append(change)
+ }
+ }
+ }
+
+ return resolvedChanges
+ }
+
+ // MARK: - Helper Methods
+
+ private func getGoogleDocsIndex(for textPosition: Int, offsetMap: [Int: Int]) -> Int {
+ if let exactIndex = offsetMap[textPosition] {
+ return exactIndex
+ }
+
+ let sortedKeys = offsetMap.keys.sorted()
+
+ var closestKey: Int? = nil
+ for key in sortedKeys {
+ if key <= textPosition {
+ closestKey = key
+ } else {
+ break
+ }
+ }
+
+ guard let baseKey = closestKey,
+ let baseGoogleIndex = offsetMap[baseKey] else {
+ return max(1, textPosition + 1)
+ }
+
+ let offset = textPosition - baseKey
+ var nextKey: Int? = nil
+ var nextGoogleIndex: Int? = nil
+
+ for key in sortedKeys {
+ if key > baseKey {
+ nextKey = key
+ nextGoogleIndex = offsetMap[key]
+ break
+ }
+ }
+
+ if let nextK = nextKey, let nextGIndex = nextGoogleIndex {
+ let keyGap = nextK - baseKey
+ let googleGap = nextGIndex - baseGoogleIndex
+
+ if offset <= keyGap {
+ let ratio = Double(offset) / Double(keyGap)
+ let interpolatedGoogleIndex = baseGoogleIndex + Int(Double(googleGap) * ratio)
+
+ return max(1, interpolatedGoogleIndex)
+ } else {
+ let extraOffset = offset - keyGap
+
+ return max(1, nextGIndex + extraOffset)
+ }
+ } else {
+ return max(1, baseGoogleIndex + offset)
+ }
+ }
+}
diff --git a/macos/Onit/Data/Services/Google API/GoogleDocumentManager.swift b/macos/Onit/Data/Services/Google API/GoogleDocumentManager.swift
new file mode 100644
index 000000000..f5ac4834f
--- /dev/null
+++ b/macos/Onit/Data/Services/Google API/GoogleDocumentManager.swift
@@ -0,0 +1,35 @@
+//
+// GoogleDocumentManager.swift
+// Onit
+//
+// Created by Kévin Naudin on 07/01/2025.
+//
+
+import Foundation
+
+class GoogleDocumentManager {
+
+ static func readPlainText(fileId: String) async throws -> String {
+ let service = try await GoogleDocumentServiceFactory.createService(for: fileId)
+
+ return try await service.getPlainTextContent(fileId: fileId)
+ }
+
+ static func applyDiffChangesToDocument(
+ documentUrl: String,
+ diffChanges: [DiffChangeData]
+ ) async throws {
+ guard let fileId = await GoogleDriveService.extractFileId(from: documentUrl) else {
+ throw GoogleDriveServiceError.invalidUrl("Could not extract file ID from URL: \(documentUrl)")
+ }
+
+ let approvedChanges = diffChanges.filter { $0.status == .approved }
+ guard !approvedChanges.isEmpty else {
+ return
+ }
+
+ let service = try await GoogleDocumentServiceFactory.createService(for: fileId)
+
+ try await service.applyDiffChanges(fileId: fileId, diffChanges: approvedChanges)
+ }
+}
diff --git a/macos/Onit/Data/Services/Google API/GoogleDocumentServiceFactory.swift b/macos/Onit/Data/Services/Google API/GoogleDocumentServiceFactory.swift
new file mode 100644
index 000000000..626aa83f5
--- /dev/null
+++ b/macos/Onit/Data/Services/Google API/GoogleDocumentServiceFactory.swift
@@ -0,0 +1,63 @@
+//
+// GoogleDocumentServiceFactory.swift
+// Onit
+//
+// Created by Kévin Naudin on 07/01/2025.
+//
+
+import Foundation
+import GoogleSignIn
+
+class GoogleDocumentServiceFactory {
+
+ static func createService(for fileId: String) async throws -> GoogleDocumentServiceProtocol {
+ let mimeType = try await getMimeType(for: fileId)
+
+ switch mimeType {
+ case .docs:
+ return GoogleDocsService()
+ case .sheet:
+ return GoogleSheetsService()
+ case .slide:
+ return GoogleSlidesService()
+ default:
+ throw GoogleDriveServiceError.invalidUrl("Unsupported MIME type: \(mimeType.rawValue)")
+ }
+ }
+
+ private static func getMimeType(for fileId: String) async throws -> GoogleDocumentMimeType {
+ guard var user = GIDSignIn.sharedInstance.currentUser else {
+ throw GoogleDriveServiceError.notAuthenticated("Not authenticated with Google Drive")
+ }
+
+ do {
+ user = try await user.refreshTokensIfNeeded()
+ } catch {
+ print("Token refresh was unsuccessful: \(error)")
+ }
+
+ let accessToken = user.accessToken.tokenString
+ let urlString = "https://www.googleapis.com/drive/v3/files/\(fileId)?fields=mimeType"
+
+ guard let url = URL(string: urlString) else {
+ throw GoogleDriveServiceError.invalidUrl("Invalid Google Drive API URL")
+ }
+
+ var request = URLRequest(url: url)
+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
+ throw GoogleDriveServiceError.invalidResponse("Failed to get file metadata")
+ }
+
+ let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
+ guard let mimeTypeString = json?["mimeType"] as? String,
+ let mimeType = GoogleDocumentMimeType(rawValue: mimeTypeString) else {
+ throw GoogleDriveServiceError.invalidResponse("Unknown or unsupported MIME type")
+ }
+
+ return mimeType
+ }
+}
diff --git a/macos/Onit/Data/Services/Google API/GoogleDocumentServiceProtocol.swift b/macos/Onit/Data/Services/Google API/GoogleDocumentServiceProtocol.swift
new file mode 100644
index 000000000..66573832f
--- /dev/null
+++ b/macos/Onit/Data/Services/Google API/GoogleDocumentServiceProtocol.swift
@@ -0,0 +1,72 @@
+//
+// GoogleDocumentServiceProtocol.swift
+// Onit
+//
+// Created by Kévin Naudin on 07/01/2025.
+//
+
+import Foundation
+import GoogleSignIn
+
+protocol GoogleDocumentServiceProtocol {
+ func getPlainTextContent(fileId: String) async throws -> String
+ func applyDiffChanges(fileId: String, diffChanges: [DiffChangeData]) async throws
+ var plainTextMimeType: String { get }
+}
+
+extension GoogleDocumentServiceProtocol {
+
+ func getPlainTextContent(fileId: String) async throws -> String {
+ guard var user = GIDSignIn.sharedInstance.currentUser else {
+ throw GoogleDriveServiceError.notAuthenticated("Not authenticated with Google Drive")
+ }
+
+ do {
+ user = try await user.refreshTokensIfNeeded()
+ } catch {
+ print("Token refresh was unsuccessful: \(error)")
+ }
+
+ let accessToken = user.accessToken.tokenString
+ let exportUrl = "https://www.googleapis.com/drive/v3/files/\(fileId)/export?mimeType=\(plainTextMimeType)"
+
+ guard let url = URL(string: exportUrl) else {
+ throw GoogleDriveServiceError.invalidUrl("Invalid export URL")
+ }
+
+ var request = URLRequest(url: url)
+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw GoogleDriveServiceError.invalidResponse("Invalid response")
+ }
+
+ if httpResponse.statusCode == 404 {
+ throw GoogleDriveServiceError.notFound("Onit needs permission to access this file.")
+ } else if httpResponse.statusCode == 403 {
+ var extractionError = "Onit can't access this file."
+
+ if let errorMessage = await GoogleDriveService.extractApiErrorMessage(from: data) {
+ extractionError += "\n\nError message: \(errorMessage)"
+ }
+
+ throw GoogleDriveServiceError.accessDenied(extractionError)
+ } else if httpResponse.statusCode != 200 {
+ var extractionError = "Failed to retrieve document"
+
+ if let errorMessage = await GoogleDriveService.extractApiErrorMessage(from: data) {
+ extractionError += "\n\nError message: \(errorMessage)"
+ }
+
+ throw GoogleDriveServiceError.httpError(httpResponse.statusCode, extractionError)
+ }
+
+ return String(data: data, encoding: .utf8) ?? ""
+ }
+
+ func applyDiffChanges(fileId: String, diffChanges: [DiffChangeData]) async throws {
+ throw GoogleDriveServiceError.unsupportedFileType("Diff changes application not implemented for this document type")
+ }
+}
diff --git a/macos/Onit/Data/Services/Google API/GoogleSheetsService.swift b/macos/Onit/Data/Services/Google API/GoogleSheetsService.swift
new file mode 100644
index 000000000..326b7bc20
--- /dev/null
+++ b/macos/Onit/Data/Services/Google API/GoogleSheetsService.swift
@@ -0,0 +1,447 @@
+//
+// GoogleSheetsService.swift
+// Onit
+//
+// Created by Kévin Naudin on 07/01/2025.
+//
+
+import Foundation
+import GoogleSignIn
+
+class GoogleSheetsService: GoogleDocumentServiceProtocol {
+
+ var plainTextMimeType: String {
+ return "text/csv"
+ }
+
+ private func readStructuredFile(fileId: String) async throws -> [String: Any] {
+ guard let user = GIDSignIn.sharedInstance.currentUser else {
+ throw GoogleDriveServiceError.notAuthenticated("Not authenticated with Google Drive")
+ }
+
+ let accessToken = user.accessToken.tokenString
+ let apiUrl = "https://sheets.googleapis.com/v4/spreadsheets/\(fileId)"
+
+ guard let url = URL(string: apiUrl) else {
+ throw GoogleDriveServiceError.invalidUrl("Invalid Google Sheets API URL")
+ }
+
+ var request = URLRequest(url: url)
+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw GoogleDriveServiceError.invalidResponse("Invalid response")
+ }
+
+ if httpResponse.statusCode == 404 {
+ throw GoogleDriveServiceError.notFound("Onit needs permission to access this file.")
+ } else if httpResponse.statusCode == 403 {
+ var errorMessage = "Onit can't access this file."
+ if let errorData = String(data: data, encoding: .utf8) {
+ errorMessage += "\n\nError message: \(errorData)"
+ }
+ throw GoogleDriveServiceError.accessDenied(errorMessage)
+ } else if httpResponse.statusCode != 200 {
+ var errorMessage = "Failed to retrieve document (HTTP \(httpResponse.statusCode))"
+ if let errorData = String(data: data, encoding: .utf8) {
+ errorMessage += "\n\nError message: \(errorData)"
+ }
+ throw GoogleDriveServiceError.httpError(httpResponse.statusCode, errorMessage)
+ }
+
+ guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw GoogleDriveServiceError.decodingError("Failed to decode document content")
+ }
+
+ return json
+ }
+
+ func updateFile(fileId: String, operations: [GoogleSheetsOperation]) async throws {
+ guard let user = GIDSignIn.sharedInstance.currentUser else {
+ throw GoogleDriveServiceError.notAuthenticated("Not authenticated with Google Drive")
+ }
+
+ let accessToken = user.accessToken.tokenString
+ let urlString = "https://sheets.googleapis.com/v4/spreadsheets/\(fileId):batchUpdate"
+
+ guard let url = URL(string: urlString) else {
+ throw GoogleDriveServiceError.invalidUrl("Invalid batchUpdate URL")
+ }
+
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ let requests = convertToAPIRequests(operations: operations)
+ let body: [String: Any] = [ "requests": requests ]
+
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+ let (_, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
+ throw GoogleDriveServiceError.invalidResponse("Invalid response")
+ }
+ }
+
+ func convertToAPIRequests(operations: [GoogleSheetsOperation]) -> [[String: Any]] {
+ var requests: [[String: Any]] = []
+
+ for operation in operations {
+ switch operation {
+ case .updateCells(let range, let values):
+ let rows = values.map { rowValues in
+ [
+ "values": rowValues.map { cellValue in
+ [
+ "userEnteredValue": [
+ "stringValue": cellValue
+ ]
+ ]
+ }
+ ]
+ }
+
+ requests.append([
+ "updateCells": [
+ "range": [
+ "sheetId": range.sheetId,
+ "startRowIndex": range.startRowIndex,
+ "endRowIndex": range.endRowIndex,
+ "startColumnIndex": range.startColumnIndex,
+ "endColumnIndex": range.endColumnIndex
+ ],
+ "rows": rows,
+ "fields": "userEnteredValue"
+ ]
+ ])
+
+ case .insertRows(let sheetId, let startIndex, let endIndex):
+ requests.append([
+ "insertDimension": [
+ "range": [
+ "sheetId": sheetId,
+ "dimension": "ROWS",
+ "startIndex": startIndex,
+ "endIndex": endIndex
+ ],
+ "inheritFromBefore": false
+ ]
+ ])
+
+ case .insertColumns(let sheetId, let startIndex, let endIndex):
+ requests.append([
+ "insertDimension": [
+ "range": [
+ "sheetId": sheetId,
+ "dimension": "COLUMNS",
+ "startIndex": startIndex,
+ "endIndex": endIndex
+ ],
+ "inheritFromBefore": false
+ ]
+ ])
+
+ case .deleteRows(let sheetId, let startIndex, let endIndex):
+ requests.append([
+ "deleteDimension": [
+ "range": [
+ "sheetId": sheetId,
+ "dimension": "ROWS",
+ "startIndex": startIndex,
+ "endIndex": endIndex
+ ]
+ ]
+ ])
+
+ case .deleteColumns(let sheetId, let startIndex, let endIndex):
+ requests.append([
+ "deleteDimension": [
+ "range": [
+ "sheetId": sheetId,
+ "dimension": "COLUMNS",
+ "startIndex": startIndex,
+ "endIndex": endIndex
+ ]
+ ]
+ ])
+
+ case .mergeCells(let range):
+ requests.append([
+ "mergeCells": [
+ "range": [
+ "sheetId": range.sheetId,
+ "startRowIndex": range.startRowIndex,
+ "endRowIndex": range.endRowIndex,
+ "startColumnIndex": range.startColumnIndex,
+ "endColumnIndex": range.endColumnIndex
+ ],
+ "mergeType": "MERGE_ALL"
+ ]
+ ])
+
+ case .unmergeCells(let range):
+ requests.append([
+ "unmergeCells": [
+ "range": [
+ "sheetId": range.sheetId,
+ "startRowIndex": range.startRowIndex,
+ "endRowIndex": range.endRowIndex,
+ "startColumnIndex": range.startColumnIndex,
+ "endColumnIndex": range.endColumnIndex
+ ]
+ ]
+ ])
+
+ case .addSheet(let title):
+ requests.append([
+ "addSheet": [
+ "properties": [
+ "title": title
+ ]
+ ]
+ ])
+
+ case .updateCellFormat(let range, let format):
+ var cellFormatDict: [String: Any] = [:]
+
+ if let numberFormat = format.numberFormat {
+ cellFormatDict["numberFormat"] = [
+ "type": numberFormat.type,
+ "pattern": numberFormat.pattern ?? ""
+ ]
+ }
+
+ if let backgroundColor = format.backgroundColor {
+ cellFormatDict["backgroundColor"] = [
+ "red": (backgroundColor.red ?? 0) as Any,
+ "green": (backgroundColor.green ?? 0) as Any,
+ "blue": (backgroundColor.blue ?? 0) as Any,
+ "alpha": (backgroundColor.alpha ?? 1) as Any
+ ]
+ }
+
+ if let textFormat = format.textFormat {
+ var textFormatDict: [String: Any] = [:]
+ if let foregroundColor = textFormat.foregroundColor {
+ textFormatDict["foregroundColor"] = [
+ "red": (foregroundColor.red ?? 0) as Any,
+ "green": (foregroundColor.green ?? 0) as Any,
+ "blue": (foregroundColor.blue ?? 0) as Any,
+ "alpha": (foregroundColor.alpha ?? 1) as Any
+ ]
+ }
+ if let fontFamily = textFormat.fontFamily { textFormatDict["fontFamily"] = fontFamily }
+ if let fontSize = textFormat.fontSize { textFormatDict["fontSize"] = fontSize }
+ if let bold = textFormat.bold { textFormatDict["bold"] = bold }
+ if let italic = textFormat.italic { textFormatDict["italic"] = italic }
+
+ cellFormatDict["textFormat"] = textFormatDict
+ }
+
+ requests.append([
+ "repeatCell": [
+ "range": [
+ "sheetId": range.sheetId,
+ "startRowIndex": range.startRowIndex,
+ "endRowIndex": range.endRowIndex,
+ "startColumnIndex": range.startColumnIndex,
+ "endColumnIndex": range.endColumnIndex
+ ],
+ "cell": [
+ "userEnteredFormat": cellFormatDict
+ ],
+ "fields": "userEnteredFormat"
+ ]
+ ])
+
+ case .addChart(let sheetId, let chartSpec):
+ var chartSpecDict: [String: Any] = [:]
+
+ if let title = chartSpec.title {
+ chartSpecDict["title"] = title
+ }
+
+ if let basicChart = chartSpec.basicChart {
+ chartSpecDict["basicChart"] = [
+ "chartType": basicChart.chartType,
+ "legendPosition": basicChart.legendPosition ?? "BOTTOM_LEGEND"
+ ]
+ }
+
+ requests.append([
+ "addChart": [
+ "chart": [
+ "spec": chartSpecDict,
+ "position": [
+ "overlayPosition": [
+ "anchorCell": [
+ "sheetId": sheetId,
+ "rowIndex": 0,
+ "columnIndex": 0
+ ]
+ ]
+ ]
+ ]
+ ]
+ ])
+
+ case .setFormula(let range, let formula):
+ requests.append([
+ "updateCells": [
+ "range": [
+ "sheetId": range.sheetId,
+ "startRowIndex": range.startRowIndex,
+ "endRowIndex": range.endRowIndex,
+ "startColumnIndex": range.startColumnIndex,
+ "endColumnIndex": range.endColumnIndex
+ ],
+ "rows": [
+ [
+ "values": [
+ [
+ "userEnteredValue": [
+ "formulaValue": formula
+ ]
+ ]
+ ]
+ ]
+ ],
+ "fields": "userEnteredValue"
+ ]
+ ])
+ }
+ }
+
+ return requests
+ }
+
+ // MARK: - Private Helper Methods
+
+ private func parseGoogleSheetsSpreadsheet(from data: [String: Any]) throws -> GoogleSheetsSpreadsheet {
+ guard let spreadsheetId = data["spreadsheetId"] as? String,
+ let propertiesData = data["properties"] as? [String: Any],
+ let sheetsArray = data["sheets"] as? [[String: Any]] else {
+ throw GoogleDriveServiceError.invalidResponse("Invalid Google Sheets structure")
+ }
+
+ let properties = parseGoogleSheetsSpreadsheetProperties(from: propertiesData)
+ let sheets = sheetsArray.compactMap { sheetData in
+ parseGoogleSheetsSheet(from: sheetData)
+ }
+
+ return GoogleSheetsSpreadsheet(
+ spreadsheetId: spreadsheetId,
+ properties: properties,
+ sheets: sheets
+ )
+ }
+
+ private func parseGoogleSheetsSpreadsheetProperties(from data: [String: Any]) -> GoogleSheetsSpreadsheetProperties {
+ let title = data["title"] as? String ?? "Untitled Spreadsheet"
+ let locale = data["locale"] as? String
+ let autoRecalc = data["autoRecalc"] as? String
+
+ return GoogleSheetsSpreadsheetProperties(
+ title: title,
+ locale: locale,
+ autoRecalc: autoRecalc
+ )
+ }
+
+ private func parseGoogleSheetsSheet(from data: [String: Any]) -> GoogleSheetsSheet? {
+ guard let propertiesData = data["properties"] as? [String: Any] else {
+ return nil
+ }
+
+ let properties = parseGoogleSheetsSheetProperties(from: propertiesData)
+ let data_array = data["data"] as? [[String: Any]]
+ let gridData = data_array?.compactMap { parseGoogleSheetsGridData(from: $0) }
+
+ return GoogleSheetsSheet(
+ properties: properties,
+ data: gridData,
+ merges: nil, // Simplified for now
+ charts: nil // Simplified for now
+ )
+ }
+
+ private func parseGoogleSheetsSheetProperties(from data: [String: Any]) -> GoogleSheetsSheetProperties {
+ let sheetId = data["sheetId"] as? Int ?? 0
+ let title = data["title"] as? String ?? "Sheet1"
+ let sheetType = data["sheetType"] as? String
+
+ var gridProperties: GoogleSheetsGridProperties?
+ if let gridPropsData = data["gridProperties"] as? [String: Any] {
+ gridProperties = GoogleSheetsGridProperties(
+ rowCount: gridPropsData["rowCount"] as? Int ?? 1000,
+ columnCount: gridPropsData["columnCount"] as? Int ?? 26,
+ frozenRowCount: gridPropsData["frozenRowCount"] as? Int,
+ frozenColumnCount: gridPropsData["frozenColumnCount"] as? Int
+ )
+ }
+
+ return GoogleSheetsSheetProperties(
+ sheetId: sheetId,
+ title: title,
+ sheetType: sheetType,
+ gridProperties: gridProperties
+ )
+ }
+
+ private func parseGoogleSheetsGridData(from data: [String: Any]) -> GoogleSheetsGridData? {
+ let startRow = data["startRow"] as? Int
+ let startColumn = data["startColumn"] as? Int
+
+ guard let rowDataArray = data["rowData"] as? [[String: Any]] else {
+ return nil
+ }
+
+ let rowData = rowDataArray.compactMap { parseGoogleSheetsRowData(from: $0) }
+
+ return GoogleSheetsGridData(
+ startRow: startRow,
+ startColumn: startColumn,
+ rowData: rowData
+ )
+ }
+
+ private func parseGoogleSheetsRowData(from data: [String: Any]) -> GoogleSheetsRowData? {
+ guard let valuesArray = data["values"] as? [[String: Any]] else {
+ return nil
+ }
+
+ let values = valuesArray.compactMap { parseGoogleSheetsCellData(from: $0) }
+
+ return GoogleSheetsRowData(values: values)
+ }
+
+ private func parseGoogleSheetsCellData(from data: [String: Any]) -> GoogleSheetsCellData {
+ var userEnteredValue: GoogleSheetsExtendedValue?
+ if let valueData = data["userEnteredValue"] as? [String: Any] {
+ userEnteredValue = parseGoogleSheetsExtendedValue(from: valueData)
+ }
+
+ let formattedValue = data["formattedValue"] as? String
+
+ return GoogleSheetsCellData(
+ userEnteredValue: userEnteredValue,
+ formattedValue: formattedValue,
+ userEnteredFormat: nil, // Simplified for now
+ effectiveFormat: nil, // Simplified for now
+ effectiveValue: nil // Simplified for now
+ )
+ }
+
+ private func parseGoogleSheetsExtendedValue(from data: [String: Any]) -> GoogleSheetsExtendedValue {
+ return GoogleSheetsExtendedValue(
+ numberValue: data["numberValue"] as? Double,
+ stringValue: data["stringValue"] as? String,
+ boolValue: data["boolValue"] as? Bool,
+ formulaValue: data["formulaValue"] as? String
+ )
+ }
+}
diff --git a/macos/Onit/Data/Services/Google API/GoogleSlidesService.swift b/macos/Onit/Data/Services/Google API/GoogleSlidesService.swift
new file mode 100644
index 000000000..3f17bd0b6
--- /dev/null
+++ b/macos/Onit/Data/Services/Google API/GoogleSlidesService.swift
@@ -0,0 +1,478 @@
+//
+// GoogleSlidesService.swift
+// Onit
+//
+// Created by Kévin Naudin on 07/01/2025.
+//
+
+import Foundation
+import GoogleSignIn
+
+class GoogleSlidesService: GoogleDocumentServiceProtocol {
+
+ var plainTextMimeType: String {
+ return "text/plain"
+ }
+
+ private func readStructuredFile(fileId: String) async throws -> [String: Any] {
+ guard let user = GIDSignIn.sharedInstance.currentUser else {
+ throw GoogleDriveServiceError.notAuthenticated("Not authenticated with Google Drive")
+ }
+
+ let accessToken = user.accessToken.tokenString
+ let apiUrl = "https://slides.googleapis.com/v1/presentations/\(fileId)"
+
+ guard let url = URL(string: apiUrl) else {
+ throw GoogleDriveServiceError.invalidUrl("Invalid Google Slides API URL")
+ }
+
+ var request = URLRequest(url: url)
+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw GoogleDriveServiceError.invalidResponse("Invalid response")
+ }
+
+ if httpResponse.statusCode == 404 {
+ throw GoogleDriveServiceError.notFound("Onit needs permission to access this file.")
+ } else if httpResponse.statusCode == 403 {
+ var errorMessage = "Onit can't access this file."
+ if let errorData = String(data: data, encoding: .utf8) {
+ errorMessage += "\n\nError message: \(errorData)"
+ }
+ throw GoogleDriveServiceError.accessDenied(errorMessage)
+ } else if httpResponse.statusCode != 200 {
+ var errorMessage = "Failed to retrieve document (HTTP \(httpResponse.statusCode))"
+ if let errorData = String(data: data, encoding: .utf8) {
+ errorMessage += "\n\nError message: \(errorData)"
+ }
+ throw GoogleDriveServiceError.httpError(httpResponse.statusCode, errorMessage)
+ }
+
+ guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw GoogleDriveServiceError.decodingError("Failed to decode document content")
+ }
+
+ return json
+ }
+
+ func updateFile(fileId: String, operations: [GoogleSlidesOperation]) async throws {
+ guard let user = GIDSignIn.sharedInstance.currentUser else {
+ throw GoogleDriveServiceError.notAuthenticated("Not authenticated with Google Drive")
+ }
+
+ let accessToken = user.accessToken.tokenString
+ let urlString = "https://slides.googleapis.com/v1/presentations/\(fileId):batchUpdate"
+
+ guard let url = URL(string: urlString) else {
+ throw GoogleDriveServiceError.invalidUrl("Invalid batchUpdate URL")
+ }
+
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ let requests = convertToAPIRequests(operations: operations)
+ let body: [String: Any] = [ "requests": requests ]
+
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+ let (_, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
+ throw GoogleDriveServiceError.invalidResponse("Invalid response")
+ }
+ }
+
+ func convertToAPIRequests(operations: [GoogleSlidesOperation]) -> [[String: Any]] {
+ var requests: [[String: Any]] = []
+
+ for operation in operations {
+ switch operation {
+ case .insertText(let objectId, let insertionIndex, let text):
+ requests.append([
+ "insertText": [
+ "objectId": objectId,
+ "insertionIndex": insertionIndex,
+ "text": text
+ ]
+ ])
+
+ case .deleteText(let objectId, let textRange):
+ var rangeDict: [String: Any] = [:]
+ if let startIndex = textRange.startIndex { rangeDict["startIndex"] = startIndex }
+ if let endIndex = textRange.endIndex { rangeDict["endIndex"] = endIndex }
+ if let type = textRange.type { rangeDict["type"] = type }
+
+ requests.append([
+ "deleteText": [
+ "objectId": objectId,
+ "textRange": rangeDict
+ ]
+ ])
+
+ case .replaceAllText(let containsText, let replaceText):
+ requests.append([
+ "replaceAllText": [
+ "containsText": [
+ "text": containsText,
+ "matchCase": false
+ ],
+ "replaceText": replaceText
+ ]
+ ])
+
+ case .createSlide(let slideLayoutReference, let insertionIndex):
+ var slideRequest: [String: Any] = [:]
+
+ if let layoutRef = slideLayoutReference {
+ var layoutDict: [String: Any] = [:]
+ if let layoutId = layoutRef.layoutId { layoutDict["layoutId"] = layoutId }
+ if let predefinedLayout = layoutRef.predefinedLayout { layoutDict["predefinedLayout"] = predefinedLayout }
+ slideRequest["slideLayoutReference"] = layoutDict
+ }
+
+ if let index = insertionIndex {
+ slideRequest["insertionIndex"] = index
+ }
+
+ requests.append([
+ "createSlide": slideRequest
+ ])
+
+ case .deleteSlide(let slideId):
+ requests.append([
+ "deleteObject": [
+ "objectId": slideId
+ ]
+ ])
+
+ case .createShape(let slideId, let shapeType, let elementProperties):
+ var shapeRequest: [String: Any] = [
+ "objectId": UUID().uuidString,
+ "shapeType": shapeType,
+ "elementProperties": [
+ "pageObjectId": elementProperties.pageObjectId
+ ]
+ ]
+
+ if let size = elementProperties.size {
+ shapeRequest["elementProperties"] = [
+ "pageObjectId": elementProperties.pageObjectId,
+ "size": [
+ "magnitude": size.magnitude,
+ "unit": size.unit
+ ]
+ ]
+ }
+
+ requests.append([
+ "createShape": shapeRequest
+ ])
+
+ case .createTextBox(let slideId, let elementProperties):
+ requests.append([
+ "createShape": [
+ "objectId": UUID().uuidString,
+ "shapeType": "TEXT_BOX",
+ "elementProperties": [
+ "pageObjectId": elementProperties.pageObjectId,
+ "size": elementProperties.size.map { size in
+ [
+ "magnitude": size.magnitude,
+ "unit": size.unit
+ ]
+ } ?? [
+ "magnitude": 200,
+ "unit": "PT"
+ ]
+ ]
+ ]
+ ])
+
+ case .createImage(let slideId, let imageUrl, let elementProperties):
+ requests.append([
+ "createImage": [
+ "objectId": UUID().uuidString,
+ "url": imageUrl,
+ "elementProperties": [
+ "pageObjectId": elementProperties.pageObjectId,
+ "size": elementProperties.size.map { size in
+ [
+ "magnitude": size.magnitude,
+ "unit": size.unit
+ ]
+ } ?? [
+ "magnitude": 300,
+ "unit": "PT"
+ ]
+ ]
+ ]
+ ])
+
+ case .updateShapeProperties(let objectId, let shapeProperties):
+ var propertiesDict: [String: Any] = [:]
+
+ if let backgroundFill = shapeProperties.shapeBackgroundFill {
+ propertiesDict["shapeBackgroundFill"] = [
+ "solidFill": [
+ "color": [
+ "rgbColor": [
+ "red": 0.8,
+ "green": 0.8,
+ "blue": 0.8
+ ]
+ ]
+ ]
+ ]
+ }
+
+ requests.append([
+ "updateShapeProperties": [
+ "objectId": objectId,
+ "shapeProperties": propertiesDict,
+ "fields": propertiesDict.keys.joined(separator: ",")
+ ]
+ ])
+
+ case .updateTextStyle(let objectId, let textRange, let style):
+ var styleDict: [String: Any] = [:]
+
+ if let bold = style.bold { styleDict["bold"] = bold }
+ if let italic = style.italic { styleDict["italic"] = italic }
+ if let fontFamily = style.fontFamily { styleDict["fontFamily"] = fontFamily }
+ if let fontSize = style.fontSize {
+ styleDict["fontSize"] = [
+ "magnitude": fontSize.magnitude,
+ "unit": fontSize.unit
+ ]
+ }
+
+ var rangeDict: [String: Any] = [:]
+ if let range = textRange {
+ if let startIndex = range.startIndex { rangeDict["startIndex"] = startIndex }
+ if let endIndex = range.endIndex { rangeDict["endIndex"] = endIndex }
+ }
+
+ requests.append([
+ "updateTextStyle": [
+ "objectId": objectId,
+ "textRange": rangeDict,
+ "style": styleDict,
+ "fields": styleDict.keys.joined(separator: ",")
+ ]
+ ])
+
+ case .updateParagraphStyle(let objectId, let textRange, let style):
+ var styleDict: [String: Any] = [:]
+
+ if let alignment = style.alignment { styleDict["alignment"] = alignment }
+ if let lineSpacing = style.lineSpacing { styleDict["lineSpacing"] = lineSpacing }
+
+ var rangeDict: [String: Any] = [:]
+ if let range = textRange {
+ if let startIndex = range.startIndex { rangeDict["startIndex"] = startIndex }
+ if let endIndex = range.endIndex { rangeDict["endIndex"] = endIndex }
+ }
+
+ requests.append([
+ "updateParagraphStyle": [
+ "objectId": objectId,
+ "textRange": rangeDict,
+ "style": styleDict,
+ "fields": styleDict.keys.joined(separator: ",")
+ ]
+ ])
+
+ case .groupObjects(let childrenObjectIds):
+ requests.append([
+ "groupObjects": [
+ "childrenObjectIds": childrenObjectIds
+ ]
+ ])
+
+ case .ungroupObjects(let objectIds):
+ for objectId in objectIds {
+ requests.append([
+ "ungroupObjects": [
+ "objectIds": [objectId]
+ ]
+ ])
+ }
+
+ case .duplicateObject(let objectId, let objectIds):
+ requests.append([
+ "duplicateObject": [
+ "objectId": objectId,
+ "objectIds": objectIds
+ ]
+ ])
+ }
+ }
+
+ return requests
+ }
+
+ // MARK: - Private Helper Methods
+
+ private func parseGoogleSlidesPresentation(from data: [String: Any]) throws -> GoogleSlidesPresentation {
+ guard let presentationId = data["presentationId"] as? String,
+ let title = data["title"] as? String,
+ let slidesArray = data["slides"] as? [[String: Any]] else {
+ throw GoogleDriveServiceError.invalidResponse("Invalid Google Slides structure")
+ }
+
+ let slides = slidesArray.compactMap { slideData in
+ parseGoogleSlidesSlide(from: slideData)
+ }
+
+ let defaultPageSize = GoogleSlidesSize(magnitude: 720, unit: "PT")
+
+ return GoogleSlidesPresentation(
+ presentationId: presentationId,
+ pageSize: parseGoogleSlidesSize(from: data["pageSize"] as? [String: Any]) ?? defaultPageSize,
+ slides: slides,
+ title: title,
+ masters: nil,
+ layouts: nil
+ )
+ }
+
+ private func parseGoogleSlidesSlide(from data: [String: Any]) -> GoogleSlidesSlide? {
+ guard let objectId = data["objectId"] as? String else {
+ return nil
+ }
+
+ let pageElementsArray = data["pageElements"] as? [[String: Any]]
+ let pageElements = pageElementsArray?.compactMap { parseGoogleSlidesPageElement(from: $0) }
+
+ return GoogleSlidesSlide(
+ objectId: objectId,
+ pageElements: pageElements,
+ slideProperties: parseGoogleSlidesSlideProperties(from: data["slideProperties"] as? [String: Any]),
+ notesPage: nil
+ )
+ }
+
+ private func parseGoogleSlidesPageElement(from data: [String: Any]) -> GoogleSlidesPageElement? {
+ guard let objectId = data["objectId"] as? String else {
+ return nil
+ }
+
+ let size = parseGoogleSlidesSize(from: data["size"] as? [String: Any])
+ let transform = parseGoogleSlidesAffineTransform(from: data["transform"] as? [String: Any])
+
+ return GoogleSlidesPageElement(
+ objectId: objectId,
+ size: size,
+ transform: transform,
+ title: data["title"] as? String,
+ description: data["description"] as? String,
+ shape: parseGoogleSlidesShape(from: data["shape"] as? [String: Any]),
+ image: parseGoogleSlidesImage(from: data["image"] as? [String: Any]),
+ video: nil, // Simplified for now
+ table: nil, // Simplified for now
+ wordArt: nil, // Simplified for now
+ line: nil, // Simplified for now
+ sheetsChart: nil // Simplified for now
+ )
+ }
+
+ private func parseGoogleSlidesSize(from data: [String: Any]?) -> GoogleSlidesSize? {
+ guard let data = data,
+ let magnitude = data["magnitude"] as? Double,
+ let unit = data["unit"] as? String else {
+ return nil
+ }
+
+ return GoogleSlidesSize(magnitude: magnitude, unit: unit)
+ }
+
+ private func parseGoogleSlidesAffineTransform(from data: [String: Any]?) -> GoogleSlidesAffineTransform? {
+ guard let data = data else { return nil }
+
+ return GoogleSlidesAffineTransform(
+ scaleX: data["scaleX"] as? Double ?? 1.0,
+ scaleY: data["scaleY"] as? Double ?? 1.0,
+ shearX: data["shearX"] as? Double ?? 0.0,
+ shearY: data["shearY"] as? Double ?? 0.0,
+ translateX: data["translateX"] as? Double ?? 0.0,
+ translateY: data["translateY"] as? Double ?? 0.0,
+ unit: data["unit"] as? String ?? "PT"
+ )
+ }
+
+ private func parseGoogleSlidesShape(from data: [String: Any]?) -> GoogleSlidesShape? {
+ guard let data = data,
+ let shapeType = data["shapeType"] as? String else {
+ return nil
+ }
+
+ return GoogleSlidesShape(
+ shapeType: shapeType,
+ text: parseGoogleSlidesTextContent(from: data["text"] as? [String: Any]),
+ shapeProperties: nil // Simplified for now
+ )
+ }
+
+ private func parseGoogleSlidesImage(from data: [String: Any]?) -> GoogleSlidesImage? {
+ guard let data = data else { return nil }
+
+ return GoogleSlidesImage(
+ contentUrl: data["contentUrl"] as? String,
+ imageProperties: nil // Simplified for now
+ )
+ }
+
+ private func parseGoogleSlidesTextContent(from data: [String: Any]?) -> GoogleSlidesTextContent? {
+ guard let data = data,
+ let textElementsArray = data["textElements"] as? [[String: Any]] else {
+ return nil
+ }
+
+ let textElements = textElementsArray.compactMap { parseGoogleSlidesTextElement(from: $0) }
+
+ return GoogleSlidesTextContent(
+ textElements: textElements,
+ lists: nil // Simplified for now
+ )
+ }
+
+ private func parseGoogleSlidesTextElement(from data: [String: Any]) -> GoogleSlidesTextElement? {
+ let startIndex = data["startIndex"] as? Int
+ let endIndex = data["endIndex"] as? Int
+
+ return GoogleSlidesTextElement(
+ endIndex: endIndex ?? 0,
+ startIndex: startIndex,
+ paragraphMarker: nil, // Simplified for now
+ textRun: parseGoogleSlidesTextRun(from: data["textRun"] as? [String: Any]),
+ autoText: nil // Simplified for now
+ )
+ }
+
+ private func parseGoogleSlidesTextRun(from data: [String: Any]?) -> GoogleSlidesTextRun? {
+ guard let data = data,
+ let content = data["content"] as? String else {
+ return nil
+ }
+
+ return GoogleSlidesTextRun(
+ content: content,
+ style: nil // Simplified for now
+ )
+ }
+
+ private func parseGoogleSlidesSlideProperties(from data: [String: Any]?) -> GoogleSlidesSlideProperties? {
+ guard let data = data else { return nil }
+
+ return GoogleSlidesSlideProperties(
+ layoutObjectId: data["layoutObjectId"] as? String,
+ masterObjectId: data["masterObjectId"] as? String,
+ notesPage: nil // Simplified for now
+ )
+ }
+}
diff --git a/macos/Onit/Data/Services/GoogleDriveService.swift b/macos/Onit/Data/Services/GoogleDriveService.swift
index d36bc339c..558548616 100644
--- a/macos/Onit/Data/Services/GoogleDriveService.swift
+++ b/macos/Onit/Data/Services/GoogleDriveService.swift
@@ -153,91 +153,21 @@ class GoogleDriveService: NSObject, ObservableObject {
self.isExtracting = false
throw GoogleDriveServiceError.invalidUrl(error)
}
-
- guard var user = GIDSignIn.sharedInstance.currentUser else {
- let error = "Not authenticated with Google Drive"
- self.extractionError = error
- self.isExtracting = false
- throw GoogleDriveServiceError.notAuthenticated(error)
- }
-
+
do {
- user = try await user.refreshTokensIfNeeded()
- } catch {
- print("Token refresh was unsuccessful: \(error)")
- }
-
- let accessToken = user.accessToken.tokenString
-
- // Determine document type and appropriate MIME type for export
- let mimeType = getMimeTypeForUrl(driveUrl)
-
- // Use Google Drive API to export the document
- let exportUrl =
- "https://www.googleapis.com/drive/v3/files/\(fileId)/export?mimeType=\(mimeType)"
-
- guard let url = URL(string: exportUrl) else {
- let error = "Invalid export URL"
- self.extractionError = error
+ let text = try await GoogleDocumentManager.readPlainText(fileId: fileId)
self.isExtracting = false
- throw GoogleDriveServiceError.invalidUrl(error)
- }
-
- var request = URLRequest(url: url)
- request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
-
- let (data, response) = try await URLSession.shared.data(for: request)
-
- self.isExtracting = false
-
- guard let httpResponse = response as? HTTPURLResponse else {
- let error = "Invalid response"
- self.extractionError = error
- throw GoogleDriveServiceError.invalidResponse(error)
- }
-
- if httpResponse.statusCode == 404 {
- let extractionError =
- "Onit needs permission to access this file."
- self.extractionError = extractionError
- throw GoogleDriveServiceError.notFound(extractionError)
- } else if httpResponse.statusCode == 403 {
- var extractionError =
- "Onit can't access this file."
- if let errorMessage = extractApiErrorMessage(from: data) {
- extractionError += "\n\nError message: \(errorMessage)"
- }
- self.extractionError = extractionError
- throw GoogleDriveServiceError.accessDenied(extractionError)
- } else if httpResponse.statusCode != 200 {
- var extractionError =
- "Failed to retrieve document"
- if let errorMessage = extractApiErrorMessage(from: data) {
- extractionError += "\n\nError message: \(errorMessage)"
+
+ if text.isEmpty {
+ return "(Document appears to be empty)"
+ } else {
+ return text
}
- self.extractionError = extractionError
- throw GoogleDriveServiceError.httpError(httpResponse.statusCode, extractionError)
- }
-
- guard let text = String(data: data, encoding: .utf8) else {
- let error = "Failed to decode document content"
- self.extractionError = error
- throw GoogleDriveServiceError.decodingError(error)
- }
-
- if text.isEmpty {
- return "(Document appears to be empty)"
- } else {
- return text
- }
- }
-
- private func extractApiErrorMessage(from data: Data) -> String? {
- if let googleDriveError = try? JSONDecoder().decode(GoogleDriveAPIError.self, from: data),
- let errorMessage = googleDriveError.error?.message {
- return errorMessage
+ } catch {
+ self.extractionError = error.localizedDescription
+ self.isExtracting = false
+ throw error
}
- return String(data: data, encoding: .utf8)
}
private func getMimeTypeForUrl(_ url: String) -> String {
@@ -254,6 +184,20 @@ class GoogleDriveService: NSObject, ObservableObject {
}
private func extractFileIdFromUrl(_ url: String) -> String? {
+ return GoogleDriveService.extractFileId(from: url)
+ }
+
+ // MARK: - Public Static Utilities
+
+ static func extractApiErrorMessage(from data: Data) -> String? {
+ if let googleDriveError = try? JSONDecoder().decode(GoogleDriveAPIError.self, from: data),
+ let errorMessage = googleDriveError.error?.message {
+ return errorMessage
+ }
+ return String(data: data, encoding: .utf8)
+ }
+
+ static func extractFileId(from url: String) -> String? {
// Handle various Google Drive URL formats
let patterns = [
#"https://docs\.google\.com/document/d/([a-zA-Z0-9-_]+)"#,
@@ -480,6 +424,7 @@ enum GoogleDriveServiceError: Error, LocalizedError {
case httpError(Int, String)
case accessDenied(String)
case invalidResponse(String)
+ case unsupportedFileType(String)
var errorDescription: String? {
switch self {
@@ -497,6 +442,8 @@ enum GoogleDriveServiceError: Error, LocalizedError {
return "Access Denied: \(message)"
case .invalidResponse(let message):
return "Invalid Response: \(message)"
+ case .unsupportedFileType(let message):
+ return "Unsupported File Type: \(message)"
}
}
}
diff --git a/macos/Onit/Data/Services/SystemPromptSuggestionService.swift b/macos/Onit/Data/Services/SystemPromptSuggestionService.swift
index cbc93c644..dfc633473 100644
--- a/macos/Onit/Data/Services/SystemPromptSuggestionService.swift
+++ b/macos/Onit/Data/Services/SystemPromptSuggestionService.swift
@@ -211,6 +211,7 @@ extension SystemPromptSuggestionService: OnitPanelStateDelegate {
func panelStateDidChange(state: OnitPanelState) {
processIfNeeded()
}
+ func panelFrameDidChange(state: OnitPanelState) { }
func userInputsDidChange(instruction: String, contexts: [Context], input: Input?) {
self.instruction = instruction
self.contexts = contexts
diff --git a/macos/Onit/Data/Structures/Context.swift b/macos/Onit/Data/Structures/Context.swift
index e043afe6f..c4bc2eb88 100644
--- a/macos/Onit/Data/Structures/Context.swift
+++ b/macos/Onit/Data/Structures/Context.swift
@@ -332,11 +332,17 @@ extension [Context] {
for context in self {
if case .auto(let autoContext) = context {
- let contentString = autoContext.appContent.values.joined(separator: "\n")
+ var parts: [String] = []
+
+ for (key, value) in autoContext.appContent {
+ parts.append("### \(key.uppercased()) ###")
+ parts.append(value)
+ }
+
+ let contentString = parts.joined(separator: "\n")
+
if let existing = result[autoContext.appName] {
- let combined = existing + "\n" + contentString
-
- result[autoContext.appName] = combined
+ result[autoContext.appName] = existing + "\n" + contentString
} else {
result[autoContext.appName] = contentString
}
diff --git a/macos/Onit/Data/Structures/Google API/GoogleDocsDocument.swift b/macos/Onit/Data/Structures/Google API/GoogleDocsDocument.swift
new file mode 100644
index 000000000..b98f9121a
--- /dev/null
+++ b/macos/Onit/Data/Structures/Google API/GoogleDocsDocument.swift
@@ -0,0 +1,127 @@
+//
+// GoogleDocsDocument.swift
+// Onit
+//
+// Created by Kévin Naudin on 07/09/2025.
+//
+
+import Foundation
+
+struct GoogleDocsDocument {
+ let documentId: String
+ let title: String
+ let body: GoogleDocsBody
+ let revisionId: String
+}
+
+struct GoogleDocsBody {
+ let content: [GoogleDocsStructuralElement]
+}
+
+struct GoogleDocsStructuralElement {
+ let startIndex: Int?
+ let endIndex: Int?
+ let paragraph: GoogleDocsParagraph?
+ let table: GoogleDocsTable?
+ let pageBreak: GoogleDocsPageBreak?
+ let sectionBreak: GoogleDocsSectionBreak?
+ let tableOfContents: GoogleDocsTableOfContents?
+}
+
+struct GoogleDocsParagraph {
+ let elements: [GoogleDocsParagraphElement]
+}
+
+struct GoogleDocsParagraphElement {
+ let startIndex: Int?
+ let endIndex: Int?
+ let textRun: GoogleDocsTextRun?
+ let inlineObjectElement: GoogleDocsInlineObjectElement?
+ let autoText: GoogleDocsAutoText?
+ let columnBreak: GoogleDocsColumnBreak?
+ let footnoteReference: GoogleDocsFootnoteReference?
+ let horizontalRule: GoogleDocsHorizontalRule?
+ let equation: GoogleDocsEquation?
+ let person: GoogleDocsPerson?
+ let richLink: GoogleDocsRichLink?
+}
+
+struct GoogleDocsTextRun {
+ let content: String
+}
+
+struct GoogleDocsInlineObjectElement {
+ let inlineObjectId: String?
+}
+
+struct GoogleDocsAutoText {
+ let type: String?
+}
+
+struct GoogleDocsColumnBreak {
+ // Column break has no specific properties
+}
+
+struct GoogleDocsFootnoteReference {
+ let footnoteId: String?
+}
+
+struct GoogleDocsHorizontalRule {
+ // Horizontal rule has no specific properties
+}
+
+struct GoogleDocsEquation {
+ // Equation content - simplified for now
+}
+
+struct GoogleDocsPerson {
+ let personId: String?
+ let personProperties: GoogleDocsPersonProperties?
+}
+
+struct GoogleDocsPersonProperties {
+ let name: String?
+ let email: String?
+}
+
+struct GoogleDocsRichLink {
+ let richLinkId: String?
+ let richLinkProperties: GoogleDocsRichLinkProperties?
+}
+
+struct GoogleDocsRichLinkProperties {
+ let title: String?
+ let mimeType: String?
+}
+
+struct GoogleDocsSectionBreak {
+ let sectionStyle: GoogleDocsSectionStyle?
+}
+
+struct GoogleDocsSectionStyle {
+ let columnSeparatorStyle: String?
+ let contentDirection: String?
+ let sectionType: String?
+}
+
+struct GoogleDocsTableOfContents {
+ let content: [GoogleDocsStructuralElement]
+}
+
+struct GoogleDocsTable {
+ let rows: Int
+ let columns: Int
+ let tableRows: [GoogleDocsTableRow]
+}
+
+struct GoogleDocsTableRow {
+ let tableCells: [GoogleDocsTableCell]
+}
+
+struct GoogleDocsTableCell {
+ let content: [GoogleDocsStructuralElement]
+}
+
+struct GoogleDocsPageBreak {
+ // Page break doesn't have additional properties
+}
diff --git a/macos/Onit/Data/Structures/Google API/GoogleDocsOperations.swift b/macos/Onit/Data/Structures/Google API/GoogleDocsOperations.swift
new file mode 100644
index 000000000..a95007135
--- /dev/null
+++ b/macos/Onit/Data/Structures/Google API/GoogleDocsOperations.swift
@@ -0,0 +1,14 @@
+//
+// GoogleDocsOperations.swift
+// Onit
+//
+// Created by Kévin Naudin on 07/01/2025.
+//
+
+import Foundation
+
+enum GoogleDocsOperation {
+ case insertText(index: Int, text: String)
+ case deleteContentRange(startIndex: Int, endIndex: Int)
+ case replaceText(startIndex: Int, endIndex: Int, newText: String)
+}
diff --git a/macos/Onit/Data/Structures/Google API/GoogleDocumentMimeType.swift b/macos/Onit/Data/Structures/Google API/GoogleDocumentMimeType.swift
new file mode 100644
index 000000000..1df0a3035
--- /dev/null
+++ b/macos/Onit/Data/Structures/Google API/GoogleDocumentMimeType.swift
@@ -0,0 +1,14 @@
+//
+// GoogleDocumentMimeType.swift
+// Onit
+//
+// Created by Kévin Naudin on 07/01/2025.
+//
+
+enum GoogleDocumentMimeType: String {
+ case docs = "application/vnd.google-apps.document"
+ case sheet = "application/vnd.google-apps.spreadsheet"
+ case slide = "application/vnd.google-apps.presentation"
+ case word = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ case excel = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+}
diff --git a/macos/Onit/Data/Structures/Google API/GoogleSheetsOperations.swift b/macos/Onit/Data/Structures/Google API/GoogleSheetsOperations.swift
new file mode 100644
index 000000000..380299cab
--- /dev/null
+++ b/macos/Onit/Data/Structures/Google API/GoogleSheetsOperations.swift
@@ -0,0 +1,210 @@
+//
+// GoogleSheetsOperations.swift
+// Onit
+//
+// Created by Kévin Naudin on 07/01/2025.
+//
+
+import Foundation
+
+enum GoogleSheetsOperation {
+ case updateCells(range: GoogleSheetsRange, values: [[String]])
+ case insertRows(sheetId: Int, startIndex: Int, endIndex: Int)
+ case insertColumns(sheetId: Int, startIndex: Int, endIndex: Int)
+ case deleteRows(sheetId: Int, startIndex: Int, endIndex: Int)
+ case deleteColumns(sheetId: Int, startIndex: Int, endIndex: Int)
+ case mergeCells(range: GoogleSheetsRange)
+ case unmergeCells(range: GoogleSheetsRange)
+ case addSheet(title: String)
+ case updateCellFormat(range: GoogleSheetsRange, format: GoogleSheetsCellFormat)
+ case addChart(sheetId: Int, chartSpec: GoogleSheetsChartSpec)
+ case setFormula(range: GoogleSheetsRange, formula: String)
+}
+
+struct GoogleSheetsDiff {
+ let fileId: String
+ let spreadsheetStructure: GoogleSheetsSpreadsheet
+ let originalText: String
+ let proposedOperations: [GoogleSheetsOperation]
+ let previewText: String
+}
+
+struct GoogleSheetsSpreadsheet {
+ let spreadsheetId: String
+ let properties: GoogleSheetsSpreadsheetProperties
+ let sheets: [GoogleSheetsSheet]
+}
+
+struct GoogleSheetsSpreadsheetProperties {
+ let title: String
+ let locale: String?
+ let autoRecalc: String?
+}
+
+struct GoogleSheetsSheet {
+ let properties: GoogleSheetsSheetProperties
+ let data: [GoogleSheetsGridData]?
+ let merges: [GoogleSheetsRange]?
+ let charts: [GoogleSheetsEmbeddedChart]?
+}
+
+struct GoogleSheetsSheetProperties {
+ let sheetId: Int
+ let title: String
+ let sheetType: String?
+ let gridProperties: GoogleSheetsGridProperties?
+}
+
+struct GoogleSheetsGridProperties {
+ let rowCount: Int
+ let columnCount: Int
+ let frozenRowCount: Int?
+ let frozenColumnCount: Int?
+}
+
+struct GoogleSheetsGridData {
+ let startRow: Int?
+ let startColumn: Int?
+ let rowData: [GoogleSheetsRowData]
+}
+
+struct GoogleSheetsRowData {
+ let values: [GoogleSheetsCellData]
+}
+
+struct GoogleSheetsCellData {
+ let userEnteredValue: GoogleSheetsExtendedValue?
+ let formattedValue: String?
+ let userEnteredFormat: GoogleSheetsCellFormat?
+ let effectiveFormat: GoogleSheetsCellFormat?
+ let effectiveValue: GoogleSheetsExtendedValue?
+}
+
+struct GoogleSheetsExtendedValue {
+ let numberValue: Double?
+ let stringValue: String?
+ let boolValue: Bool?
+ let formulaValue: String?
+}
+
+struct GoogleSheetsCellFormat {
+ let numberFormat: GoogleSheetsNumberFormat?
+ let backgroundColor: GoogleSheetsColor?
+ let textFormat: GoogleSheetsTextFormat?
+ let horizontalAlignment: String?
+ let verticalAlignment: String?
+ let wrapStrategy: String?
+}
+
+struct GoogleSheetsNumberFormat {
+ let type: String
+ let pattern: String?
+}
+
+struct GoogleSheetsTextFormat {
+ let foregroundColor: GoogleSheetsColor?
+ let fontFamily: String?
+ let fontSize: Int?
+ let bold: Bool?
+ let italic: Bool?
+ let strikethrough: Bool?
+ let underline: Bool?
+}
+
+struct GoogleSheetsColor {
+ let red: Float?
+ let green: Float?
+ let blue: Float?
+ let alpha: Float?
+}
+
+struct GoogleSheetsRange {
+ let sheetId: Int
+ let startRowIndex: Int
+ let endRowIndex: Int
+ let startColumnIndex: Int
+ let endColumnIndex: Int
+}
+
+struct GoogleSheetsEmbeddedChart {
+ let chartId: Int
+ let spec: GoogleSheetsChartSpec
+ let position: GoogleSheetsEmbeddedObjectPosition
+}
+
+struct GoogleSheetsChartSpec {
+ let title: String?
+ let basicChart: GoogleSheetsBasicChartSpec?
+ let pieChart: GoogleSheetsPieChartSpec?
+}
+
+struct GoogleSheetsBasicChartSpec {
+ let chartType: String
+ let legendPosition: String?
+ let axis: [GoogleSheetsBasicChartAxis]?
+ let domains: [GoogleSheetsBasicChartDomain]?
+ let series: [GoogleSheetsBasicChartSeries]?
+}
+
+struct GoogleSheetsPieChartSpec {
+ let legendPosition: String?
+ let domain: GoogleSheetsPieChartDomain?
+ let series: GoogleSheetsPieChartSeries?
+}
+
+struct GoogleSheetsBasicChartAxis {
+ let position: String
+ let title: String?
+}
+
+struct GoogleSheetsBasicChartDomain {
+ let domain: GoogleSheetsChartData
+}
+
+struct GoogleSheetsBasicChartSeries {
+ let series: GoogleSheetsChartData
+ let type: String?
+}
+
+struct GoogleSheetsPieChartDomain {
+ let domain: GoogleSheetsChartData
+}
+
+struct GoogleSheetsPieChartSeries {
+ let series: GoogleSheetsChartData
+}
+
+struct GoogleSheetsChartData {
+ let sourceRange: GoogleSheetsChartSourceRange
+}
+
+struct GoogleSheetsChartSourceRange {
+ let sources: [GoogleSheetsGridRange]
+}
+
+struct GoogleSheetsGridRange {
+ let sheetId: Int
+ let startRowIndex: Int?
+ let endRowIndex: Int?
+ let startColumnIndex: Int?
+ let endColumnIndex: Int?
+}
+
+struct GoogleSheetsEmbeddedObjectPosition {
+ let sheetId: Int
+ let overlayPosition: GoogleSheetsOverlayPosition?
+}
+
+struct GoogleSheetsOverlayPosition {
+ let anchorCell: GoogleSheetsGridCoordinate
+ let offsetXPixels: Int?
+ let offsetYPixels: Int?
+ let widthPixels: Int?
+ let heightPixels: Int?
+}
+
+struct GoogleSheetsGridCoordinate {
+ let sheetId: Int
+ let rowIndex: Int
+ let columnIndex: Int
+}
diff --git a/macos/Onit/Data/Structures/Google API/GoogleSlidesOperations.swift b/macos/Onit/Data/Structures/Google API/GoogleSlidesOperations.swift
new file mode 100644
index 000000000..6e4daa7c3
--- /dev/null
+++ b/macos/Onit/Data/Structures/Google API/GoogleSlidesOperations.swift
@@ -0,0 +1,416 @@
+//
+// GoogleSlidesOperations.swift
+// Onit
+//
+// Created by Kévin Naudin on 07/01/2025.
+//
+
+import Foundation
+
+enum GoogleSlidesOperation {
+ case insertText(objectId: String, insertionIndex: Int, text: String)
+ case deleteText(objectId: String, textRange: GoogleSlidesRange)
+ case replaceAllText(containsText: String, replaceText: String)
+ case createSlide(slideLayoutReference: GoogleSlidesLayoutReference?, insertionIndex: Int?)
+ case deleteSlide(slideId: String)
+ case createShape(slideId: String, shapeType: String, elementProperties: GoogleSlidesPageElementProperties)
+ case createTextBox(slideId: String, elementProperties: GoogleSlidesPageElementProperties)
+ case createImage(slideId: String, imageUrl: String, elementProperties: GoogleSlidesPageElementProperties)
+ case updateShapeProperties(objectId: String, shapeProperties: GoogleSlidesShapeProperties)
+ case updateTextStyle(objectId: String, textRange: GoogleSlidesRange?, style: GoogleSlidesTextStyle)
+ case updateParagraphStyle(objectId: String, textRange: GoogleSlidesRange?, style: GoogleSlidesParagraphStyle)
+ case groupObjects(childrenObjectIds: [String])
+ case ungroupObjects(objectIds: [String])
+ case duplicateObject(objectId: String, objectIds: [String: String])
+}
+
+struct GoogleSlidesDiff {
+ let fileId: String
+ let presentationStructure: GoogleSlidesPresentation
+ let originalText: String
+ let proposedOperations: [GoogleSlidesOperation]
+ let previewText: String
+}
+
+struct GoogleSlidesPresentation {
+ let presentationId: String
+ let pageSize: GoogleSlidesSize
+ let slides: [GoogleSlidesSlide]
+ let title: String
+ let masters: [GoogleSlidesMaster]?
+ let layouts: [GoogleSlidesLayout]?
+}
+
+struct GoogleSlidesSlide {
+ let objectId: String
+ let pageElements: [GoogleSlidesPageElement]?
+ let slideProperties: GoogleSlidesSlideProperties?
+ let notesPage: GoogleSlidesNotesPage?
+}
+
+struct GoogleSlidesPageElement {
+ let objectId: String
+ let size: GoogleSlidesSize?
+ let transform: GoogleSlidesAffineTransform?
+ let title: String?
+ let description: String?
+ let shape: GoogleSlidesShape?
+ let image: GoogleSlidesImage?
+ let video: GoogleSlidesVideo?
+ let table: GoogleSlidesTable?
+ let wordArt: GoogleSlidesWordArt?
+ let line: GoogleSlidesLine?
+ let sheetsChart: GoogleSlidesSheetsChart?
+}
+
+struct GoogleSlidesShape {
+ let shapeType: String
+ let text: GoogleSlidesTextContent?
+ let shapeProperties: GoogleSlidesShapeProperties?
+}
+
+struct GoogleSlidesTextContent {
+ let textElements: [GoogleSlidesTextElement]
+ let lists: [String: GoogleSlidesList]?
+}
+
+struct GoogleSlidesTextElement {
+ let endIndex: Int
+ let startIndex: Int?
+ let paragraphMarker: GoogleSlidesParagraphMarker?
+ let textRun: GoogleSlidesTextRun?
+ let autoText: GoogleSlidesAutoText?
+}
+
+struct GoogleSlidesParagraphMarker {
+ let style: GoogleSlidesParagraphStyle?
+ let bullet: GoogleSlidesBullet?
+}
+
+struct GoogleSlidesTextRun {
+ let content: String
+ let style: GoogleSlidesTextStyle?
+}
+
+struct GoogleSlidesAutoText {
+ let type: String
+ let content: String?
+}
+
+struct GoogleSlidesTextStyle {
+ let backgroundColor: GoogleSlidesOptionalColor?
+ let foregroundColor: GoogleSlidesOptionalColor?
+ let bold: Bool?
+ let italic: Bool?
+ let fontFamily: String?
+ let fontSize: GoogleSlidesSize?
+ let link: GoogleSlidesLink?
+ let underline: Bool?
+ let strikethrough: Bool?
+ let smallCaps: Bool?
+ let fontWeight: Int?
+ let baselineOffset: String?
+}
+
+struct GoogleSlidesParagraphStyle {
+ let lineSpacing: Double?
+ let alignment: String?
+ let indentStart: GoogleSlidesSize?
+ let indentEnd: GoogleSlidesSize?
+ let spaceAbove: GoogleSlidesSize?
+ let spaceBelow: GoogleSlidesSize?
+ let indentFirstLine: GoogleSlidesSize?
+ let direction: String?
+ let spacingMode: String?
+}
+
+struct GoogleSlidesBullet {
+ let listId: String?
+ let nestingLevel: Int?
+ let glyph: String?
+ let bulletStyle: GoogleSlidesTextStyle?
+}
+
+struct GoogleSlidesList {
+ let listId: String
+ let nestingLevel: [String: GoogleSlidesNestingLevel]
+}
+
+struct GoogleSlidesNestingLevel {
+ let bulletStyle: GoogleSlidesTextStyle?
+}
+
+struct GoogleSlidesShapeProperties {
+ let shapeBackgroundFill: GoogleSlidesShapeBackgroundFill?
+ let outline: GoogleSlidesOutline?
+ let shadow: GoogleSlidesShadow?
+ let link: GoogleSlidesLink?
+ let contentAlignment: String?
+}
+
+struct GoogleSlidesShapeBackgroundFill {
+ let solidFill: GoogleSlidesSolidFill?
+}
+
+struct GoogleSlidesSolidFill {
+ let color: GoogleSlidesOpaqueColor?
+ let alpha: Double?
+}
+
+struct GoogleSlidesOptionalColor {
+ let opaqueColor: GoogleSlidesOpaqueColor?
+}
+
+struct GoogleSlidesOpaqueColor {
+ let rgbColor: GoogleSlidesRgbColor?
+ let themeColor: String?
+}
+
+struct GoogleSlidesRgbColor {
+ let red: Double
+ let green: Double
+ let blue: Double
+}
+
+struct GoogleSlidesOutline {
+ let outlineFill: GoogleSlidesOutlineFill?
+ let weight: GoogleSlidesSize?
+ let dashStyle: String?
+ let propertyState: String?
+}
+
+struct GoogleSlidesOutlineFill {
+ let solidFill: GoogleSlidesSolidFill?
+}
+
+struct GoogleSlidesShadow {
+ let type: String
+ let transform: GoogleSlidesAffineTransform
+ let alignment: String
+ let blurRadius: GoogleSlidesSize?
+ let color: GoogleSlidesOpaqueColor?
+ let alpha: Double?
+ let rotateWithShape: Bool?
+ let propertyState: String?
+}
+
+struct GoogleSlidesLink {
+ let url: String?
+ let relativeLink: String?
+ let pageObjectId: String?
+ let slideIndex: Int?
+}
+
+struct GoogleSlidesImage {
+ let contentUrl: String?
+ let imageProperties: GoogleSlidesImageProperties?
+}
+
+struct GoogleSlidesImageProperties {
+ let cropProperties: GoogleSlidesCropProperties?
+ let transparency: Double?
+ let brightness: Double?
+ let contrast: Double?
+ let recolor: GoogleSlidesRecolor?
+ let outline: GoogleSlidesOutline?
+ let shadow: GoogleSlidesShadow?
+ let link: GoogleSlidesLink?
+}
+
+struct GoogleSlidesCropProperties {
+ let leftOffset: Double?
+ let rightOffset: Double?
+ let topOffset: Double?
+ let bottomOffset: Double?
+ let angle: Double?
+}
+
+struct GoogleSlidesRecolor {
+ let recolorStops: [GoogleSlidesColorStop]?
+}
+
+struct GoogleSlidesColorStop {
+ let color: GoogleSlidesOpaqueColor
+ let alpha: Double?
+ let position: Double
+}
+
+struct GoogleSlidesVideo {
+ let url: String?
+ let source: String?
+ let id: String?
+ let videoProperties: GoogleSlidesVideoProperties?
+}
+
+struct GoogleSlidesVideoProperties {
+ let outline: GoogleSlidesOutline?
+ let autoPlay: Bool?
+ let start: Int?
+ let end: Int?
+ let mute: Bool?
+}
+
+struct GoogleSlidesTable {
+ let rows: Int
+ let columns: Int
+ let tableRows: [GoogleSlidesTableRow]
+ let horizontalBorderRows: [GoogleSlidesTableBorderRow]?
+ let verticalBorderRows: [GoogleSlidesTableBorderRow]?
+}
+
+struct GoogleSlidesTableRow {
+ let height: GoogleSlidesSize
+ let tableCells: [GoogleSlidesTableCell]
+}
+
+struct GoogleSlidesTableCell {
+ let location: GoogleSlidesTableCellLocation?
+ let rowSpan: Int?
+ let columnSpan: Int?
+ let text: GoogleSlidesTextContent?
+ let tableCellProperties: GoogleSlidesTableCellProperties?
+}
+
+struct GoogleSlidesTableCellLocation {
+ let rowIndex: Int
+ let columnIndex: Int
+}
+
+struct GoogleSlidesTableCellProperties {
+ let tableCellBackgroundFill: GoogleSlidesTableCellBackgroundFill?
+ let contentAlignment: String?
+}
+
+struct GoogleSlidesTableCellBackgroundFill {
+ let solidFill: GoogleSlidesSolidFill?
+}
+
+struct GoogleSlidesTableBorderRow {
+ let tableBorderCells: [GoogleSlidesTableBorderCell]
+}
+
+struct GoogleSlidesTableBorderCell {
+ let location: GoogleSlidesTableCellLocation
+ let tableBorderProperties: GoogleSlidesTableBorderProperties?
+}
+
+struct GoogleSlidesTableBorderProperties {
+ let tableBorderFill: GoogleSlidesTableBorderFill?
+ let weight: GoogleSlidesSize?
+ let dashStyle: String?
+}
+
+struct GoogleSlidesTableBorderFill {
+ let solidFill: GoogleSlidesSolidFill?
+}
+
+struct GoogleSlidesWordArt {
+ let renderedText: String?
+}
+
+struct GoogleSlidesLine {
+ let lineProperties: GoogleSlidesLineProperties?
+ let lineType: String?
+ let lineCategory: String?
+}
+
+struct GoogleSlidesLineProperties {
+ let lineFill: GoogleSlidesLineFill?
+ let weight: GoogleSlidesSize?
+ let dashStyle: String?
+ let startArrow: String?
+ let endArrow: String?
+ let link: GoogleSlidesLink?
+ let startConnection: GoogleSlidesLineConnection?
+ let endConnection: GoogleSlidesLineConnection?
+}
+
+struct GoogleSlidesLineFill {
+ let solidFill: GoogleSlidesSolidFill?
+}
+
+struct GoogleSlidesLineConnection {
+ let connectedObjectId: String
+ let connectionSiteIndex: Int?
+}
+
+struct GoogleSlidesSheetsChart {
+ let spreadsheetId: String
+ let chartId: Int
+ let contentUrl: String?
+ let sheetsChartProperties: GoogleSlidesSheetsChartProperties?
+}
+
+struct GoogleSlidesSheetsChartProperties {
+ let chartImageProperties: GoogleSlidesImageProperties?
+}
+
+struct GoogleSlidesSize {
+ let magnitude: Double
+ let unit: String
+}
+
+struct GoogleSlidesAffineTransform {
+ let scaleX: Double
+ let scaleY: Double
+ let shearX: Double
+ let shearY: Double
+ let translateX: Double
+ let translateY: Double
+ let unit: String
+}
+
+struct GoogleSlidesPageElementProperties {
+ let pageObjectId: String
+ let size: GoogleSlidesSize?
+ let transform: GoogleSlidesAffineTransform?
+}
+
+struct GoogleSlidesRange {
+ let startIndex: Int?
+ let endIndex: Int?
+ let type: String?
+}
+
+struct GoogleSlidesLayoutReference {
+ let layoutId: String?
+ let predefinedLayout: String?
+}
+
+struct GoogleSlidesSlideProperties {
+ let layoutObjectId: String?
+ let masterObjectId: String?
+ let notesPage: GoogleSlidesNotesPage?
+}
+
+struct GoogleSlidesNotesPage {
+ let objectId: String
+ let pageElements: [GoogleSlidesPageElement]?
+ let notesProperties: GoogleSlidesNotesProperties?
+}
+
+struct GoogleSlidesNotesProperties {
+ let speakerNotesObjectId: String?
+}
+
+struct GoogleSlidesMaster {
+ let objectId: String
+ let pageElements: [GoogleSlidesPageElement]?
+ let masterProperties: GoogleSlidesMasterProperties?
+}
+
+struct GoogleSlidesMasterProperties {
+ let displayName: String?
+}
+
+struct GoogleSlidesLayout {
+ let objectId: String
+ let pageElements: [GoogleSlidesPageElement]?
+ let layoutProperties: GoogleSlidesLayoutProperties?
+}
+
+struct GoogleSlidesLayoutProperties {
+ let masterObjectId: String?
+ let name: String?
+ let displayName: String?
+}
diff --git a/macos/Onit/Data/SwiftDataContainer.swift b/macos/Onit/Data/SwiftDataContainer.swift
index 25dce7aa1..5d240e696 100644
--- a/macos/Onit/Data/SwiftDataContainer.swift
+++ b/macos/Onit/Data/SwiftDataContainer.swift
@@ -17,6 +17,10 @@ actor SwiftDataContainer {
let schema = Schema([
Chat.self,
SystemPrompt.self,
+ Prompt.self,
+ Response.self,
+ DiffChangeState.self,
+ DiffRevision.self,
])
// This handles legacy clients before we added the sandbox entitlement.
@@ -53,6 +57,10 @@ actor SwiftDataContainer {
let schema = Schema([
Chat.self,
SystemPrompt.self,
+ Prompt.self,
+ Response.self,
+ DiffChangeState.self,
+ DiffRevision.self,
])
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: schema, configurations: [configuration])
diff --git a/macos/Onit/Data/Tools/DiffTool.swift b/macos/Onit/Data/Tools/DiffTool.swift
index 905ae5387..379f382dd 100644
--- a/macos/Onit/Data/Tools/DiffTool.swift
+++ b/macos/Onit/Data/Tools/DiffTool.swift
@@ -18,17 +18,17 @@ class DiffTool: ToolProtocol {
private let availableTools: [String: Tool] = [
"plain_text": Tool(
name: "diff_plain_text",
- description: "Generate structured text modifications by comparing original plain text content with your improved version.",
+ description: "Generate structured text modifications for any text improvements including corrections, edits, proofreading, grammar fixes, spelling fixes, or content enhancements. Use this tool whenever you need to apply changes to existing text content.",
parameters: ToolParameters(
properties: [
- "source_name": ToolProperty(
+ "app_name": ToolProperty(
type: "string",
- description: "Name of the source from which text content was extracted",
+ description: "Name of the app from which text content was extracted",
items: nil
),
- "source_title": ToolProperty(
+ "document_url": ToolProperty(
type: "string",
- description: "Title of the source document or window",
+ description: "Optional URL of the document",
items: nil
),
"original_content": ToolProperty(
@@ -42,7 +42,7 @@ class DiffTool: ToolProtocol {
items: nil
)
],
- required: ["source_name", "source_title", "original_content", "improved_content"]
+ required: ["app_name", "original_content", "improved_content"]
)
)
]
@@ -55,10 +55,20 @@ class DiffTool: ToolProtocol {
}
func canExecute(partialArguments: String) -> Bool {
- let hasOriginalStart = partialArguments.contains("\"original_content\"")
- let hasImprovedStart = partialArguments.contains("\"improved_content\"")
+ guard let data = partialArguments.data(using: .utf8) else {
+ return false
+ }
- return hasOriginalStart && hasImprovedStart
+ do {
+ let decoded = try JSONDecoder().decode(PlainTextDiffArguments.self, from: data)
+
+ let originalHasContent = decoded.original_content.count >= 10
+ let improvedHasContent = decoded.improved_content.count >= 10
+
+ return originalHasContent && improvedHasContent
+ } catch {
+ return false
+ }
}
func execute(toolName: String, arguments: String) async -> Result {
@@ -73,14 +83,14 @@ class DiffTool: ToolProtocol {
}
struct PlainTextDiffArguments: Codable {
- let source_name: String
- let source_title: String
+ let app_name: String
let original_content: String
let improved_content: String
+ let document_url: String?
}
struct PlainTextDiffOperation: Codable {
- let type: String // "insertText", "deleteContentRange", "replaceText"
+ let type: DiffOperationType
let index: Int? // For insertText operations
let startIndex: Int? // For delete/replace operations
let endIndex: Int? // For delete/replace operations
@@ -127,59 +137,320 @@ class DiffTool: ToolProtocol {
}
private func generateDiffOperations(original: String, improved: String) -> [PlainTextDiffOperation] {
- let diffs = diff(text1: original, text2: improved)
+ let diffs = optimizedDiff(text1: original, text2: improved)
var operations: [PlainTextDiffOperation] = []
var currentIndex = 0
- for diffOp in diffs {
+ var rawOperations: [(type: String, index: Int, text: String?)] = []
+
+ for (index, diffOp) in diffs.enumerated() {
switch diffOp {
case .equal(let text):
+ log.debug(" \(index + 1). EQUAL(\(text.count) chars) at index \(currentIndex): '\(text.prefix(20))...'")
currentIndex += text.count
+
case .delete(let text):
+ log.debug(" \(index + 1). DELETE(\(text.count) chars) at index \(currentIndex): '\(text)'")
+ rawOperations.append((type: "delete", index: currentIndex, text: text))
+ currentIndex += text.count
+
+ case .insert(let text):
+ log.debug(" \(index + 1). INSERT(\(text.count) chars) at index \(currentIndex): '\(text)'")
+ rawOperations.append((type: "insert", index: currentIndex, text: text))
+ }
+ }
+
+ var i = 0
+
+ while i < rawOperations.count {
+ let current = rawOperations[i]
+
+ if i + 1 < rawOperations.count {
+ let next = rawOperations[i + 1]
+ var deleteOp: (type: String, index: Int, text: String?)? = nil
+ var insertOp: (type: String, index: Int, text: String?)? = nil
+
+ if current.type == "delete" && next.type == "insert" {
+ let deleteEnd = current.index + (current.text?.count ?? 0)
+
+ if next.index >= current.index && next.index <= deleteEnd {
+ deleteOp = current
+ insertOp = next
+ }
+ }
+ else if current.type == "insert" && next.type == "delete" {
+ let deleteEnd = next.index + (next.text?.count ?? 0)
+
+ if current.index >= next.index && current.index <= deleteEnd {
+ insertOp = current
+ deleteOp = next
+ }
+ }
+
+ if let deleteOp = deleteOp, let insertOp = insertOp {
+ operations.append(PlainTextDiffOperation(
+ type: .replaceText,
+ index: nil,
+ startIndex: deleteOp.index,
+ endIndex: deleteOp.index + (deleteOp.text?.count ?? 0),
+ text: nil,
+ newText: insertOp.text
+ ))
+ i += 2
+ continue
+ }
+ }
+
+ if current.type == "insert" {
+ var insertSequence = [current]
+ var j = i + 1
+
+ while j < rawOperations.count && rawOperations[j].type == "insert" {
+ let nextInsert = rawOperations[j]
+ let lastInsert = insertSequence.last!
+ let lastInsertEnd = lastInsert.index + (lastInsert.text?.count ?? 0)
+
+ if nextInsert.index <= lastInsertEnd + 5 {
+ insertSequence.append(nextInsert)
+ j += 1
+ } else {
+ break
+ }
+ }
+
+ if insertSequence.count >= 3 {
+ let firstInsert = insertSequence.first!
+ let lastInsert = insertSequence.last!
+ let span = lastInsert.index - firstInsert.index
+
+ if span <= 10 {
+ let replacementText = reconstructReplacementText(
+ originalText: original,
+ insertSequence: insertSequence,
+ startIndex: firstInsert.index,
+ endIndex: lastInsert.index
+ )
+
+ operations.append(PlainTextDiffOperation(
+ type: .replaceText,
+ index: nil,
+ startIndex: firstInsert.index,
+ endIndex: lastInsert.index,
+ text: nil,
+ newText: replacementText
+ ))
+ } else {
+ for insert in insertSequence {
+ operations.append(PlainTextDiffOperation(
+ type: .insertText,
+ index: insert.index,
+ startIndex: nil,
+ endIndex: nil,
+ text: insert.text,
+ newText: nil
+ ))
+ }
+ }
+ } else {
+ for insert in insertSequence {
+ operations.append(PlainTextDiffOperation(
+ type: .insertText,
+ index: insert.index,
+ startIndex: nil,
+ endIndex: nil,
+ text: insert.text,
+ newText: nil
+ ))
+ }
+ }
+
+ i = j
+ continue
+ }
+
+ switch current.type {
+ case "delete":
operations.append(PlainTextDiffOperation(
- type: "deleteContentRange",
+ type: .deleteContentRange,
index: nil,
- startIndex: currentIndex,
- endIndex: currentIndex + text.count,
+ startIndex: current.index,
+ endIndex: current.index + (current.text?.count ?? 0),
text: nil,
newText: nil
))
- currentIndex += text.count
- case .insert(let text):
+ case "insert":
operations.append(PlainTextDiffOperation(
- type: "insertText",
- index: currentIndex,
+ type: .insertText,
+ index: current.index,
startIndex: nil,
endIndex: nil,
- text: text,
+ text: current.text,
newText: nil
))
+
+ default:
+ break
}
+
+ i += 1
}
return operations
}
-}
-
-public func diff(text1: String, text2: String) -> [DiffOperation] {
- if text1 == text2 {
- return text1.isEmpty ? [] : [.equal(text1)]
- }
- let (commonPrefix, trimmedText1, trimmedText2) = extractCommonPrefix(text1: text1, text2: text2)
- let (commonSuffix, finalText1, finalText2) = extractCommonSuffix(text1: trimmedText1, text2: trimmedText2)
- let diffs = computeDiff(text1: finalText1, text2: finalText2)
- var result = diffs
+ private func reconstructReplacementText(
+ originalText: String,
+ insertSequence: [(type: String, index: Int, text: String?)],
+ startIndex: Int,
+ endIndex: Int
+ ) -> String {
+ var result = ""
+ var currentPos = startIndex
+
+ for insert in insertSequence {
+ if insert.index > currentPos {
+ let textStart = originalText.index(originalText.startIndex, offsetBy: currentPos)
+ let textEnd = originalText.index(originalText.startIndex, offsetBy: min(insert.index, originalText.count))
+ result += String(originalText[textStart.. [DiffOperation] {
+ if text1 == text2 {
+ return text1.isEmpty ? [] : [.equal(text1)]
+ }
+
+ let (commonPrefix, trimmedText1, trimmedText2) = extractCommonPrefix(text1: text1, text2: text2)
+ let (commonSuffix, finalText1, finalText2) = extractCommonSuffix(text1: trimmedText1, text2: trimmedText2)
+
+ if finalText1.isEmpty && finalText2.isEmpty {
+ var result: [DiffOperation] = []
+ if !commonPrefix.isEmpty {
+ result.append(.equal(commonPrefix))
+ }
+ if !commonSuffix.isEmpty {
+ result.append(.equal(commonSuffix))
+ }
+ return result
+ }
+
+ if finalText1.isEmpty {
+ var result: [DiffOperation] = []
+ if !commonPrefix.isEmpty {
+ result.append(.equal(commonPrefix))
+ }
+ result.append(.insert(finalText2))
+ if !commonSuffix.isEmpty {
+ result.append(.equal(commonSuffix))
+ }
+ return result
+ }
+
+ if finalText2.isEmpty {
+ var result: [DiffOperation] = []
+ if !commonPrefix.isEmpty {
+ result.append(.equal(commonPrefix))
+ }
+ result.append(.delete(finalText1))
+ if !commonSuffix.isEmpty {
+ result.append(.equal(commonSuffix))
+ }
+ return result
+ }
+
+ let diffs = computeOptimalDiff(text1: finalText1, text2: finalText2)
+
+ var result = diffs
+ if !commonPrefix.isEmpty {
+ result.insert(.equal(commonPrefix), at: 0)
+ }
+ if !commonSuffix.isEmpty {
+ result.append(.equal(commonSuffix))
+ }
+
+ return result
}
- if !commonSuffix.isEmpty {
- result.append(.equal(commonSuffix))
+
+ private func computeOptimalDiff(text1: String, text2: String) -> [DiffOperation] {
+ let chars1 = Array(text1)
+ let chars2 = Array(text2)
+ let n = chars1.count
+ let m = chars2.count
+
+ var dp = Array(repeating: Array(repeating: 0, count: m + 1), count: n + 1)
+
+ for i in 1...n {
+ for j in 1...m {
+ if chars1[i-1] == chars2[j-1] {
+ dp[i][j] = dp[i-1][j-1] + 1
+ } else {
+ dp[i][j] = max(dp[i-1][j], dp[i][j-1])
+ }
+ }
+ }
+
+ var result: [DiffOperation] = []
+ var i = n
+ var j = m
+
+ while i > 0 || j > 0 {
+ if i > 0 && j > 0 && chars1[i-1] == chars2[j-1] {
+ result.insert(.equal(String(chars1[i-1])), at: 0)
+ i -= 1
+ j -= 1
+ } else if i > 0 && (j == 0 || dp[i-1][j] >= dp[i][j-1]) {
+ result.insert(.delete(String(chars1[i-1])), at: 0)
+ i -= 1
+ } else if j > 0 {
+ result.insert(.insert(String(chars2[j-1])), at: 0)
+ j -= 1
+ }
+ }
+
+ return mergeConsecutiveOperations(result)
}
- return result
+ private func mergeConsecutiveOperations(_ operations: [DiffOperation]) -> [DiffOperation] {
+ guard !operations.isEmpty else { return [] }
+
+ var result: [DiffOperation] = []
+ var currentOp = operations[0]
+
+ for i in 1.. (String, String, String) {
@@ -211,6 +482,10 @@ private func extractCommonSuffix(text1: String, text2: String) -> (String, Strin
let chars2 = Array(text2)
let minLength = min(chars1.count, chars2.count)
+ if minLength == 0 {
+ return ("", text1, text2)
+ }
+
var suffixLength = 0
for i in 1...minLength {
if chars1[chars1.count - i] != chars2[chars2.count - i] {
@@ -230,109 +505,3 @@ private func extractCommonSuffix(text1: String, text2: String) -> (String, Strin
return (suffix, remaining1, remaining2)
}
-private func computeDiff(text1: String, text2: String) -> [DiffOperation] {
- if text1.isEmpty {
- return text2.isEmpty ? [] : [.insert(text2)]
- }
-
- if text2.isEmpty {
- return [.delete(text1)]
- }
-
- return myersDiff(text1: text1, text2: text2)
-}
-
-private func myersDiff(text1: String, text2: String) -> [DiffOperation] {
- let chars1 = Array(text1)
- let chars2 = Array(text2)
- let n = chars1.count
- let m = chars2.count
-
- if n == 0 {
- return [.insert(text2)]
- }
- if m == 0 {
- return [.delete(text1)]
- }
-
- let max = n + m
- var v = Array(repeating: 0, count: 2 * max + 1)
- var trace: [[Int]] = []
-
- for d in 0...max {
- trace.append(v)
-
- for k in stride(from: -d, through: d, by: 2) {
- let kIndex = k + max
-
- var x: Int
- if k == -d || (k != d && v[kIndex - 1] < v[kIndex + 1]) {
- x = v[kIndex + 1]
- } else {
- x = v[kIndex - 1] + 1
- }
-
- var y = x - k
-
- while x < n && y < m && chars1[x] == chars2[y] {
- v[kIndex] = x + 1
- if x + 1 < n && y + 1 < m {
- x += 1
- y += 1
- } else {
- break
- }
- }
-
- v[kIndex] = x
-
- if x >= n && y >= m {
- return backtrack(chars1: chars1, chars2: chars2, trace: trace, d: d)
- }
- }
- }
-
- // Fallback to simple diff
- return [.delete(text1), .insert(text2)]
-}
-
-private func backtrack(chars1: [Character], chars2: [Character], trace: [[Int]], d: Int) -> [DiffOperation] {
- var result: [DiffOperation] = []
- var x = chars1.count
- var y = chars2.count
-
- for step in stride(from: d, through: 0, by: -1) {
- let v = trace[step]
- let max = chars1.count + chars2.count
- let k = x - y
- let kIndex = k + max
-
- let prevK: Int
- if k == -step || (k != step && v[kIndex - 1] < v[kIndex + 1]) {
- prevK = k + 1
- } else {
- prevK = k - 1
- }
-
- let prevX = v[prevK + max]
- let prevY = prevX - prevK
-
- while x > prevX && y > prevY {
- result.insert(.equal(String(chars1[x - 1])), at: 0)
- x -= 1
- y -= 1
- }
-
- if step > 0 {
- if x > prevX {
- result.insert(.delete(String(chars1[x - 1])), at: 0)
- x -= 1
- } else {
- result.insert(.insert(String(chars2[y - 1])), at: 0)
- y -= 1
- }
- }
- }
-
- return result
-}
diff --git a/macos/Onit/Data/Tools/ToolRouter.swift b/macos/Onit/Data/Tools/ToolRouter.swift
index 1b82d7fea..03350a314 100644
--- a/macos/Onit/Data/Tools/ToolRouter.swift
+++ b/macos/Onit/Data/Tools/ToolRouter.swift
@@ -45,7 +45,7 @@ actor ToolRouter {
guard let tool = tool else {
return .failure(
- ToolCallError(toolName: toolName, message: "Unrecognized app name: \(appName)")
+ ToolCallError(toolName: toolName, message: "Unrecognized tool name: \(appName)")
)
}
diff --git a/macos/Onit/Helpers/DiffSegmentUtils.swift b/macos/Onit/Helpers/DiffSegmentUtils.swift
new file mode 100644
index 000000000..7e7f34ab7
--- /dev/null
+++ b/macos/Onit/Helpers/DiffSegmentUtils.swift
@@ -0,0 +1,130 @@
+//
+// DiffSegmentUtils.swift
+// Onit
+//
+// Created by Kévin Naudin on 29/07/2025.
+//
+
+import Foundation
+
+struct DiffSegmentUtils {
+
+ static func shouldSegmentBeVisible(segment: DiffSegment, status: DiffChangeStatus?) -> Bool {
+ return segment.type == .unchanged ||
+ (segment.type == .added && status != .rejected) ||
+ (segment.type == .removed && status != .approved)
+ }
+
+ static func generateDiffSegments(
+ originalText: String,
+ operations: [DiffTool.PlainTextDiffOperation]
+ ) -> [DiffSegment] {
+ let sortedOperations = operations.sorted { (op1, op2) in
+ let pos1 = op1.startIndex ?? op1.index ?? 0
+ let pos2 = op2.startIndex ?? op2.index ?? 0
+
+ if pos1 == pos2 {
+ return op1.type.priority < op2.type.priority
+ }
+
+ return pos1 < pos2
+ }
+
+ var segments: [DiffSegment] = []
+ var currentPosition = 0
+
+ for (opIndex, operation) in sortedOperations.enumerated() {
+ let operationStart: Int
+
+ switch operation.type {
+ case .insertText:
+ operationStart = operation.index ?? 0
+ case .deleteContentRange, .replaceText:
+ operationStart = operation.startIndex ?? 0
+ }
+
+ if currentPosition < operationStart {
+ let unchangedText = String(originalText[originalText.index(originalText.startIndex, offsetBy: currentPosition).. String {
+ var result = ""
+ for segment in segments {
+ let segmentStatus: DiffChangeStatus? = {
+ guard let opIndex = segment.operationIndex else { return nil }
+ return effectiveChanges.first { $0.operationIndex == opIndex }?.status
+ }()
+
+ let isVisible = shouldSegmentBeVisible(segment: segment, status: segmentStatus)
+ if isVisible {
+ result += segment.content
+ }
+ }
+ return result
+ }
+}
\ No newline at end of file
diff --git a/macos/Onit/PanelStateManager/Pinned/PanelStatePinnedManager+Delegates.swift b/macos/Onit/PanelStateManager/Pinned/PanelStatePinnedManager+Delegates.swift
index fbf7bca57..c231ba590 100644
--- a/macos/Onit/PanelStateManager/Pinned/PanelStatePinnedManager+Delegates.swift
+++ b/macos/Onit/PanelStateManager/Pinned/PanelStatePinnedManager+Delegates.swift
@@ -102,5 +102,7 @@ extension PanelStatePinnedManager: OnitPanelStateDelegate {
}
}
+ func panelFrameDidChange(state: OnitPanelState) { }
+
func userInputsDidChange(instruction: String, contexts: [Context], input: Input?) {}
}
diff --git a/macos/Onit/PanelStateManager/Tethered/PanelStateTetheredManager+Delegates.swift b/macos/Onit/PanelStateManager/Tethered/PanelStateTetheredManager+Delegates.swift
index cf726196a..d257a1afe 100644
--- a/macos/Onit/PanelStateManager/Tethered/PanelStateTetheredManager+Delegates.swift
+++ b/macos/Onit/PanelStateManager/Tethered/PanelStateTetheredManager+Delegates.swift
@@ -130,6 +130,8 @@ extension PanelStateTetheredManager: OnitPanelStateDelegate {
func panelStateDidChange(state: OnitPanelState) {
handlePanelStateChange(state: state, action: .undefined)
}
+ func panelFrameDidChange(state: OnitPanelState) { }
+
func userInputsDidChange(instruction: String, contexts: [Context], input: Input?) { }
}
diff --git a/macos/Onit/PanelStateManager/Tethered/PanelStateTetheredManager.swift b/macos/Onit/PanelStateManager/Tethered/PanelStateTetheredManager.swift
index 299af1430..3c16b6884 100644
--- a/macos/Onit/PanelStateManager/Tethered/PanelStateTetheredManager.swift
+++ b/macos/Onit/PanelStateManager/Tethered/PanelStateTetheredManager.swift
@@ -288,6 +288,8 @@ class PanelStateTetheredManager: PanelStateBaseManager, ObservableObject {
}
}
}
+ state.notifyDelegates { $0.panelFrameDidChange(state: state) }
+
handlePanelStateChange(state: state, action: .moveEnd)
}
diff --git a/macos/Onit/PanelStateManager/Untethered/PanelStateUntetheredManager.swift b/macos/Onit/PanelStateManager/Untethered/PanelStateUntetheredManager.swift
index 95143ef4a..fcab2bb20 100644
--- a/macos/Onit/PanelStateManager/Untethered/PanelStateUntetheredManager.swift
+++ b/macos/Onit/PanelStateManager/Untethered/PanelStateUntetheredManager.swift
@@ -210,6 +210,7 @@ extension PanelStateUntetheredManager: OnitPanelStateDelegate {
func panelStateDidChange(state: OnitPanelState) {
handlePanelStateChange(state: state)
}
+ func panelFrameDidChange(state: OnitPanelState) { }
func userInputsDidChange(instruction: String, contexts: [Context], input: Input?) { }
}
diff --git a/macos/Onit/UI/Debug/DebugManager.swift b/macos/Onit/UI/Debug/DebugManager.swift
index 915ac504b..acf10111f 100644
--- a/macos/Onit/UI/Debug/DebugManager.swift
+++ b/macos/Onit/UI/Debug/DebugManager.swift
@@ -340,7 +340,7 @@ class DebugManager: ObservableObject {
print("ocrTiming - OCR took: \(ocrEndTime - ocrStartTime) seconds")
let heavy2StartTime = CFAbsoluteTimeGetCurrent()
- let accessibilityText = accessibilityResults["screen"] ?? ""
+ let accessibilityText = accessibilityResults[AccessibilityParsedElements.screen] ?? ""
let computationResult = await Task {
await self.performHeavyComputation(
observations: ocrObservations,
diff --git a/macos/Onit/UI/Notepad/DiffTextView.swift b/macos/Onit/UI/Notepad/DiffTextView.swift
new file mode 100644
index 000000000..f30ef82ed
--- /dev/null
+++ b/macos/Onit/UI/Notepad/DiffTextView.swift
@@ -0,0 +1,371 @@
+//
+// DiffTextView.swift
+// Onit
+//
+// Created by Kévin Naudin on 07/09/2025.
+//
+
+import SwiftUI
+import AppKit
+import SwiftData
+
+class ScrollDetectorScrollView: NSScrollView {
+ var onScrollStart: (() -> Void)?
+ var onScrollEnd: (() -> Void)?
+ private var scrollTimer: Timer?
+
+ override func scrollWheel(with event: NSEvent) {
+ onScrollStart?()
+
+ scrollTimer?.invalidate()
+
+ super.scrollWheel(with: event)
+
+ scrollTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: false) { _ in
+ DispatchQueue.main.async {
+ self.onScrollEnd?()
+ }
+ }
+ }
+}
+
+class ClickableTextView: NSTextView {
+ var onTextClicked: ((Int) -> Void)?
+ var segments: [DiffSegment] = []
+ var effectiveChanges: [DiffChangeData] = []
+
+ override func mouseDown(with event: NSEvent) {
+ guard let layoutManager = layoutManager,
+ let textContainer = textContainer,
+ let textStorage = textStorage else {
+ super.mouseDown(with: event)
+ return
+ }
+
+ let location = convert(event.locationInWindow, from: nil)
+ let adjustedLocation = CGPoint(
+ x: location.x - textContainerInset.width,
+ y: location.y - textContainerInset.height
+ )
+ let characterIndex = layoutManager.characterIndex(for: adjustedLocation, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
+
+ guard characterIndex >= 0 && characterIndex < textStorage.length else {
+ super.mouseDown(with: event)
+ return
+ }
+
+ var currentIndex = 0
+
+ for (_, segment) in segments.enumerated() {
+ let segmentStatus: DiffChangeStatus? = {
+ guard let opIndex = segment.operationIndex else { return nil }
+ return effectiveChanges.first { $0.operationIndex == opIndex }?.status
+ }()
+ let isSegmentVisible = DiffSegmentUtils.shouldSegmentBeVisible(segment: segment, status: segmentStatus)
+
+ if isSegmentVisible {
+ let segmentRange = NSRange(location: currentIndex, length: segment.content.count)
+
+ if segmentRange.contains(characterIndex) {
+ if let operationIndex = segment.operationIndex {
+ if segmentStatus == .pending {
+ onTextClicked?(operationIndex)
+ return
+ }
+ }
+ return
+ }
+
+ currentIndex += segment.content.count
+ }
+ }
+
+ super.mouseDown(with: event)
+ }
+}
+
+struct DiffTextView: NSViewRepresentable {
+ typealias NSViewType = NSScrollView
+
+ let segments: [DiffSegment]
+ let currentOperationIndex: Int
+ let effectiveChanges: [DiffChangeData]
+ let onSegmentPositionChanged: (CGRect?) -> Void
+ let onSegmentClicked: (Int) -> Void
+ let shouldScrollToCurrentSegment: Bool
+ let onScrollStateChanged: (Bool) -> Void
+
+ func makeNSView(context: Self.Context) -> NSScrollView {
+ let scrollView = ScrollDetectorScrollView()
+ let textView = ClickableTextView()
+
+ scrollView.hasVerticalScroller = true
+ scrollView.hasHorizontalScroller = false
+ scrollView.autohidesScrollers = true
+ scrollView.borderType = .noBorder
+ scrollView.drawsBackground = false
+
+ guard let textContainer = textView.textContainer else {
+ return scrollView
+ }
+
+ textContainer.lineFragmentPadding = 0
+ textContainer.widthTracksTextView = true
+ textContainer.containerSize = CGSize(width: 0, height: CGFloat.greatestFiniteMagnitude)
+
+ textView.isEditable = false
+ textView.isSelectable = true
+ textView.textContainerInset = NSSize(width: 16, height: 12)
+ textView.drawsBackground = false
+ textView.isRichText = true
+ textView.allowsUndo = false
+ textView.isVerticallyResizable = true
+ textView.isHorizontallyResizable = false
+ textView.autoresizingMask = [.width]
+
+ scrollView.documentView = textView
+
+ let attributedText = createNSAttributedText(for: segments)
+ if attributedText.length > 0 {
+ textView.textStorage?.setAttributedString(attributedText)
+ }
+
+ textView.segments = segments
+ textView.effectiveChanges = effectiveChanges
+ textView.onTextClicked = onSegmentClicked
+
+ scrollView.onScrollStart = {
+ DispatchQueue.main.async {
+ self.onScrollStateChanged(true)
+ }
+ }
+
+ scrollView.onScrollEnd = {
+ DispatchQueue.main.async {
+ self.onScrollStateChanged(false)
+ self.calculateCurrentSegmentPosition(textView: textView)
+ }
+ }
+
+ return scrollView
+ }
+
+ func updateNSView(_ nsView: NSScrollView, context: Self.Context) {
+ guard let textView = nsView.documentView as? ClickableTextView,
+ let scrollView = nsView as? ScrollDetectorScrollView else {
+ return
+ }
+
+ let attributedText = createNSAttributedText(for: segments)
+
+ textView.textStorage?.setAttributedString(attributedText)
+
+ if let textContainer = textView.textContainer {
+ textView.layoutManager?.ensureLayout(for: textContainer)
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.calculateCurrentSegmentPosition(textView: textView)
+
+ if self.shouldScrollToCurrentSegment {
+ self.scrollToCurrentSegment(textView: textView)
+ }
+ }
+
+ textView.segments = segments
+ textView.effectiveChanges = effectiveChanges
+ textView.onTextClicked = onSegmentClicked
+
+ scrollView.onScrollStart = {
+ DispatchQueue.main.async {
+ self.onScrollStateChanged(true)
+ }
+ }
+
+ scrollView.onScrollEnd = {
+ DispatchQueue.main.async {
+ self.onScrollStateChanged(false)
+ self.calculateCurrentSegmentPosition(textView: textView)
+ }
+ }
+ }
+
+ private func findCurrentSegmentRect(textView: NSTextView) -> CGRect? {
+ guard let layoutManager = textView.layoutManager,
+ let textContainer = textView.textContainer else {
+ return nil
+ }
+
+ var characterIndex = 0
+ var currentSegmentStart = 0
+ var currentSegmentLength = 0
+ var foundCurrentSegment = false
+
+ for segment in segments {
+ let segmentStatus: DiffChangeStatus? = {
+ guard let opIndex = segment.operationIndex else { return nil }
+ return effectiveChanges.first { $0.operationIndex == opIndex }?.status
+ }()
+ let isSegmentVisible = DiffSegmentUtils.shouldSegmentBeVisible(segment: segment, status: segmentStatus)
+
+ if let segmentOpIndex = segment.operationIndex, segmentOpIndex == currentOperationIndex && isSegmentVisible {
+ currentSegmentStart = characterIndex
+ currentSegmentLength = segment.content.count
+ foundCurrentSegment = true
+ break
+ }
+
+ if isSegmentVisible {
+ characterIndex += segment.content.count
+ }
+ }
+
+ guard foundCurrentSegment && currentSegmentLength > 0 else {
+ return nil
+ }
+
+ let range = NSRange(location: currentSegmentStart, length: currentSegmentLength)
+ let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
+ let boundingRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
+
+ return CGRect(
+ x: boundingRect.origin.x + textView.textContainerInset.width,
+ y: boundingRect.origin.y + textView.textContainerInset.height,
+ width: boundingRect.width,
+ height: boundingRect.height
+ )
+ }
+
+ private func scrollToCurrentSegment(textView: NSTextView) {
+ guard let scrollView = textView.enclosingScrollView,
+ let segmentRect = findCurrentSegmentRect(textView: textView) else {
+ return
+ }
+
+ let visibleRect = scrollView.contentView.visibleRect
+ let targetY = segmentRect.origin.y - (visibleRect.height / 3)
+ let targetPoint = NSPoint(x: 0, y: max(0, targetY))
+
+ DispatchQueue.main.async {
+ self.onScrollStateChanged(true)
+ }
+
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = 0.3
+ context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+ context.allowsImplicitAnimation = true
+ scrollView.contentView.animator().setBoundsOrigin(targetPoint)
+ } completionHandler: {
+ DispatchQueue.main.async {
+ self.onScrollStateChanged(false)
+ self.calculateCurrentSegmentPosition(textView: textView)
+ }
+ }
+ }
+
+ private func calculateCurrentSegmentPosition(textView: NSTextView) {
+ guard let segmentRect = findCurrentSegmentRect(textView: textView) else {
+ DispatchQueue.main.async {
+ self.onSegmentPositionChanged(nil)
+ }
+ return
+ }
+
+ let visibleRect = textView.visibleRect
+
+ if visibleRect.contains(segmentRect) {
+ let scrollViewRect = CGRect(
+ x: segmentRect.origin.x,
+ y: segmentRect.origin.y - visibleRect.origin.y,
+ width: segmentRect.width,
+ height: segmentRect.height
+ )
+ DispatchQueue.main.async {
+ self.onSegmentPositionChanged(scrollViewRect)
+ }
+ } else {
+ DispatchQueue.main.async {
+ self.onSegmentPositionChanged(nil)
+ }
+ }
+ }
+
+ private func createNSAttributedText(for segments: [DiffSegment]) -> NSAttributedString {
+ let result = NSMutableAttributedString()
+
+ for segment in segments {
+ let segmentStatus: DiffChangeStatus? = {
+ guard let opIndex = segment.operationIndex else { return nil }
+ return effectiveChanges.first { $0.operationIndex == opIndex }?.status
+ }()
+ let isCurrentSegment = segment.operationIndex == currentOperationIndex
+ let font = NSFont.monospacedSystemFont(ofSize: 14,
+ weight: isCurrentSegment ? .bold : .regular)
+ var attributes: [NSAttributedString.Key: Any] = [
+ .font: font
+ ]
+
+ switch segment.type {
+ case .unchanged:
+ attributes[.foregroundColor] = NSColor.labelColor
+
+ case .added:
+ switch segmentStatus {
+ case .approved:
+ attributes[.foregroundColor] = NSColor.labelColor
+ case .pending:
+ attributes[.foregroundColor] = NSColor.systemGreen
+ attributes[.backgroundColor] = NSColor.systemGreen.withAlphaComponent(0.2)
+ case .rejected:
+ continue
+ default:
+ attributes[.foregroundColor] = NSColor.secondaryLabelColor
+ attributes[.backgroundColor] = NSColor.secondaryLabelColor.withAlphaComponent(0.1)
+ }
+
+ case .removed:
+ switch segmentStatus {
+ case .approved:
+ continue
+ case .pending:
+ attributes[.foregroundColor] = NSColor.systemRed
+ attributes[.backgroundColor] = NSColor.systemRed.withAlphaComponent(0.2)
+ attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
+ case .rejected:
+ attributes[.foregroundColor] = NSColor.labelColor
+ default:
+ attributes[.foregroundColor] = NSColor.secondaryLabelColor
+ attributes[.backgroundColor] = NSColor.secondaryLabelColor.withAlphaComponent(0.1)
+ attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
+ }
+ }
+
+ let segmentText = NSAttributedString(string: segment.content, attributes: attributes)
+ result.append(segmentText)
+ }
+
+ // Add bottom spacing to prevent text from being hidden by bottom views
+ let spacingAttributes: [NSAttributedString.Key: Any] = [
+ .font: NSFont.monospacedSystemFont(ofSize: 14, weight: .regular),
+ .foregroundColor: NSColor.clear
+ ]
+ let spacingText = NSAttributedString(string: "\n\n\n\n\n\n\n", attributes: spacingAttributes)
+ result.append(spacingText)
+
+ return result
+ }
+}
+
+extension DiffTextView {
+ func makeCoordinator() -> Coordinator {
+ Coordinator(self)
+ }
+
+ class Coordinator: NSObject {
+ let parent: DiffTextView
+
+ init(_ parent: DiffTextView) {
+ self.parent = parent
+ }
+ }
+}
diff --git a/macos/Onit/UI/Notepad/DiffView.swift b/macos/Onit/UI/Notepad/DiffView.swift
new file mode 100644
index 000000000..84b31e61e
--- /dev/null
+++ b/macos/Onit/UI/Notepad/DiffView.swift
@@ -0,0 +1,371 @@
+//
+// DiffView.swift
+// Onit
+//
+// Created by Kévin Naudin on 07/07/2025.
+//
+
+import KeyboardShortcuts
+import SwiftUI
+import SwiftData
+
+struct DiffView: View {
+ @Environment(\.modelContext) private var modelContext
+ @State private var currentSegmentRect: CGRect? = nil
+ @State private var currentOperationBarSize: CGSize = CGSize(width: 140, height: 30)
+ @State private var shouldScrollToSegment: Bool = false
+ @State private var isScrolling: Bool = false
+
+ let viewModel: DiffViewModel
+ private let response: Response
+
+ init(viewModel: DiffViewModel) {
+ self.viewModel = viewModel
+ self.response = viewModel.response
+ }
+
+ var body: some View {
+ ZStack {
+ if let diffSegments = generateDiffSegments() {
+ DiffTextView(
+ segments: diffSegments,
+ currentOperationIndex: viewModel.currentOperationIndex,
+ effectiveChanges: viewModel.getEffectiveDiffChanges(),
+ onSegmentPositionChanged: { rect in
+ currentSegmentRect = rect
+ },
+ onSegmentClicked: { operationIndex in
+ viewModel.currentOperationIndex = operationIndex
+ },
+ shouldScrollToCurrentSegment: shouldScrollToSegment,
+ onScrollStateChanged: { scrolling in
+ isScrolling = scrolling
+ }
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .id("\(viewModel.response.currentDiffRevisionIndex)-\(viewModel.diffChanges.count)-\(viewModel.isPreviewingAllApproved)")
+ }
+
+ if viewModel.currentDiffChange?.status == .pending && !isScrolling && !viewModel.isPreviewingAllApproved {
+ if let segmentRect = currentSegmentRect {
+ GeometryReader { geometry in
+ VStack {
+ HStack {
+ currentOperationBar
+ .offset(x: calculateBarXOffset(segmentRect: segmentRect,
+ containerWidth: geometry.size.width,
+ barWidth: currentOperationBarSize.width))
+ Spacer()
+ }
+ .offset(y: segmentRect.minY - currentOperationBarSize.height)
+ Spacer()
+ }
+ }
+ }
+ }
+
+ VStack(alignment: .center, spacing: 8) {
+ Spacer()
+
+ if !viewModel.diffChanges.filter({ $0.status == .pending }).isEmpty {
+ compactNavigationBar
+ }
+
+ bottomToolbar
+ }
+ }
+ .background(.BG)
+ .background {
+ if !viewModel.diffChanges.filter({ $0.status == .pending }).isEmpty {
+ upArrowListener
+ downArrowListener
+ acceptListener
+ rejectListener
+ insertListener
+ }
+ }
+ .onChange(of: viewModel.currentOperationIndex) { _, _ in
+ currentSegmentRect = nil
+ }
+ .onChange(of: response.isPartial) { oldValue, newValue in
+ if oldValue == true && newValue == false {
+ viewModel.refreshForResponseUpdate()
+ }
+ }
+ .alert("Insertion Error", isPresented: Binding(
+ get: { viewModel.insertionError != nil },
+ set: { if !$0 { viewModel.clearInsertionError() } }
+ )) {
+ Button("OK", role: .cancel) {
+ viewModel.clearInsertionError()
+ }
+ } message: {
+ Text(viewModel.insertionError ?? "An unknown error occurred")
+ }
+ }
+
+ private func triggerScrollToSegment() {
+ shouldScrollToSegment = true
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ shouldScrollToSegment = false
+ }
+ }
+
+ private var currentOperationBar: some View {
+ HStack(spacing: 4) {
+ Button {
+ viewModel.approveCurrentChange()
+ } label: {
+ HStack(spacing: 6) {
+ KeyboardShortcutView(shortcut: KeyboardShortcut(KeyEquivalent("y")))
+ .font(.system(size: 13, weight: .medium))
+ Text("Accept")
+ .font(.system(size: 13, weight: .medium))
+ .foregroundStyle(.FG)
+ }
+ .padding(.horizontal, 6)
+ .padding(.vertical, 3)
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.white)
+ .background(
+ RoundedRectangle(cornerRadius: 3)
+ .fill(.acceptBG)
+ )
+
+ Button {
+ viewModel.rejectCurrentChange()
+ } label: {
+ HStack(spacing: 6) {
+ KeyboardShortcutView(shortcut: KeyboardShortcut(KeyEquivalent("n")))
+ .font(.system(size: 13, weight: .medium))
+ Text("Reject")
+ .font(.system(size: 13, weight: .medium))
+ .foregroundStyle(.FG)
+ }
+ .padding(.horizontal, 6)
+ .padding(.vertical, 3)
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.white)
+ .background(
+ RoundedRectangle(cornerRadius: 3)
+ .fill(.rejectBG)
+ )
+ }
+ .padding(2)
+ .background(
+ RoundedRectangle(cornerRadius: 4)
+ .fill(.gray700)
+ .stroke(.gray500)
+ )
+ .background(
+ GeometryReader { geometry in
+ Color.clear
+ .onAppear {
+ currentOperationBarSize = geometry.size
+ }
+ .onChange(of: geometry.size) { _, newSize in
+ currentOperationBarSize = newSize
+ }
+ }
+ )
+ }
+
+ private var compactNavigationBar: some View {
+ HStack(spacing: 2) {
+ Button {
+ viewModel.approveAllChanges()
+ } label: {
+ Text("Accept all")
+ .padding(6)
+ .foregroundStyle(.FG)
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.FG)
+ .background(
+ RoundedRectangle(cornerRadius: 4)
+ .fill(.T_7)
+ )
+ .onHover { isHovering in
+ currentSegmentRect = nil
+ if isHovering {
+ viewModel.startPreviewingAllApproved()
+ } else {
+ viewModel.stopPreviewingAllApproved()
+ }
+ }
+
+ HStack(spacing: 0) {
+ Button(action: {
+ viewModel.navigateToPreviousAvailablePendingChange()
+ triggerScrollToSegment()
+ }) {
+ Image(systemName: "chevron.up")
+ .foregroundColor(viewModel.canNavigatePrevious ? .primary : .secondary)
+ .padding(10)
+ .contentShape(Rectangle())
+ }
+ .disabled(!viewModel.canNavigatePrevious)
+ .buttonStyle(.plain)
+
+ Text("\(viewModel.currentPendingOperationNumber) / \(viewModel.totalPendingOperationsCount)")
+ .font(.system(size: 13, weight: .medium))
+ .foregroundColor(.secondary)
+ .frame(minWidth: 30)
+
+ Button(action: {
+ viewModel.navigateToNextAvailablePendingChange()
+ triggerScrollToSegment()
+ }) {
+ Image(systemName: "chevron.down")
+ .foregroundColor(viewModel.canNavigateNext ? .primary : .secondary)
+ .padding(10)
+ .contentShape(Rectangle())
+ }
+ .disabled(!viewModel.canNavigateNext)
+ .buttonStyle(.plain)
+ }
+ .padding(.horizontal, 2)
+ }
+ .padding(4)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(.gray700)
+ .stroke(.gray500)
+ )
+ }
+
+ private var bottomToolbar: some View {
+ HStack {
+ Button {
+ viewModel.createVariant()
+ } label: {
+ Text("Create variant")
+ .padding(6)
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.FG)
+ .background(
+ RoundedRectangle(cornerRadius: 7)
+ .fill(.T_7)
+ )
+
+ if viewModel.response.totalDiffRevisions > 1 {
+ ToggleDiffVariantView(response: viewModel.response, viewModel: viewModel)
+ .padding(.leading, 8)
+ }
+
+ Spacer()
+
+ if let diffPreview = response.diffPreview {
+ CopyButton(text: diffPreview)
+ }
+
+ Button {
+ viewModel.insert()
+ } label: {
+ HStack(spacing: 6) {
+ // TODO: KNA - Uncomment when we use the Google Docs API.
+ let text = /*viewModel.diffArguments?.document_url != nil ? "Update" : */"Insert"
+
+ Text(text)
+
+ if viewModel.isInserting {
+ Loader(size: 14, scaleEffect: 0.5)
+ } else {
+ KeyboardShortcutView(shortcut: KeyboardShortcut(.return))
+ .font(.system(size: 13, weight: .medium))
+ .padding(.horizontal, 4)
+ .padding(.vertical, 4)
+ .background(.blue300, in: .rect(cornerRadius: 5))
+ }
+ }
+ .padding(6)
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.FG)
+ .background(
+ RoundedRectangle(cornerRadius: 7)
+ .fill(.blue400)
+ )
+ .disabled(!viewModel.hasUnsavedChanges || viewModel.isInserting)
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 8)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(.gray700)
+ .stroke(.gray600)
+ )
+ .padding(.horizontal, 16)
+ .padding(.bottom, 24)
+ }
+ private func generateDiffSegments() -> [DiffSegment]? {
+ guard let arguments = viewModel.diffArguments,
+ let result = viewModel.diffResult else { return nil }
+
+ return DiffSegmentUtils.generateDiffSegments(
+ originalText: arguments.original_content,
+ operations: result.operations
+ )
+ }
+
+ private func calculateBarXOffset(segmentRect: CGRect, containerWidth: CGFloat, barWidth: CGFloat) -> CGFloat {
+ let desiredXOffset = segmentRect.minX
+ let minXOffset: CGFloat = 0
+ let maxXOffset = max(0, containerWidth - barWidth)
+
+ return max(minXOffset, min(maxXOffset, desiredXOffset))
+ }
+}
+
+// MARK: - Keyboard Listeners
+
+extension DiffView {
+ private var upArrowListener: some View {
+ KeyListener(key: .upArrow, modifiers: []) {
+ viewModel.navigateToPreviousAvailablePendingChange()
+ triggerScrollToSegment()
+ }
+ }
+
+ private var downArrowListener: some View {
+ KeyListener(key: .downArrow, modifiers: []) {
+ viewModel.navigateToNextAvailablePendingChange()
+ triggerScrollToSegment()
+ }
+ }
+
+ private var acceptListener: some View {
+ KeyListener(key: KeyEquivalent("y"), modifiers: [.command]) {
+ viewModel.approveCurrentChange()
+ }
+ }
+
+ private var rejectListener: some View {
+ KeyListener(key: KeyEquivalent("n"), modifiers: [.command]) {
+ viewModel.rejectCurrentChange()
+ }
+ }
+
+ private var insertListener: some View {
+ KeyListener(key: .return, modifiers: [.command]) {
+ viewModel.insert()
+ }
+ }
+}
+
+// MARK: - DiffSegment Model
+
+struct DiffSegment {
+ let content: String
+ let type: DiffSegmentType
+ let operationIndex: Int?
+}
+
+enum DiffSegmentType {
+ case unchanged
+ case added
+ case removed
+}
diff --git a/macos/Onit/UI/Notepad/DiffViewModel.swift b/macos/Onit/UI/Notepad/DiffViewModel.swift
new file mode 100644
index 000000000..c8044cc11
--- /dev/null
+++ b/macos/Onit/UI/Notepad/DiffViewModel.swift
@@ -0,0 +1,568 @@
+//
+// DiffViewModel.swift
+// Onit
+//
+// Created by Kévin Naudin on 07/07/2025.
+//
+
+import AppKit
+import Combine
+import Foundation
+import SwiftData
+
+@MainActor
+@Observable
+class DiffViewModel {
+ private let modelContext: ModelContext
+ let response: Response
+
+ // Current state
+ var diffChanges: [DiffChangeState] = []
+ var currentOperationIndex: Int = 0
+ var hasUnsavedChanges: Bool = false
+ var isPreviewingAllApproved: Bool = false
+ var isInserting: Bool = false
+ var insertionError: String? = nil
+
+ // Computed properties
+ var diffArguments: DiffTool.PlainTextDiffArguments? {
+ response.diffArguments
+ }
+
+ var diffResult: DiffTool.PlainTextDiffResult? {
+ response.diffResult
+ }
+
+ var currentOperation: DiffTool.PlainTextDiffOperation? {
+ guard let result = diffResult,
+ currentOperationIndex < result.operations.count else { return nil }
+ return result.operations[currentOperationIndex]
+ }
+
+ var currentDiffChange: DiffChangeState? {
+ diffChanges.first { $0.operationIndex == currentOperationIndex }
+ }
+
+ var statistics: (pending: Int, approved: Int, rejected: Int) {
+ getChangeStatistics()
+ }
+
+ var canNavigateNext: Bool {
+ return getNextPendingChange(after: currentOperationIndex) != nil
+ }
+
+ var canNavigatePrevious: Bool {
+ return getPreviousPendingChange(before: currentOperationIndex) != nil
+ }
+
+ var allChangesApproved: Bool {
+ let stats = statistics
+ return stats.pending == 0 && stats.approved > 0
+ }
+
+ var totalPendingOperationsCount: Int {
+ return diffChanges.filter { $0.status == .pending }.count
+ }
+
+ var currentPendingOperationNumber: Int {
+ let pendingChanges = diffChanges.filter { $0.status == .pending }
+ .sorted { $0.operationIndex < $1.operationIndex }
+
+ if let currentIndex = pendingChanges.firstIndex(where: { $0.operationIndex == currentOperationIndex }) {
+ return currentIndex + 1
+ }
+ return 1
+ }
+
+ init(response: Response) {
+ self.response = response
+ self.modelContext = SwiftDataContainer.appContainer.mainContext
+
+ loadOrCreateDiffChanges()
+ }
+
+ func refreshForResponseUpdate() {
+ let hadChanges = !diffChanges.isEmpty
+
+ loadOrCreateDiffChanges()
+
+ if hadChanges && !diffChanges.isEmpty {
+ if currentOperationIndex >= diffChanges.count {
+ if let firstPending = diffChanges.first(where: { $0.status == .pending }) {
+ currentOperationIndex = firstPending.operationIndex
+ }
+ }
+ }
+ }
+
+ func refreshForRevisionChange() {
+ diffChanges = getDiffChanges()
+
+ if let firstPending = diffChanges.first(where: { $0.status == .pending }) {
+ currentOperationIndex = firstPending.operationIndex
+ } else {
+ currentOperationIndex = 0
+ }
+
+ isPreviewingAllApproved = false
+ }
+
+ // MARK: - Data Management
+
+ private func loadOrCreateDiffChanges() {
+ diffChanges = getDiffChanges()
+
+ if diffChanges.isEmpty && response.diffResult != nil {
+ createDiffChanges()
+ diffChanges = getDiffChanges()
+ } else if let diffResult = response.diffResult,
+ diffResult.operations.count > diffChanges.count {
+ updateDiffChangesForNewOperations()
+ diffChanges = getDiffChanges()
+ }
+
+ if let firstPending = diffChanges.first(where: { $0.status == .pending }) {
+ currentOperationIndex = firstPending.operationIndex
+ }
+ }
+
+ // MARK: - Database Operations
+
+ private func getDiffChanges() -> [DiffChangeState] {
+ return response.currentDiffChanges.sorted { $0.operationIndex < $1.operationIndex }
+ }
+
+ private func getDiffChange(operationIndex: Int) -> DiffChangeState? {
+ return response.currentDiffChanges.first { $0.operationIndex == operationIndex }
+ }
+
+ private func createDiffChanges() {
+ guard let diffResult = response.diffResult else {
+ return
+ }
+
+ if !diffChanges.isEmpty {
+ if diffChanges.count == diffResult.operations.count {
+ return
+ }
+ }
+
+ var newRevisionChanges: [DiffChangeState] = []
+
+ for (index, operation) in diffResult.operations.enumerated() {
+ let diffChange = DiffChangeState(
+ operationIndex: index,
+ operationType: operation.type,
+ status: .pending,
+ operationText: operation.text ?? operation.newText,
+ operationStartIndex: operation.startIndex ?? operation.index,
+ operationEndIndex: operation.endIndex
+ )
+ modelContext.insert(diffChange)
+ newRevisionChanges.append(diffChange)
+ }
+
+ response.createNewDiffRevision(with: newRevisionChanges)
+ saveContext(modelContext)
+ }
+
+ private func updateDiffChangesForNewOperations() {
+ guard let diffResult = response.diffResult else { return }
+
+ let existingCount = diffChanges.count
+ let totalOperations = diffResult.operations.count
+
+ guard totalOperations > existingCount else { return }
+
+ var newChanges: [DiffChangeState] = []
+
+ for index in existingCount.. approvedOperationIndex && change.status == .pending {
+ updateIndicesForChange(change, withOffset: offset)
+ }
+ }
+ }
+
+ private func calculateOffsetForOperation(_ operation: DiffTool.PlainTextDiffOperation) -> Int {
+ switch operation.type {
+ case .insertText:
+ return operation.text?.count ?? 0
+
+ case .deleteContentRange:
+ if let startIndex = operation.startIndex, let endIndex = operation.endIndex {
+ return -(endIndex - startIndex)
+ }
+ return 0
+
+ case .replaceText:
+ if let startIndex = operation.startIndex,
+ let endIndex = operation.endIndex,
+ let newText = operation.newText {
+ let deletedLength = endIndex - startIndex
+ let insertedLength = newText.count
+ return insertedLength - deletedLength
+ }
+ return 0
+ }
+ }
+
+ private func updateIndicesForChange(_ change: DiffChangeState, withOffset offset: Int) {
+ if let startIndex = change.operationStartIndex {
+ change.operationStartIndex = max(0, startIndex + offset)
+ }
+
+ if let endIndex = change.operationEndIndex {
+ change.operationEndIndex = max(0, endIndex + offset)
+ }
+ }
+
+ private func getAdjustedDiffChanges() -> [DiffChangeData] {
+ let changes = getDiffChanges()
+ var adjustedChanges: [DiffChangeData] = []
+ var cumulativeOffset = 0
+
+ for change in changes.sorted(by: { $0.operationIndex < $1.operationIndex }) {
+ if change.status == .approved {
+ if let operation = getOperation(for: change) {
+ let originalStartIndex: Int?
+ let originalEndIndex: Int?
+
+ switch operation.type {
+ case .insertText:
+ originalStartIndex = operation.index
+ originalEndIndex = nil
+ case .deleteContentRange, .replaceText:
+ originalStartIndex = operation.startIndex
+ originalEndIndex = operation.endIndex
+ }
+
+ adjustedChanges.append(DiffChangeData(
+ operationIndex: change.operationIndex,
+ operationType: change.operationType,
+ status: change.status,
+ operationText: change.operationText,
+ operationStartIndex: originalStartIndex,
+ operationEndIndex: originalEndIndex
+ ))
+
+ cumulativeOffset += calculateOffsetForOperation(operation)
+ } else {
+ // Fallback if operation not found
+ adjustedChanges.append(DiffChangeData(
+ operationIndex: change.operationIndex,
+ operationType: change.operationType,
+ status: change.status,
+ operationText: change.operationText,
+ operationStartIndex: change.operationStartIndex,
+ operationEndIndex: change.operationEndIndex
+ ))
+ }
+ } else {
+ let adjustedStartIndex = change.operationStartIndex.map { max(0, $0 + cumulativeOffset) }
+ let adjustedEndIndex = change.operationEndIndex.map { max(0, $0 + cumulativeOffset) }
+
+ adjustedChanges.append(DiffChangeData(
+ operationIndex: change.operationIndex,
+ operationType: change.operationType,
+ status: change.status,
+ operationText: change.operationText,
+ operationStartIndex: adjustedStartIndex,
+ operationEndIndex: adjustedEndIndex
+ ))
+ }
+ }
+
+ return adjustedChanges
+ }
+
+ private func getOperation(for change: DiffChangeState) -> DiffTool.PlainTextDiffOperation? {
+ guard let diffResult = response.diffResult,
+ change.operationIndex < diffResult.operations.count else {
+ return nil
+ }
+ return diffResult.operations[change.operationIndex]
+ }
+
+ private func getChangeStatistics() -> (pending: Int, approved: Int, rejected: Int) {
+ let changes = getDiffChanges()
+
+ let pending = changes.filter { $0.status == .pending }.count
+ let approved = changes.filter { $0.status == .approved }.count
+ let rejected = changes.filter { $0.status == .rejected }.count
+
+ return (pending: pending, approved: approved, rejected: rejected)
+ }
+
+ private func getNextPendingChange(after currentIndex: Int) -> DiffChangeState? {
+ let changes = getDiffChanges()
+ .filter { $0.status == .pending && $0.operationIndex > currentIndex }
+ return changes.first
+ }
+
+ private func getPreviousPendingChange(before currentIndex: Int) -> DiffChangeState? {
+ let changes = getDiffChanges()
+ .filter { $0.status == .pending && $0.operationIndex < currentIndex }
+ return changes.last
+ }
+
+ private func saveContext(_ context: ModelContext) {
+ do {
+ try context.save()
+ } catch {
+ print("Error saving diff changes: \(error)")
+ }
+ }
+
+ // MARK: - Actions
+
+ func createVariant() {
+ response.createVariantWithContext(modelContext)
+
+ resetViewModel()
+ }
+
+ func approveCurrentChange() {
+ updateCurrentChangeStatus(.approved)
+ }
+
+ func rejectCurrentChange() {
+ updateCurrentChangeStatus(.rejected)
+ }
+
+ func approveAllChanges() {
+ let pendingChanges = diffChanges.filter { $0.status == .pending }
+ .sorted { $0.operationIndex < $1.operationIndex }
+
+ for change in pendingChanges {
+ updateDiffChangeStatus(
+ operationIndex: change.operationIndex,
+ status: .approved
+ )
+ }
+
+ refreshChanges()
+ hasUnsavedChanges = true
+ }
+
+ func rejectAllChanges() {
+ let pendingChanges = diffChanges.filter { $0.status == .pending }
+
+ for change in pendingChanges {
+ updateDiffChangeStatus(
+ operationIndex: change.operationIndex,
+ status: .rejected
+ )
+ }
+
+ refreshChanges()
+ }
+
+ // MARK: - Navigation
+
+ func navigateToNextAvailablePendingChange() {
+ if let nextPending = getNextPendingChange(after: currentOperationIndex) {
+ currentOperationIndex = nextPending.operationIndex
+ return
+ }
+
+ let pendingChanges = diffChanges.filter { $0.status == .pending }
+ .sorted { $0.operationIndex < $1.operationIndex }
+
+ if let lastPending = pendingChanges.last {
+ currentOperationIndex = lastPending.operationIndex
+ }
+ }
+
+ func navigateToPreviousAvailablePendingChange() {
+ if let previousPending = getPreviousPendingChange(before: currentOperationIndex) {
+ currentOperationIndex = previousPending.operationIndex
+ return
+ }
+
+ let pendingChanges = diffChanges.filter { $0.status == .pending }
+ .sorted { $0.operationIndex < $1.operationIndex }
+
+ if let firstPending = pendingChanges.first {
+ currentOperationIndex = firstPending.operationIndex
+ }
+ }
+
+ // MARK: - Text Generation
+
+ func clearInsertionError() {
+ insertionError = nil
+ }
+
+ // MARK: - Insertion
+
+ func insert() {
+ guard let diffArguments = diffArguments else {
+ insertionError = "No arguments available"
+ return
+ }
+
+ // TODO: KNA - Let's not use the Google Docs API update until it's working optimally.
+// if let documentUrl = diffArguments.document_url {
+// Task {
+// await insertToDocument(documentUrl: documentUrl)
+// }
+// } else {
+ let appName = diffArguments.app_name
+ let runningApps = NSWorkspace.shared.runningApplications
+
+ guard let runningApp = runningApps.first(where: { app in
+ app.localizedName?.localizedCaseInsensitiveContains(appName) == true
+ }) else {
+ insertionError = "Application '\(appName)' is not running. Please open the application and try again."
+ return
+ }
+
+ guard let diffPreview = response.diffPreview else {
+ insertionError = "No text available. Please approve some changes before inserting."
+ return
+ }
+
+ runningApp.activate()
+
+ let source = CGEventSource(stateID: .hidSystemState)
+ let pasteDown = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true)
+ let pasteUp = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false)
+
+ pasteDown?.flags = .maskCommand
+ pasteUp?.flags = .maskCommand
+
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(diffPreview, forType: .string)
+
+ pasteDown?.post(tap: .cghidEventTap)
+ pasteUp?.post(tap: .cghidEventTap)
+// }
+ }
+
+ @MainActor
+ private func insertToDocument(documentUrl: String) async {
+ isInserting = true
+ insertionError = nil
+
+ do {
+ try await applyApprovedChangesToDocument(documentUrl: documentUrl)
+ } catch {
+ insertionError = error.localizedDescription
+ }
+
+ hasUnsavedChanges = false
+ isInserting = false
+ }
+
+ func applyApprovedChangesToDocument(documentUrl: String) async throws {
+ let adjustedChanges = getAdjustedDiffChanges()
+ let approvedChanges = adjustedChanges.filter { $0.status == .approved }
+
+ guard !approvedChanges.isEmpty else {
+ log.error("no approved changes")
+ return
+ }
+
+ try await GoogleDocumentManager.applyDiffChangesToDocument(
+ documentUrl: documentUrl,
+ diffChanges: approvedChanges
+ )
+ }
+
+ // MARK: - Preview Mode
+
+ func startPreviewingAllApproved() {
+ isPreviewingAllApproved = true
+ }
+
+ func stopPreviewingAllApproved() {
+ isPreviewingAllApproved = false
+ }
+
+ func getEffectiveDiffChanges() -> [DiffChangeData] {
+ let adjustedChanges = getAdjustedDiffChanges()
+
+ if isPreviewingAllApproved {
+ return adjustedChanges.map { change in
+ DiffChangeData(
+ operationIndex: change.operationIndex,
+ operationType: change.operationType,
+ status: change.status == .pending ? .approved : change.status,
+ operationText: change.operationText,
+ operationStartIndex: change.operationStartIndex,
+ operationEndIndex: change.operationEndIndex
+ )
+ }
+ } else {
+ return adjustedChanges
+ }
+ }
+
+ // MARK: - Private
+
+ private func updateCurrentChangeStatus(_ status: DiffChangeStatus) {
+ updateDiffChangeStatus(
+ operationIndex: currentOperationIndex,
+ status: status
+ )
+ refreshChanges()
+
+ if status == .approved {
+ hasUnsavedChanges = true
+ }
+
+ navigateToNextAvailablePendingChange()
+ }
+
+ private func refreshChanges() {
+ diffChanges = getDiffChanges()
+ }
+
+ private func resetViewModel() {
+ loadOrCreateDiffChanges()
+
+ hasUnsavedChanges = false
+ isPreviewingAllApproved = false
+ isInserting = false
+ insertionError = nil
+ }
+}
diff --git a/macos/Onit/UI/Notepad/NotepadView.swift b/macos/Onit/UI/Notepad/NotepadView.swift
new file mode 100644
index 000000000..7ddaeafda
--- /dev/null
+++ b/macos/Onit/UI/Notepad/NotepadView.swift
@@ -0,0 +1,54 @@
+//
+// NotepadView.swift
+// Onit
+//
+// Created by Kévin Naudin on 13/03/2025.
+//
+
+import SwiftUI
+import SwiftData
+
+struct NotepadView: View {
+ static let toolbarHeight: CGFloat = 32
+
+ let response: Response
+ let closeCompletion: (() -> Void)
+
+ @State private var viewModel: DiffViewModel
+
+ init(response: Response, closeCompletion: @escaping () -> Void) {
+ self.response = response
+ self.closeCompletion = closeCompletion
+ self._viewModel = State(initialValue: DiffViewModel(response: response))
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ toolbar
+ PromptDivider()
+ DiffView(viewModel: viewModel)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ }
+ }
+
+ private var toolbar: some View {
+ HStack {
+ close
+ Spacer()
+
+ }
+ .frame(height: Self.toolbarHeight)
+ }
+
+ private var close: some View {
+ Button(action: {
+ // viewModel.createVariant()
+ closeCompletion()
+ }) {
+ Image(.smallCross)
+ .frame(width: 48, height: 32)
+ .foregroundStyle(.gray200)
+ }
+ .buttonStyle(.plain)
+ }
+}
diff --git a/macos/Onit/UI/Notepad/ToggleDiffVariantView.swift b/macos/Onit/UI/Notepad/ToggleDiffVariantView.swift
new file mode 100644
index 000000000..a3a5f4dd1
--- /dev/null
+++ b/macos/Onit/UI/Notepad/ToggleDiffVariantView.swift
@@ -0,0 +1,118 @@
+//
+// ToggleDiffVariantView.swift
+// Onit
+//
+// Created by Kévin Naudin on 29/07/2025.
+//
+
+import SwiftUI
+
+struct ToggleDiffVariantView: View {
+ @Environment(\.windowState) var windowState
+
+ let response: Response
+ let viewModel: DiffViewModel
+
+ private var currentPosition: Int? {
+ let sortedRevisions = response.sortedDiffRevisions
+
+ return sortedRevisions.firstIndex(where: {
+ $0.index == response.currentDiffRevisionIndex
+ })
+ }
+
+ var body: some View {
+ HStack(spacing: 0) {
+ `left`
+ text
+ `right`
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.gray300)
+ }
+
+ var left: some View {
+ Button {
+ decrementRevisionIndex()
+ } label: {
+ Color.clear
+ .frame(width: 20, height: 20)
+ .overlay {
+ Image(.chevLeft)
+ }
+ }
+ .foregroundStyle(canDecrementRevision ? .FG : .gray300)
+ .disabled(!canDecrementRevision)
+ }
+
+ @ViewBuilder
+ var text: some View {
+ if response.totalDiffRevisions > 1 {
+ let sortedRevisions = response.sortedDiffRevisions
+ let currentPosition = sortedRevisions.firstIndex { $0.index == response.currentDiffRevisionIndex } ?? 0
+ Text("\(currentPosition + 1) / \(response.totalDiffRevisions)")
+ .font(.system(size: 13, weight: .medium))
+ .foregroundStyle(.FG)
+ }
+ }
+
+ var right: some View {
+ Button {
+ incrementRevisionIndex()
+ } label: {
+ Color.clear
+ .frame(width: 20, height: 20)
+ .overlay {
+ Image(.chevRight)
+ }
+ }
+ .foregroundStyle(canIncrementRevision ? .FG : .gray300)
+ .disabled(!canIncrementRevision)
+ }
+
+ // MARK: - Computed Properties
+
+ private var canDecrementRevision: Bool {
+ guard let currentPosition = currentPosition else { return false }
+
+ return currentPosition > 0
+ }
+
+ private var canIncrementRevision: Bool {
+ guard let currentPosition = currentPosition else { return false }
+
+ let sortedRevisions = response.sortedDiffRevisions
+
+ return currentPosition < sortedRevisions.count - 1
+ }
+}
+
+// MARK: - Private Functions
+
+extension ToggleDiffVariantView {
+ private func decrementRevisionIndex() {
+ if canDecrementRevision {
+ guard let currentPosition = currentPosition, currentPosition > 0 else { return }
+
+ let sortedRevisions = response.sortedDiffRevisions
+ let previousRevision = sortedRevisions[currentPosition - 1]
+
+ response.setCurrentRevision(index: previousRevision.index)
+ viewModel.refreshForRevisionChange()
+ }
+ }
+
+ private func incrementRevisionIndex() {
+ if canIncrementRevision {
+ let sortedRevisions = response.sortedDiffRevisions
+
+ guard let currentPosition = currentPosition,
+ currentPosition < sortedRevisions.count - 1 else { return }
+
+ let nextRevision = sortedRevisions[currentPosition + 1]
+
+ response.setCurrentRevision(index: nextRevision.index)
+ viewModel.refreshForRevisionChange()
+ }
+ }
+}
diff --git a/macos/Onit/UI/Panels/OnitRegularPanel+Move.swift b/macos/Onit/UI/Panels/OnitRegularPanel+Move.swift
index 1e31a3655..826ab962a 100644
--- a/macos/Onit/UI/Panels/OnitRegularPanel+Move.swift
+++ b/macos/Onit/UI/Panels/OnitRegularPanel+Move.swift
@@ -11,6 +11,10 @@ import SwiftUI
extension OnitRegularPanel {
@objc func windowDidMove(_ notification: Notification) {
+ state.notifyDelegates { delegate in
+ delegate.panelFrameDidChange(state: state)
+ }
+
guard let activeWindow = state.trackedWindow?.element,
let activeWindowFrame = activeWindow.getFrame(),
wasAnimated, !isAnimating, dragDetails.isDragging, !state.isWindowDragging else { return }
diff --git a/macos/Onit/UI/Panels/State/OnitPanelState+Chat.swift b/macos/Onit/UI/Panels/State/OnitPanelState+Chat.swift
index c7862301a..9320ec78f 100644
--- a/macos/Onit/UI/Panels/State/OnitPanelState+Chat.swift
+++ b/macos/Onit/UI/Panels/State/OnitPanelState+Chat.swift
@@ -218,7 +218,7 @@ extension OnitPanelState {
partialResponse.toolCallArguments = response.toolArguments ?? partialResponse.toolCallArguments
if !response.isToolComplete {
- self.executeToolCall(partialResponse: partialResponse)
+ self.executeToolCall(partialResponse: partialResponse, throttleMs: 300)
}
}
} else {
@@ -338,6 +338,7 @@ extension OnitPanelState {
generateTask = nil
toolExecutionTask = nil
lastToolExecutionTime = nil
+ lastToolCallExecutedHash = nil
if let curPrompt = generatingPrompt, let priorState = generatingPromptPriorState {
curPrompt.generationState = priorState
}
@@ -453,6 +454,13 @@ extension OnitPanelState {
isComplete: Bool = false,
throttleMs: Int = 150
) {
+ let currentToolCallHash = createToolCallHash(response: partialResponse)
+
+ // Skip execution if toolCall hasn't changed
+ if currentToolCallHash == lastToolCallExecutedHash {
+ return
+ }
+
let now = Date()
let throttleSeconds = Double(throttleMs) / 1000.0
@@ -461,14 +469,18 @@ extension OnitPanelState {
}
toolExecutionTask = Task { @MainActor in
+ let lastToolExecutionTimeBackup = self.lastToolExecutionTime
+ self.lastToolExecutionTime = now
+
guard await executeToolCallSync(
partialResponse: partialResponse,
isComplete: isComplete
) else {
+ self.lastToolExecutionTime = lastToolExecutionTimeBackup
return
}
- self.lastToolExecutionTime = now
+ self.lastToolCallExecutedHash = currentToolCallHash
}
}
@@ -478,7 +490,7 @@ extension OnitPanelState {
) async -> Bool {
guard let toolName = partialResponse.toolCallName,
let toolArguments = partialResponse.toolCallArguments,
- !toolArguments.isEmpty else {
+ !toolName.isEmpty, !toolArguments.isEmpty else {
return false
}
@@ -493,6 +505,8 @@ extension OnitPanelState {
case .success(let success):
partialResponse.toolCallResult = success.result
partialResponse.toolCallSuccess = true
+
+ completionAfterToolCallSucceed(response: partialResponse)
case .failure(let failure):
partialResponse.toolCallResult = failure.message
partialResponse.toolCallSuccess = false
@@ -503,5 +517,23 @@ extension OnitPanelState {
return false
}
+
+ private func completionAfterToolCallSucceed(response: Response) {
+ if response.toolCallName?.hasPrefix("diff_") == true {
+ guard !isDiffViewActive else { return }
+
+ NotepadWindowController.shared.showWindow(windowState: self, response: response)
+ }
+ }
+
+ private func createToolCallHash(response: Response) -> String {
+ guard let toolName = response.toolCallName,
+ let arguments = response.toolCallArguments else {
+ return ""
+ }
+ let combined = "\(toolName)|\(arguments)"
+
+ return String(combined.hashValue)
+ }
}
diff --git a/macos/Onit/UI/Panels/State/OnitPanelState.swift b/macos/Onit/UI/Panels/State/OnitPanelState.swift
index 3c1b5d30c..2ef949789 100644
--- a/macos/Onit/UI/Panels/State/OnitPanelState.swift
+++ b/macos/Onit/UI/Panels/State/OnitPanelState.swift
@@ -15,6 +15,7 @@ import SwiftUI
func panelBecomeKey(state: OnitPanelState)
func panelResignKey(state: OnitPanelState)
func panelStateDidChange(state: OnitPanelState)
+ func panelFrameDidChange(state: OnitPanelState)
func userInputsDidChange(instruction: String, contexts: [Context], input: Input?)
}
@@ -105,7 +106,13 @@ class OnitPanelState: NSObject {
var tetheredButtonYRelativePosition: CGFloat?
- var panelWidth: CGFloat
+ var panelWidth: CGFloat {
+ didSet {
+ notifyDelegates { delegate in
+ delegate.panelFrameDidChange(state: self)
+ }
+ }
+ }
var currentChat: Chat?
var currentPrompts: [Prompt]?
@@ -143,6 +150,9 @@ class OnitPanelState: NSObject {
var showHistory: Bool = false
var historyIndex = -1
+ var isDiffViewActive: Bool = false
+ var responseUsedForDiffView: Response?
+
var setUpHeight: CGFloat = 0
var generateTask: Task? = nil
@@ -156,6 +166,7 @@ class OnitPanelState: NSObject {
}
}
var lastToolExecutionTime: Date? = nil
+ var lastToolCallExecutedHash: String?
// Web search states
var webSearchError: Error? = nil
diff --git a/macos/Onit/UI/Prompt/Generated/GeneratedContentView.swift b/macos/Onit/UI/Prompt/Generated/GeneratedContentView.swift
index 7808b33a2..b1b5d5675 100644
--- a/macos/Onit/UI/Prompt/Generated/GeneratedContentView.swift
+++ b/macos/Onit/UI/Prompt/Generated/GeneratedContentView.swift
@@ -32,7 +32,9 @@ struct GeneratedContentView: View {
citationTextColor: .gray100)
let thought = ThoughtConfiguration(icon: Image(.lightBulb))
- return LLMStreamConfiguration(font: font, colors: color, thought: thought)
+ return LLMStreamConfiguration(font: font,
+ colors: color,
+ thought: thought)
}
@@ -43,18 +45,19 @@ struct GeneratedContentView: View {
VStack(alignment: .leading, spacing: 8) {
LLMStreamView(text: textToRead,
- configuration: configuration,
- onUrlClicked: onUrlClicked,
- onCodeAction: codeAction)
+ configuration: configuration,
+ onUrlClicked: onUrlClicked)
.id("\(fontSize)-\(lineHeight)") // Force recreation when font settings change
.padding(.horizontal, 12)
if let response = prompt.currentResponse, response.hasToolCall {
- ToolCallView(response: response)
+ ToolCallHandlerView(response: response)
.padding(.horizontal, 12)
+ .padding(.bottom, 12)
+ } else {
+ Spacer()
}
- Spacer()
if textToRead.isEmpty && !(state?.isSearchingWeb[prompt.id] ?? false) && prompt.currentResponse?.hasToolCall != true {
HStack {
Spacer()
@@ -72,10 +75,6 @@ struct GeneratedContentView: View {
openURL(url)
}
}
-
- private func codeAction(code: String) {
-
- }
}
#Preview {
diff --git a/macos/Onit/UI/Prompt/Generated/GeneratedToolbar.swift b/macos/Onit/UI/Prompt/Generated/GeneratedToolbar.swift
index 4d1433467..e3a141793 100644
--- a/macos/Onit/UI/Prompt/Generated/GeneratedToolbar.swift
+++ b/macos/Onit/UI/Prompt/Generated/GeneratedToolbar.swift
@@ -13,29 +13,53 @@ struct GeneratedToolbar: View {
@Default(.mode) var mode
var prompt: Prompt
+
+ private var isDiffViewActive: Bool {
+ state?.responseUsedForDiffView == prompt.currentResponse &&
+ state?.isDiffViewActive == true
+ }
var body: some View {
HStack(spacing: 8) {
+ diffButton
copy
regenerate
selector
Spacer()
- if prompt.safeGenerationIndex >= 0 &&
- prompt.safeGenerationIndex < prompt.responses.count,
- let model = prompt.sortedResponses[prompt.safeGenerationIndex].model {
+ if let model = prompt.currentResponse?.model {
Text("\(model)")
.foregroundColor(Color.gray300)
}
}
.padding(.horizontal, 12)
}
+
+ @ViewBuilder
+ var diffButton: some View {
+ if let response = prompt.currentResponse,
+ let state = state,
+ response.isDiffResponse {
+ IconButton(
+ icon: .lucideDiff,
+ isActive: isDiffViewActive,
+ activeBackground: .gray800,
+ tooltipPrompt: "Diff"
+ ) {
+ if !isDiffViewActive {
+ NotepadWindowController.shared.showWindow(windowState: state, response: response)
+ }
+ }
+ }
+ }
@ViewBuilder
var copy: some View {
if let generation = prompt.generation {
- CopyButton(text: generation, stripMarkdown: true)
+ let stripMarkdown = generation != prompt.currentResponse?.diffPreview
+
+ CopyButton(text: generation, stripMarkdown: stripMarkdown)
}
}
diff --git a/macos/Onit/UI/Prompt/Generated/Tools/DiffToolView.swift b/macos/Onit/UI/Prompt/Generated/Tools/DiffToolView.swift
new file mode 100644
index 000000000..08af66641
--- /dev/null
+++ b/macos/Onit/UI/Prompt/Generated/Tools/DiffToolView.swift
@@ -0,0 +1,101 @@
+//
+// DiffToolView.swift
+// Onit
+//
+// Created by Kévin Naudin on 07/07/2025.
+//
+
+import Defaults
+import SwiftUI
+
+struct DiffToolView: View {
+ @Default(.lineHeight) var lineHeight
+ @Default(.fontSize) var fontSize
+
+ let response: Response
+
+ private var message: String {
+ if response.isPartial {
+ return "Streaming in Diff View..."
+ } else {
+ return "Content in Diff View"
+ }
+ }
+
+ var body: some View {
+ if response.shouldDisplayDiffToolView {
+ toolView
+ } else {
+ previewText
+ }
+ }
+
+ private var toolView: some View {
+ HStack(alignment: .center, spacing: 2) {
+ ZStack(alignment: .center) {
+ RoundedRectangle(cornerRadius: 4)
+ .fill(.redPale400.opacity(0.22))
+ .frame(width: 18, height: 18)
+ Text("-")
+ .appFont(.medium14)
+ .foregroundStyle(.redPale400)
+ .frame(alignment: .center)
+ }
+
+ ZStack(alignment: .center) {
+ RoundedRectangle(cornerRadius: 4)
+ .fill(.limeGreen.opacity(0.22))
+ .frame(width: 18, height: 18)
+ Text("+")
+ .appFont(.medium14)
+ .foregroundStyle(.limeGreen)
+ .frame(alignment: .center)
+ }
+
+ Text(message)
+ .foregroundStyle(.gray100)
+ .padding(.leading, 2)
+
+ Button {
+
+ } label: {
+ Image(systemName: "info.circle")
+ .foregroundStyle(.gray200)
+ .frame(width: 16, height: 16)
+ }
+ .tooltip(prompt: "Diff view automatically activates\nwhen an iteration or edit is\ndetected.")
+ .padding(.leading, 2)
+
+ Spacer()
+
+ Button {
+ switchToChat()
+ } label: {
+ Text("Switch to Chat →")
+ .foregroundStyle(.FG)
+ }
+ }
+ .padding(8)
+ .background {
+ RoundedRectangle(cornerRadius: 8)
+ .strokeBorder(.gray700)
+ }
+ }
+
+ @ViewBuilder
+ private var previewText: some View {
+ if let diffPreview = response.diffPreview {
+ Text(diffPreview)
+ .textSelection(.enabled)
+ .font(.system(size: fontSize))
+ .lineSpacing(lineHeight)
+ .foregroundStyle(.FG)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+
+ private func switchToChat() {
+ NotepadWindowController.shared.closeWindow()
+ response.shouldDisplayDiffToolView = false
+ }
+}
diff --git a/macos/Onit/UI/Prompt/Generated/Tools/ToolCallHandlerView.swift b/macos/Onit/UI/Prompt/Generated/Tools/ToolCallHandlerView.swift
new file mode 100644
index 000000000..ad9327046
--- /dev/null
+++ b/macos/Onit/UI/Prompt/Generated/Tools/ToolCallHandlerView.swift
@@ -0,0 +1,80 @@
+//
+// ToolCallHandlerView.swift
+// Onit
+//
+// Created by Kévin Naudin on 07/07/2025.
+//
+
+import SwiftUI
+
+struct ToolCallHandlerView: View {
+ let response: Response
+
+ var body: some View {
+ Group {
+ if let functionName = response.toolCallName, !functionName.isEmpty {
+ toolView(for: functionName)
+ } else {
+ EmptyView()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func toolView(for functionName: String) -> some View {
+ let nameParts = functionName.split(separator: "_")
+
+ if nameParts.count >= 2 {
+ let appName = String(nameParts[0])
+
+ switch appName {
+ case "diff":
+ DiffToolView(response: response)
+ default:
+ ToolCallView(response: response)
+ }
+ } else {
+ ToolCallView(response: response)
+ }
+ }
+}
+
+// MARK: - Tool-specific Views
+
+struct CalendarToolView: View {
+ let response: Response
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Image(systemName: "calendar")
+ .foregroundColor(.blue)
+ Text("Calendar Event")
+ .font(.headline)
+ .foregroundColor(.blue)
+ Spacer()
+ }
+
+ if let result = response.toolCallResult {
+ Text(result)
+ .font(.body)
+ .foregroundColor(.primary)
+ } else if response.toolCallSuccess == false {
+ Text("Failed to create calendar event")
+ .font(.body)
+ .foregroundColor(.red)
+ } else {
+ HStack {
+ ProgressView()
+ .scaleEffect(0.8)
+ Text("Creating calendar event...")
+ .font(.body)
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+ .padding(12)
+ .background(Color.blue.opacity(0.1))
+ .cornerRadius(8)
+ }
+}
diff --git a/macos/Onit/UI/Components/ToolCallView.swift b/macos/Onit/UI/Prompt/Generated/Tools/ToolCallView.swift
similarity index 100%
rename from macos/Onit/UI/Components/ToolCallView.swift
rename to macos/Onit/UI/Prompt/Generated/Tools/ToolCallView.swift
diff --git a/macos/Onit/UI/Prompt/PromptCore.swift b/macos/Onit/UI/Prompt/PromptCore.swift
index 44b19babe..359a70506 100644
--- a/macos/Onit/UI/Prompt/PromptCore.swift
+++ b/macos/Onit/UI/Prompt/PromptCore.swift
@@ -82,7 +82,7 @@ struct PromptCore: View {
}
.background {
if !isEditing, let windowState = windowState {
- if !showSlashMenu && (windowState.showContextMenu != true) && (windowState.isTyping != true) {
+ if !showSlashMenu && (windowState.showContextMenu != true) && (windowState.isTyping != true) && (windowState.isDiffViewActive != true) {
upListener
downListener
}
@@ -144,7 +144,7 @@ extension PromptCore {
maxHeight: maxHeightLimit,
placeholder: placeholderText,
audioRecorder: audioRecorder,
- isDisabled: showingAlert,
+ isDisabled: showingAlert || windowState.isDiffViewActive,
detectLinks: true
)
.frame(height: min(textHeight, maxHeightLimit))
@@ -329,5 +329,6 @@ private final class NotificationDelegate: OnitPanelStateDelegate {
// These are required to conform to the OnitPanelStateDelegate protocol, but they aren't needed in this implementation.
func panelStateDidChange(state: OnitPanelState) {}
+ func panelFrameDidChange(state: OnitPanelState) {}
func userInputsDidChange(instruction: String, contexts: [Context], input: Input?) {}
}
diff --git a/macos/Onit/UI/QuickEdit/QuickEditManager.swift b/macos/Onit/UI/QuickEdit/QuickEditManager.swift
index fca426af0..a6818b440 100644
--- a/macos/Onit/UI/QuickEdit/QuickEditManager.swift
+++ b/macos/Onit/UI/QuickEdit/QuickEditManager.swift
@@ -88,6 +88,10 @@ class QuickEditManager: ObservableObject, CaretPositionDelegate {
hintWindowController.hideMenu()
}
+ func getWindow() -> NSWindow? {
+ return windowController.window
+ }
+
func activateLastApp() {
guard let element = lastElement, let pid = element.pid() else { return }
diff --git a/macos/Onit/UI/QuickEdit/QuickEditResponseView.swift b/macos/Onit/UI/QuickEdit/QuickEditResponseView.swift
index 95b7f27bd..93b15a031 100644
--- a/macos/Onit/UI/QuickEdit/QuickEditResponseView.swift
+++ b/macos/Onit/UI/QuickEdit/QuickEditResponseView.swift
@@ -52,6 +52,18 @@ struct QuickEditResponseView: View {
return response.text
}
+ private var shouldDisplayLoader: Bool {
+ displayText.isEmpty &&
+ (prompt.currentResponse?.toolCallName?.isEmpty ?? true) &&
+ (prompt.currentResponse?.toolCallArguments?.isEmpty ?? true) &&
+ !(state?.isSearchingWeb[prompt.id] ?? false)
+ }
+
+ private var isDiffViewActive: Bool {
+ state?.responseUsedForDiffView == prompt.currentResponse &&
+ state?.isDiffViewActive == true
+ }
+
private var configuration: LLMStreamConfiguration {
let font = FontConfiguration(size: fontSize, lineHeight: lineHeight)
let color = ColorConfiguration(citationBackgroundColor: .gray600,
@@ -106,7 +118,7 @@ extension QuickEditResponseView {
.frame(maxWidth: .infinity, alignment: .leading)
case .streaming, .done:
- if displayText.isEmpty && !(state?.isSearchingWeb[prompt.id] ?? false) {
+ if shouldDisplayLoader {
HStack {
Spacer()
QLImage("loader_rotated-200")
@@ -174,8 +186,14 @@ extension QuickEditResponseView {
.padding(.vertical, 8)
.id("content")
+ if let response = prompt.currentResponse, response.hasToolCall {
+ ToolCallHandlerView(response: response)
+ .buttonStyle(.plain)
+ .padding(.vertical, 8)
+ }
+
Color.clear
- .frame(height: 1)
+ .frame(height: 4)
.id("bottom")
}
.background(
@@ -254,27 +272,33 @@ extension QuickEditResponseView {
private var generatedToolbar: some View {
HStack(spacing: 8) {
- if let generation = prompt.generation {
- if isEditableElement {
- Button(
- action: {
- insertGeneratedText(generation)
- },
- label: {
- HStack {
- KeyboardShortcutView(shortcut: insertShortcut)
- Text("Insert")
- }
- .padding(.horizontal, 8)
+ if let generation = prompt.generation, isEditableElement {
+ Button(
+ action: {
+ let stripMarkdown = generation != prompt.currentResponse?.diffPreview
+
+ insertGeneratedText(generation, shouldStripMarkdown: stripMarkdown)
+ },
+ label: {
+ HStack {
+ KeyboardShortcutView(shortcut: insertShortcut)
+ Text("Insert")
}
- )
- .buttonStyle(.plain)
- .frame(height: 24)
- .background(.blue400)
- .clipShape(RoundedRectangle(cornerRadius: 5))
- }
+ .padding(.horizontal, 8)
+ }
+ )
+ .buttonStyle(.plain)
+ .frame(height: 24)
+ .background(.blue400)
+ .clipShape(RoundedRectangle(cornerRadius: 5))
+ }
+
+ diffButton
+
+ if let generation = prompt.generation {
+ let stripMarkdown = generation != prompt.currentResponse?.diffPreview
- CopyButton(text: generation, stripMarkdown: true)
+ CopyButton(text: generation, stripMarkdown: stripMarkdown)
}
IconButton(
@@ -287,6 +311,24 @@ extension QuickEditResponseView {
}
}
}
+
+ @ViewBuilder
+ var diffButton: some View {
+ if let response = prompt.currentResponse,
+ let state = state,
+ response.isDiffResponse {
+ IconButton(
+ icon: .lucideDiff,
+ isActive: isDiffViewActive,
+ activeBackground: .gray800,
+ tooltipPrompt: "Diff"
+ ) {
+ if !isDiffViewActive {
+ NotepadWindowController.shared.showWindow(windowState: state, response: response)
+ }
+ }
+ }
+ }
}
// MARK: - Actions
@@ -303,10 +345,10 @@ extension QuickEditResponseView {
// TODO: Implement code action if needed
}
- private func insertGeneratedText(_ text: String) {
+ private func insertGeneratedText(_ text: String, shouldStripMarkdown: Bool = true) {
QuickEditManager.shared.activateLastApp()
- let textToInsert = text.stripMarkdown()
+ let textToInsert = shouldStripMarkdown ? text.stripMarkdown() : text
let source = CGEventSource(stateID: .hidSystemState)
let pasteDown = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true)
let pasteUp = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false)
diff --git a/macos/Onit/UI/QuickEdit/QuickEditWindowController.swift b/macos/Onit/UI/QuickEdit/QuickEditWindowController.swift
index 63e5c994e..bca241d1c 100644
--- a/macos/Onit/UI/QuickEdit/QuickEditWindowController.swift
+++ b/macos/Onit/UI/QuickEdit/QuickEditWindowController.swift
@@ -387,6 +387,10 @@ class QuickEditWindowController: NSObject, NSWindowDelegate {
}
func windowDidResignKey(_ notification: Notification) {
- hide()
+ let state = PanelStateCoordinator.shared.state
+
+ if !state.isDiffViewActive {
+ hide()
+ }
}
}
diff --git a/macos/Onit/UI/Windows/ContextWindowController.swift b/macos/Onit/UI/Windows/ContextWindowController.swift
index 1628635ac..5aceecb49 100644
--- a/macos/Onit/UI/Windows/ContextWindowController.swift
+++ b/macos/Onit/UI/Windows/ContextWindowController.swift
@@ -93,10 +93,6 @@ class ContextWindowController: NSObject, NSWindowDelegate {
window.ignoresMouseEvents = false
window.hidesOnDeactivate = false
- // TODO: KNA - WIP: Display the stars icon in title's left
- window.representedURL = URL(string: "")
- window.standardWindowButton(.documentIconButton)?.image = .stars
-
self.window = window
}
diff --git a/macos/Onit/UI/Windows/NotepadWindowController.swift b/macos/Onit/UI/Windows/NotepadWindowController.swift
new file mode 100644
index 000000000..3f5756415
--- /dev/null
+++ b/macos/Onit/UI/Windows/NotepadWindowController.swift
@@ -0,0 +1,268 @@
+//
+// NotepadWindowController.swift
+// Onit
+//
+// Created by Kévin Naudin on 13/03/2025.
+//
+
+import AppKit
+import Defaults
+import SwiftUI
+
+@MainActor
+class NotepadWindowController: NSObject, NSWindowDelegate {
+
+ // MARK: - Singleton
+
+ static let shared = NotepadWindowController()
+
+ // MARK: - Properties
+
+ private static let minWidth: CGFloat = 320
+ private static let animationDuration: TimeInterval = 0.2
+
+ private var panel: CustomPanel?
+ private var windowState: OnitPanelState?
+ private var isResizing: Bool = false
+ private var originalFrame: NSRect = .zero
+ private var width: CGFloat {
+ get { return Defaults[.notepadWidth] }
+ set { Defaults[.notepadWidth] = Double(newValue) }
+ }
+
+ class CustomPanel: NSPanel {
+ override var canBecomeKey: Bool { true }
+ }
+
+ // MARK: - Functions
+
+ func showWindow(windowState: OnitPanelState, response: Response) {
+ guard response.isDiffResponse,
+ let _ = response.diffArguments,
+ let _ = response.diffResult else {
+ print("NotepadWindowController: Invalid diff response provided")
+ return
+ }
+
+ self.windowState = windowState
+ self.windowState?.addDelegate(self)
+
+ let notepadView = NotepadView(
+ response: response,
+ closeCompletion: closeWindow
+ )
+ .environment(\.windowState, windowState)
+
+ let contentWithResize = ZStack(alignment: .leading) {
+ notepadView
+
+ ResizeHandle(
+ onDrag: { [weak self] deltaX in
+ guard let self = self, let panel = self.panel else { return }
+
+ self.isResizing = true
+
+ if self.originalFrame == .zero {
+ self.originalFrame = panel.frame
+ }
+
+ self.resizePanel(byWidth: deltaX)
+ },
+ onDragEnded: { [weak self] in
+ guard let self = self else { return }
+
+ self.originalFrame = .zero
+ self.isResizing = false
+ }
+ )
+ .padding(.top, NotepadView.toolbarHeight + 1) // Add 1px for divider height
+ .frame(width: 6)
+ .frame(maxHeight: .infinity)
+ }
+
+ let panelContentView = NSHostingView(rootView: contentWithResize)
+ panelContentView.wantsLayer = true
+ panelContentView.layer?.cornerRadius = 14
+ panelContentView.layer?.cornerCurve = .continuous
+ panelContentView.layer?.masksToBounds = true
+ panelContentView.layer?.backgroundColor = .black
+
+ if panel == nil {
+ let newPanel = CustomPanel(
+ contentRect: NSRect(x: 0, y: 0, width: width, height: 600),
+ styleMask: [.fullSizeContentView],
+ backing: .buffered,
+ defer: false
+ )
+
+ newPanel.isOpaque = false
+ newPanel.backgroundColor = NSColor.clear
+ newPanel.level = .floating
+ newPanel.titleVisibility = .hidden
+ newPanel.titlebarAppearsTransparent = true
+ newPanel.isMovableByWindowBackground = true
+ newPanel.delegate = self
+ newPanel.hidesOnDeactivate = false
+
+ newPanel.standardWindowButton(.closeButton)?.isHidden = true
+ newPanel.standardWindowButton(.miniaturizeButton)?.isHidden = true
+ newPanel.standardWindowButton(.zoomButton)?.isHidden = true
+ newPanel.isFloatingPanel = true
+
+ panel = newPanel
+ }
+
+ panel?.contentView = panelContentView
+ panel?.contentView?.setFrameOrigin(NSPoint(x: 0, y: 0))
+
+ if let onitPanel = windowState.panel {
+ positionWindow(for: onitPanel)
+ } else {
+ positionWindowForQuickEdit()
+ }
+
+ windowState.isDiffViewActive = true
+ windowState.responseUsedForDiffView = response
+
+ bringToFront()
+ }
+
+ func bringToFront() {
+ panel?.alphaValue = 1.0
+ panel?.orderFront(nil)
+ panel?.makeKeyAndOrderFront(nil)
+ }
+
+ func closeWindow() {
+ guard let panel = panel else { return }
+
+ self.windowState?.removeDelegate(self)
+
+ NSAnimationContext.runAnimationGroup(
+ { context in
+ context.duration = 0.2
+ context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+ panel.animator().alphaValue = 0.0
+ },
+ completionHandler: { [weak self] in
+ self?.windowState?.isDiffViewActive = false
+ panel.orderOut(nil)
+ panel.alphaValue = 1.0
+ })
+ }
+
+ // MARK: - Private Functions
+
+ private func resizePanel(byWidth deltaWidth: CGFloat) {
+ guard let panel = panel else { return }
+
+ let newWidth = width - deltaWidth
+
+ if newWidth >= Self.minWidth {
+ width = newWidth
+
+ let newFrame = NSRect(
+ x: originalFrame.maxX - newWidth,
+ y: panel.frame.origin.y,
+ width: newWidth,
+ height: panel.frame.height
+ )
+
+ panel.setFrame(newFrame, display: true)
+ }
+ }
+
+ private func positionWindow(for onitPanel: OnitPanel) {
+ guard let panel = self.panel else { return }
+
+ var newFrame = onitPanel.frame
+
+ newFrame.origin.x = onitPanel.frame.origin.x - width + (TetheredButton.width / 2)
+ newFrame.size.width = width
+
+ panel.setFrame(newFrame, display: false, animate: false)
+ }
+
+ private func positionWindowForQuickEdit() {
+ guard let panel = self.panel else { return }
+ guard let window = QuickEditManager.shared.getWindow(),
+ let screen = window.screen else { return }
+
+ let x = screen.visibleFrame.maxX - width
+ let y = screen.visibleFrame.minY
+ let width = width
+ let height = screen.visibleFrame.height
+ let newFrame = NSRect(origin: CGPoint(x: x, y: y),
+ size: CGSize(width: width, height: height))
+
+ panel.setFrame(newFrame, display: false, animate: false)
+ }
+
+ private func repositionForOnitPanel(_ onitPanel: OnitPanel) {
+ guard let panel = self.panel else { return }
+
+ var newFrame = onitPanel.frame
+ newFrame.origin.x = onitPanel.frame.origin.x - width + (TetheredButton.width / 2)
+ newFrame.size.width = width
+
+ panel.setFrame(newFrame, display: true)
+ }
+
+ func tempHidePanel(state: OnitPanelState) {
+ guard let panel = panel else { return }
+
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = Self.animationDuration
+ panel.animator().alphaValue = 0.0
+ }
+ }
+
+ func tempShowPanel(state: OnitPanelState) {
+ guard let panel = panel else { return }
+
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = Self.animationDuration
+ panel.animator().alphaValue = 1.0
+ }
+ }
+
+ // MARK: - NSWindowDelegate
+
+ func windowWillClose(_ notification: Notification) {
+
+ }
+
+}
+
+// MARK: - OnitPanelStateDelegate
+
+extension NotepadWindowController: OnitPanelStateDelegate {
+
+ func panelBecomeKey(state: OnitPanelState) {}
+ func panelResignKey(state: OnitPanelState) {}
+ func panelStateDidChange(state: OnitPanelState) {
+ if !state.panelOpened {
+ closeWindow()
+ } else {
+ if state.hidden && !state.panelWasHidden {
+ tempHidePanel(state: state)
+ } else if !state.hidden && state.panelWasHidden {
+ tempShowPanel(state: state)
+ }
+ }
+ }
+
+ func panelFrameDidChange(state: OnitPanelState) {
+ guard state.panelOpened, let onitPanel = state.panel else { return }
+
+ if state.isWindowDragging {
+ panel?.alphaValue = 0.3
+ } else {
+ panel?.alphaValue = 1.0
+ }
+
+ repositionForOnitPanel(onitPanel)
+ }
+
+ func userInputsDidChange(instruction: String, contexts: [Context], input: Input?) { }
+}