iCloud Backup, better chatview exp. bugfixes++

This commit is contained in:
2026-02-27 14:05:11 +01:00
parent 3997f3feee
commit e9d0ad3c66
11 changed files with 634 additions and 30 deletions

View File

@@ -87,7 +87,7 @@ struct FooterView: View {
// Shortcuts hint
#if os(macOS)
Text("⌘N New • ⌘M Model • ⌘S Save")
Text("⌘N New • ⌘M Model • ⌘S Save")
.font(.caption2)
.foregroundColor(.oaiSecondary)
#endif

View File

@@ -33,6 +33,8 @@ struct MessageRow: View {
let viewModel: ChatViewModel?
private let settings = SettingsService.shared
@State private var isExpanded = false
#if os(macOS)
@State private var isHovering = false
@State private var showCopied = false
@@ -82,8 +84,8 @@ struct MessageRow: View {
.help("Star this message to always include it in context")
}
// Copy button (assistant messages only, visible on hover)
if message.role == .assistant && isHovering && !message.content.isEmpty {
// Copy button (user + assistant messages, visible on hover)
if (message.role == .assistant || message.role == .user) && isHovering && !message.content.isEmpty {
Button(action: copyContent) {
HStack(spacing: 3) {
Image(systemName: showCopied ? "checkmark" : "doc.on.doc")
@@ -188,27 +190,126 @@ struct MessageRow: View {
@ViewBuilder
private var compactSystemMessage: some View {
HStack(spacing: 8) {
Image(systemName: "wrench.and.screwdriver")
.font(.system(size: 11))
.foregroundColor(.secondary)
let expandable = message.toolCalls != nil
VStack(alignment: .leading, spacing: 0) {
Button(action: {
if expandable {
withAnimation(.easeInOut(duration: 0.18)) { isExpanded.toggle() }
}
}) {
HStack(spacing: 8) {
Image(systemName: "wrench.and.screwdriver")
.font(.system(size: 11))
.foregroundColor(.secondary)
Text(message.content)
.font(.system(size: 11))
.foregroundColor(.secondary)
Text(message.content)
.font(.system(size: 11))
.foregroundColor(.secondary)
Spacer()
Spacer()
Text(message.timestamp, style: .time)
.font(.system(size: 10))
.foregroundColor(.secondary.opacity(0.7))
if expandable {
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 9, weight: .medium))
.foregroundColor(.secondary.opacity(0.5))
}
Text(message.timestamp, style: .time)
.font(.system(size: 10))
.foregroundColor(.secondary.opacity(0.7))
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if isExpanded, let calls = message.toolCalls {
Divider()
.padding(.horizontal, 8)
toolCallsDetailView(calls)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.secondary.opacity(0.08))
.cornerRadius(6)
}
@ViewBuilder
private func toolCallsDetailView(_ calls: [ToolCallDetail]) -> some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(calls.indices, id: \.self) { i in
let call = calls[i]
VStack(alignment: .leading, spacing: 6) {
// Tool name + status
HStack(spacing: 6) {
Image(systemName: "function")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.secondary)
Text(call.name)
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.secondary)
Spacer()
if call.result == nil {
ProgressView()
.scaleEffect(0.5)
.frame(width: 12, height: 12)
} else {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 10))
.foregroundColor(.green.opacity(0.8))
}
}
// Input
if !call.input.isEmpty && call.input != "{}" {
toolDetailSection(label: "Input", text: prettyJSON(call.input), maxHeight: 100)
}
// Result
if let result = call.result {
toolDetailSection(label: "Result", text: prettyJSON(result), maxHeight: 180)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
if i < calls.count - 1 {
Divider().padding(.horizontal, 12)
}
}
}
}
@ViewBuilder
private func toolDetailSection(label: String, text: String, maxHeight: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label.uppercased())
.font(.system(size: 9, weight: .semibold))
.foregroundColor(.secondary.opacity(0.6))
ScrollView([.vertical, .horizontal]) {
Text(text)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxHeight: maxHeight)
.background(Color.secondary.opacity(0.06))
.cornerRadius(4)
}
}
private func prettyJSON(_ raw: String) -> String {
guard let data = raw.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data),
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]),
let str = String(data: pretty, encoding: .utf8) else {
return raw
}
return str
}
// MARK: - Message Content
@ViewBuilder

View File

@@ -112,7 +112,8 @@ private let helpCategories: [CommandCategory] = [
command: "/save <name>",
brief: "Save current conversation",
detail: "Saves all messages in the current session under the given name. You can load it later to continue the conversation.",
examples: ["/save my-project-chat", "/save debug session"]
examples: ["/save my-project-chat", "/save debug session"],
shortcut: "⌘S"
),
CommandDetail(
command: "/load",
@@ -191,7 +192,8 @@ private let helpCategories: [CommandCategory] = [
command: "/stats",
brief: "Show session statistics",
detail: "Displays statistics for the current session: message count, total tokens used, estimated cost, and session duration.",
examples: ["/stats"]
examples: ["/stats"],
shortcut: "⌘⇧S"
),
]),
]

View File

@@ -65,6 +65,14 @@ struct SettingsView: View {
@State private var isTestingPaperless = false
@State private var paperlessTestResult: String?
// Backup state
private let backupService = BackupService.shared
@State private var isExporting = false
@State private var isImporting = false
@State private var backupMessage: String?
@State private var backupMessageIsError = false
@State private var showRestoreFilePicker = false
// Email handler state
@State private var showEmailLog = false
@State private var showEmailModelSelector = false
@@ -135,6 +143,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
tabButton(4, icon: "arrow.triangle.2.circlepath", label: "Sync")
tabButton(5, icon: "envelope", label: "Email")
tabButton(8, icon: "doc.text", label: "Paperless")
tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
}
.padding(.horizontal, 16)
.padding(.bottom, 12)
@@ -162,6 +171,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
agentSkillsTab
case 8:
paperlessTab
case 9:
backupTab
default:
generalTab
}
@@ -171,10 +182,24 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
}
}
.frame(minWidth: 740, idealWidth: 820, minHeight: 620, idealHeight: 760)
.frame(minWidth: 860, idealWidth: 940, minHeight: 620, idealHeight: 760)
.sheet(isPresented: $showEmailLog) {
EmailLogView()
}
.fileImporter(
isPresented: $showRestoreFilePicker,
allowedContentTypes: [.json],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
guard let url = urls.first else { return }
Task { await performRestore(from: url) }
case .failure(let error):
backupMessage = "Could not open file: \(error.localizedDescription)"
backupMessageIsError = true
}
}
}
// MARK: - General Tab
@@ -1823,6 +1848,184 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
}
}
// MARK: - Backup Tab
@ViewBuilder
private var backupTab: some View {
VStack(alignment: .leading, spacing: 20) {
// Warning notice
VStack(alignment: .leading, spacing: 6) {
sectionHeader("iCloud Drive Backup")
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.font(.system(size: 14))
Text("API keys and credentials are **not** included in the backup. You will need to re-enter them after restoring on a new machine.")
.font(.system(size: 13))
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 4)
.padding(.top, 2)
}
// Status
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Status")
formSection {
row("iCloud Drive") {
HStack(spacing: 6) {
Circle()
.fill(backupService.iCloudAvailable ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text(backupService.iCloudAvailable ? "Available" : "Not available — using Downloads")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
rowDivider()
row("Last Backup") {
if let date = backupService.lastBackupDate {
Text(formatBackupDate(date))
.font(.system(size: 13))
.foregroundStyle(.secondary)
} else {
Text("Never")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
}
}
// Actions
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Actions")
formSection {
HStack(spacing: 12) {
Button(action: { Task { await performBackup() } }) {
HStack(spacing: 6) {
if isExporting {
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
} else {
Image(systemName: "icloud.and.arrow.up")
}
Text("Back Up Now")
}
}
.disabled(isExporting || isImporting)
Button(action: { showRestoreFilePicker = true }) {
HStack(spacing: 6) {
if isImporting {
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
} else {
Image(systemName: "icloud.and.arrow.down")
}
Text("Restore from File…")
}
}
.disabled(isExporting || isImporting)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
if let msg = backupMessage {
rowDivider()
HStack(spacing: 6) {
Image(systemName: backupMessageIsError ? "xmark.circle.fill" : "checkmark.circle.fill")
.foregroundStyle(backupMessageIsError ? .red : .green)
Text(msg)
.font(.system(size: 13))
.foregroundStyle(backupMessageIsError ? .red : .primary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
}
// Backup location
if let url = backupService.lastBackupURL {
VStack(alignment: .leading, spacing: 4) {
Text("Backup location:")
.font(.system(size: 13, weight: .medium))
Text(url.path.replacingOccurrences(
of: FileManager.default.homeDirectoryForCurrentUser.path,
with: "~"))
.font(.system(size: 12))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
.padding(.horizontal, 4)
}
}
.onAppear {
backupService.checkForExistingBackup()
}
}
private func performBackup() async {
await MainActor.run {
isExporting = true
backupMessage = nil
}
do {
let url = try await backupService.exportSettings()
let shortPath = url.path.replacingOccurrences(
of: FileManager.default.homeDirectoryForCurrentUser.path,
with: "~")
await MainActor.run {
backupMessage = "Backup saved to \(shortPath)"
backupMessageIsError = false
isExporting = false
}
} catch {
await MainActor.run {
backupMessage = error.localizedDescription
backupMessageIsError = true
isExporting = false
}
}
}
private func performRestore(from url: URL) async {
await MainActor.run {
isImporting = true
backupMessage = nil
}
do {
_ = url.startAccessingSecurityScopedResource()
defer { url.stopAccessingSecurityScopedResource() }
try await backupService.importSettings(from: url)
await MainActor.run {
backupMessage = "Settings restored. Re-enter your API keys to resume using oAI."
backupMessageIsError = false
isImporting = false
}
} catch {
await MainActor.run {
backupMessage = error.localizedDescription
backupMessageIsError = true
isImporting = false
}
}
}
private func formatBackupDate(_ date: Date) -> String {
let cal = Calendar.current
if cal.isDateInToday(date) {
let tf = DateFormatter()
tf.dateFormat = "HH:mm"
return "Today \(tf.string(from: date))"
}
let df = DateFormatter()
df.dateFormat = "dd.MM.yyyy HH:mm"
return df.string(from: date)
}
// MARK: - Tab Navigation
private func tabButton(_ tag: Int, icon: String, label: String) -> some View {
@@ -1856,6 +2059,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
case 6: return "Shortcuts"
case 7: return "Skills"
case 8: return "Paperless"
case 9: return "Backup"
default: return "Settings"
}
}