Let File > Export as HTML/PDF/Markdown choose name and location

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.
This commit is contained in:
2026-07-29 08:49:36 +02:00
parent 53736d4c42
commit 6fb49083d8
2 changed files with 57 additions and 3 deletions
+54
View File
@@ -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)
+3 -3
View File
@@ -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)
}