iCloud Backup, better chatview exp. bugfixes++
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
]),
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user