diff --git a/macos/Onit/Data/Model/Model+History.swift b/macos/Onit/Data/Model/Model+History.swift index 1d4d5cce..4e289d15 100644 --- a/macos/Onit/Data/Model/Model+History.swift +++ b/macos/Onit/Data/Model/Model+History.swift @@ -5,6 +5,8 @@ // Created by Loyd Kim on 3/31/25. // +import Foundation + extension OnitModel { func setChat(chat: Chat, index: Int) { currentChat = chat @@ -15,15 +17,14 @@ extension OnitModel { func deleteChat(chat: Chat) { do { - deleteChatFailed = false + chatDeletionFailed = false container.mainContext.delete(chat) try container.mainContext.save() } catch { - deleteChatFailed = true - - #if DEBUG - print("Chat delete error: \(error)") - #endif + chatDeletionFailed = true + chatDeletionTimePassed = 0 } + + chatQueuedForDeletion = nil } } diff --git a/macos/Onit/Data/Model/OnitModel.swift b/macos/Onit/Data/Model/OnitModel.swift index 07ac844c..078bf6f1 100644 --- a/macos/Onit/Data/Model/OnitModel.swift +++ b/macos/Onit/Data/Model/OnitModel.swift @@ -36,7 +36,12 @@ import SwiftUI var currentChat: Chat? var currentPrompts: [Prompt]? - var deleteChatFailed: Bool = false + /// Chat deletion state START + var chatQueuedForDeletion: Chat? = nil + var chatDeletionTimePassed: Int = 0 + var chatDeletionFailed: Bool = false + var historyDeleteToastDismissed: Bool = false + /// Chat deletion state END // User inputs that have not yet been submitted var pendingInstruction = "" { diff --git a/macos/Onit/General/Styles/AnimationStyle.swift b/macos/Onit/General/Styles/AnimationStyle.swift new file mode 100644 index 00000000..53c4dc5b --- /dev/null +++ b/macos/Onit/General/Styles/AnimationStyle.swift @@ -0,0 +1,8 @@ +// +// AnimationStyle.swift +// Onit +// +// Created by Loyd Kim on 4/4/25. +// + +let animationDuration: Double = 0.15 diff --git a/macos/Onit/General/Styles/HoverableButtonStyle.swift b/macos/Onit/General/Styles/HoverableButtonStyle.swift index 6019e62c..fe25c09f 100644 --- a/macos/Onit/General/Styles/HoverableButtonStyle.swift +++ b/macos/Onit/General/Styles/HoverableButtonStyle.swift @@ -60,7 +60,7 @@ struct HoverableButtonStyle: ButtonStyle { RoundedRectangle(cornerRadius: 6) .fill(.gray800) .opacity(hovering ? 1 : 0) - .animation(.easeInOut(duration: 0.15), value: hovering) + .animation(.easeInOut(duration: animationDuration), value: hovering) } } diff --git a/macos/Onit/UI/History/HistoryDeleteToast.swift b/macos/Onit/UI/History/HistoryDeleteToast.swift new file mode 100644 index 00000000..d384802f --- /dev/null +++ b/macos/Onit/UI/History/HistoryDeleteToast.swift @@ -0,0 +1,145 @@ +// +// HistoryDeleteToast.swift +// Onit +// +// Created by Loyd Kim on 4/4/25. +// + +import SwiftUI +import SwiftData + +struct HistoryDeleteToast: View { + @Environment(\.model) var model + + var text: String + let chat: Chat? + + @State private var isHoveringUndo: Bool = false + @State private var isHoveringDismiss: Bool = false + @State private var deletionTimer: Timer? = nil + // + @State private var fadeOutTimer: Timer? = nil + @State private var fadeOutOpacity: CGFloat = 1 + @State private var topOffset: CGFloat = -8 + @State private var fadeOutUndo: Bool = false + + let hoverOpacity: Double = 0.5 + + var body: some View { + VStack(spacing: 8) { + HStack { + Text(text) + undoButton + dismissButton + } + .frame(alignment: .center) + .foregroundColor(.white) + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background(.gray700) + .cornerRadius(8) + .shadow(color: .black.opacity(0.4), radius: 11, x: 0, y: 3) + .onChange(of: model.chatDeletionTimePassed) { + if model.chatDeletionTimePassed >= 5 { + fadeOutOpacity = 1 + createFadeOutTimer() + removeDeletionTimer() + } + } + .onChange(of: fadeOutOpacity) { + if fadeOutOpacity <= 0 { + removeFadeOutTimer() + + if fadeOutUndo { model.chatQueuedForDeletion = nil } + else if let chat = chat { model.deleteChat(chat: chat) } + else { model.chatDeletionFailed = false } + } + } + .onAppear { + model.chatDeletionTimePassed = 0 + fadeOutOpacity = 1 + createDeletionTimer() + withAnimation(.easeOut(duration: animationDuration)) { topOffset = 0 } + } + .onDisappear { + removeDeletionTimer() + model.chatDeletionTimePassed = 0 + } + .opacity(model.historyDeleteToastDismissed ? 0 : fadeOutOpacity) + .offset(y: topOffset) + .animation(.easeInOut(duration: animationDuration), value: topOffset) + .animation(.easeInOut(duration: animationDuration), value: model.historyDeleteToastDismissed) + .allowsHitTesting(!model.historyDeleteToastDismissed) + } +} + +/// Child Components +extension HistoryDeleteToast { + @ViewBuilder + var undoButton: some View { + if !model.chatDeletionFailed { + Button { + fadeOutUndo = true + createFadeOutTimer() + } label: { + Text("Undo").underline().font(.system(size: 14)) + } + .buttonStyle(PlainButtonStyle()) + .padding(.leading, 12) + .padding(.trailing, 4) + .onHover{ hovering in isHoveringUndo = hovering } + .opacity(isHoveringUndo ? hoverOpacity : 1) + .animation(.easeIn(duration: animationDuration), value: isHoveringUndo) + } + } + + var dismissButton: some View { + Button { + withAnimation(.easeIn(duration: animationDuration)) { + model.historyDeleteToastDismissed = true + } + } label: { + Image(.smallCross) + .renderingMode(.template) + .foregroundStyle(.white) + .frame(width: 8, height: 8) + } + .buttonStyle(PlainButtonStyle()) + .onHover{ hovering in isHoveringDismiss = hovering } + .opacity(isHoveringDismiss ? hoverOpacity : 1) + .animation(.easeIn(duration: animationDuration), value: isHoveringDismiss) + } +} + + +/// Helper Functions +extension HistoryDeleteToast { + func createDeletionTimer() { + deletionTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in + Task { @MainActor in model.chatDeletionTimePassed += 1 } + } + if let deletionTimer = deletionTimer { + RunLoop.current.add(deletionTimer, forMode: .common) + } + } + + func removeDeletionTimer() { + deletionTimer?.invalidate() + deletionTimer = nil + } + + func createFadeOutTimer() { + fadeOutTimer = Timer.scheduledTimer(withTimeInterval: 1/60, repeats: true) { _ in + Task { @MainActor in fadeOutOpacity -= 0.1 } + } + if let fadeOutTimer = fadeOutTimer { + RunLoop.current.add(fadeOutTimer, forMode: .common) + } + } + + func removeFadeOutTimer() { + fadeOutTimer?.invalidate() + fadeOutTimer = nil + } +} diff --git a/macos/Onit/UI/History/HistoryRowView.swift b/macos/Onit/UI/History/HistoryRowView.swift index 1f328f2b..adcebe6c 100644 --- a/macos/Onit/UI/History/HistoryRowView.swift +++ b/macos/Onit/UI/History/HistoryRowView.swift @@ -10,7 +10,8 @@ import SwiftUI struct HistoryRowView: View { @Environment(\.model) var model - @State private var showDelete: Bool = false + @State private var showDeleteButton: Bool = false + @State private var deleteButtonHovered: Bool = false var chat: Chat var index: Int @@ -20,14 +21,16 @@ struct HistoryRowView: View { model.setChat(chat: chat, index: index) } label: { HStack { - Text(getPromptText()) + Text(HistoryRowView.getPromptText(chat: chat)) .appFont(.medium16) .foregroundStyle(.FG) + .lineLimit(1) + .truncationMode(.tail) Spacer() Group { - if showDelete { deleteButton } + if showDeleteButton { deleteButton } else { chatResponseCount } } } @@ -35,7 +38,7 @@ struct HistoryRowView: View { } .frame(height: 36) .buttonStyle(HoverableButtonStyle(background: true)) - .onHover { hovering in showDelete = hovering } + .onHover { hovering in showDeleteButton = hovering } } var chatResponseCount: some View { @@ -47,25 +50,28 @@ struct HistoryRowView: View { } var deleteButton: some View { - HStack(alignment: .center) { - Text(model.deleteChatFailed ? "Delete failed" : "") - .foregroundColor(Color.red) + Button { + if let pendingDeletedHistoryItem = model.chatQueuedForDeletion { + model.deleteChat(chat: pendingDeletedHistoryItem) + } + + if !model.chatDeletionFailed { model.chatQueuedForDeletion = chat } + model.historyDeleteToastDismissed = false + model.chatDeletionTimePassed = 0 + } label: { Image(systemName: "trash") + .resizable() .frame(width: 14, height: 14) - .padding(.trailing, 10) - } - .frame(height: 34) - .contentShape(Rectangle()) - .onTapGesture { - // We want the padding around the button to also be a tap target - if !model.deleteChatFailed { - model.deleteChat(chat: chat) - } + .padding(.horizontal, 10) + .foregroundColor(deleteButtonHovered ? .gray100 : .gray200) } + .buttonStyle(PlainButtonStyle()) + .frame(maxHeight: .infinity) + .onHover { hovering in deleteButtonHovered = hovering } } - private func getPromptText() -> String { + static func getPromptText(chat: Chat) -> String { guard let firstPrompt = chat.prompts.first, !firstPrompt.responses.isEmpty, firstPrompt.generationIndex < firstPrompt.responses.count else { diff --git a/macos/Onit/UI/History/HistoryView.swift b/macos/Onit/UI/History/HistoryView.swift index e1d600cb..82568990 100644 --- a/macos/Onit/UI/History/HistoryView.swift +++ b/macos/Onit/UI/History/HistoryView.swift @@ -9,15 +9,22 @@ import SwiftData import SwiftUI struct HistoryView: View { + @Environment(\.model) var model + @Query(sort: \Chat.timestamp, order: .reverse) private var chats: [Chat] @State private var searchQuery: String = "" + @State private var dismissedDeleteNotifications: Set = [] var filteredChats: [Chat] { + let chatsNotQueuedForDeletion = chats.filter { chat in + chat.id != model.chatQueuedForDeletion?.id + } + if searchQuery.isEmpty { - return chats + return chatsNotQueuedForDeletion } else { - return chats.filter { + return chatsNotQueuedForDeletion.filter { $0.fullText.localizedCaseInsensitiveContains(searchQuery) } } @@ -43,21 +50,25 @@ struct HistoryView: View { var sortedChats: [(key: String, value: [Chat])] { groupedChats.sorted(by: { $0.key > $1.key }) } - + var body: some View { - VStack(spacing: 0) { - HistoryTitle() - HistorySearchView(text: $searchQuery) + ZStack(alignment: .topLeading) { + VStack(spacing: 0) { + HistoryTitle() + HistorySearchView(text: $searchQuery) + + if sortedChats.isEmpty { emptyText } + else { historyRows } + } + .frame(width: 350) + .background(.BG) - if sortedChats.isEmpty { emptyText } - else { historyRows } + historyDeleteToast } - .frame(width: 350) - .background(.BG) } var emptyText: some View { - Text("No prompts found") + Text("No chats") .foregroundStyle(.gray200) .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 16) @@ -86,9 +97,37 @@ struct HistoryView: View { .padding(.bottom, 10) } } + + @ViewBuilder + var historyDeleteToast: some View { + if let chat = model.chatQueuedForDeletion { + ZStack { + HistoryDeleteToast( + text: "Item deleted", + chat: chat + ) + }.attachHistoryDeleteToastStyles() + } else if model.chatDeletionFailed { + ZStack { + HistoryDeleteToast( + text: "Failed to delete. Please retry", + chat: nil + ) + }.attachHistoryDeleteToastStyles() + } + } } +extension View { + func attachHistoryDeleteToastStyles() -> some View { + self + .frame(maxWidth: .infinity) + .padding(.top, 12) + .padding(.horizontal, 12) + } +} + #Preview { HistoryView() }