From 6fb49083d84ac41821befceec6becaed311a1696 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Wed, 29 Jul 2026 08:49:36 +0200 Subject: [PATCH] Let File > Export as HTML/PDF/Markdown choose name and location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously always silently wrote to Downloads with a fixed name — no way to pick where it goes or rename it. Added exportConversationWithSavePanel(format:defaultFilename:), which shows a native NSSavePanel (defaulting to Downloads + the same filename as before) before writing. Wired into all three File menu export buttons. The /export slash command and the conversation list's per-row Export submenu are unchanged (still write straight to Downloads) — the slash command already takes an explicit filename argument inline, and the row export is meant as a quick one-click action rather than a save-as workflow. --- oAI/ViewModels/ChatViewModel.swift | 54 ++++++++++++++++++++++++++++++ oAI/oAIApp.swift | 6 ++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 2aa6d84..c26dc90 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -24,6 +24,7 @@ import Foundation import os import SwiftUI +import UniformTypeIdentifiers @Observable @MainActor @@ -1815,6 +1816,59 @@ Don't narrate future actions ("Let me...") - just use the tools. } } + /// Same export formats as `exportConversation(format:filename:)`, but lets the user pick + /// the name and location via a native save panel instead of always writing to Downloads. + #if os(macOS) + func exportConversationWithSavePanel(format: String, defaultFilename: String) { + let chatMessages = messages.filter { $0.role != .system } + guard !chatMessages.isEmpty else { + showSystemMessage("Nothing to export — no messages") + return + } + + let panel = NSSavePanel() + panel.nameFieldStringValue = defaultFilename + panel.canCreateDirectories = true + panel.allowedContentTypes = { + switch format { + case "html": return [.html] + case "pdf": return [.pdf] + default: return [UTType(filenameExtension: "md") ?? .plainText] + } + }() + if let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first { + panel.directoryURL = downloads + } + + guard panel.runModal() == .OK, let url = panel.url else { return } + + if format == "pdf" { + let name = currentConversationName ?? "conversation" + Task { @MainActor in + do { + let data = try await ConversationExportService.pdfData(name: name, messages: chatMessages) + try data.write(to: url, options: .atomic) + showSystemMessage("Exported to \(url.path)") + } catch { + showSystemMessage("Export failed: \(error.localizedDescription)") + } + } + return + } + + let content = format == "html" + ? ConversationExportService.html(name: currentConversationName ?? "conversation", messages: chatMessages) + : ConversationExportService.markdown(messages: chatMessages) + + do { + try content.write(to: url, atomically: true, encoding: .utf8) + showSystemMessage("Exported to \(url.path)") + } catch { + showSystemMessage("Export failed: \(error.localizedDescription)") + } + } + #endif + // MARK: - Auto-Save & Background Summarization /// Summarize the current conversation in the background (hidden from user) diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift index 79c7035..ac24d0e 100644 --- a/oAI/oAIApp.swift +++ b/oAI/oAIApp.swift @@ -121,21 +121,21 @@ struct oAIApp: App { Button("Export as Markdown…") { let name = chatViewModel.currentConversationName ?? "conversation" let safe = name.components(separatedBy: .whitespacesAndNewlines).joined(separator: "-") - chatViewModel.exportConversation(format: "md", filename: "\(safe).md") + chatViewModel.exportConversationWithSavePanel(format: "md", defaultFilename: "\(safe).md") } .disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty) Button("Export as HTML…") { let name = chatViewModel.currentConversationName ?? "conversation" let safe = name.components(separatedBy: .whitespacesAndNewlines).joined(separator: "-") - chatViewModel.exportConversation(format: "html", filename: "\(safe).html") + chatViewModel.exportConversationWithSavePanel(format: "html", defaultFilename: "\(safe).html") } .disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty) Button("Export as PDF…") { let name = chatViewModel.currentConversationName ?? "conversation" let safe = name.components(separatedBy: .whitespacesAndNewlines).joined(separator: "-") - chatViewModel.exportConversation(format: "pdf", filename: "\(safe).pdf") + chatViewModel.exportConversationWithSavePanel(format: "pdf", defaultFilename: "\(safe).pdf") } .disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty) }