diff --git a/mac/FreeChat.xcodeproj/xcshareddata/xcschemes/FreeChat.xcscheme b/mac/FreeChat.xcodeproj/xcshareddata/xcschemes/FreeChat.xcscheme index a28a1e2..7df5072 100644 --- a/mac/FreeChat.xcodeproj/xcshareddata/xcschemes/FreeChat.xcscheme +++ b/mac/FreeChat.xcodeproj/xcshareddata/xcschemes/FreeChat.xcscheme @@ -30,7 +30,7 @@ shouldAutocreateTestPlan = "YES"> - + + + + + + + + + + + + + + + diff --git a/mac/FreeChat/Models/Conversation+Extensions.swift b/mac/FreeChat/Models/Conversation+Extensions.swift index 82cfb9b..1b7e8c3 100644 --- a/mac/FreeChat/Models/Conversation+Extensions.swift +++ b/mac/FreeChat/Models/Conversation+Extensions.swift @@ -8,16 +8,31 @@ import Foundation import CoreData +//extension Conversation: Hashable { extension Conversation { + public var id: UUID { + if uniqueId == nil { + uniqueId = UUID() + } + return uniqueId! + } + + + static func create(ctx: NSManagedObjectContext) throws -> Self { - let record = self.init(context: ctx) - record.createdAt = Date() - record.lastMessageAt = record.createdAt - - try ctx.save() - return record - } + let record = self.init(context: ctx) + record.createdAt = Date() + record.lastMessageAt = record.createdAt + record.uniqueId = UUID() // Set the uniqueId here + try ctx.save() + return record + } + func moveToFolder(_ folder: Folder?) { + self.folder = folder + try? self.managedObjectContext?.save() + } + var orderedMessages: [Message] { let set = messages as? Set ?? [] return set.sorted { @@ -60,4 +75,17 @@ extension Conversation { self.setValue(Date(), forKey: "updatedAt") } } + + /* + public func hash(into hasher: inout Hasher) { + hasher.combine(objectID) + }*/ + + public static func == (lhs: Conversation, rhs: Conversation) -> Bool { + return lhs.objectID == rhs.objectID + } + + + } + diff --git a/mac/FreeChat/Models/ConversationHierarchy.swift b/mac/FreeChat/Models/ConversationHierarchy.swift new file mode 100644 index 0000000..cf379d5 --- /dev/null +++ b/mac/FreeChat/Models/ConversationHierarchy.swift @@ -0,0 +1,93 @@ +import Foundation +import CoreData + +class ConversationHierarchy { + private let viewContext: NSManagedObjectContext + + @Published var folderHierarchy: [FolderNode] = [] + @Published var rootConversations: [Conversation] = [] + + + init(viewContext: NSManagedObjectContext) { + self.viewContext = viewContext + refreshHierarchy() + } + + func refreshHierarchy() { + (folderHierarchy, rootConversations) = getHierarchy() + } + + func getHierarchy() -> ([FolderNode], [Conversation]) { + do { + let folderFetchRequest: NSFetchRequest = Folder.fetchRequest() + folderFetchRequest.predicate = NSPredicate(format: "parent == nil") + let rootFolders = try viewContext.fetch(folderFetchRequest) + + let folderNodes = rootFolders + .map { createFolderNode(from: $0) } + .sorted { $0.folder.name?.lowercased() ?? "" < $1.folder.name?.lowercased() ?? "" } + + let conversationFetchRequest: NSFetchRequest = Conversation.fetchRequest() + conversationFetchRequest.predicate = NSPredicate(format: "folder == nil") + let rootConversations = try viewContext.fetch(conversationFetchRequest) + .sorted { $0.titleWithDefault.lowercased() < $1.titleWithDefault.lowercased() } + + // Combine and sort folders and root conversations + let combinedItems = (folderNodes as [Any] + rootConversations as [Any]).sorted { + let title1 = ($0 as? FolderNode)?.folder.name?.lowercased() ?? ($0 as? Conversation)?.titleWithDefault.lowercased() ?? "" + let title2 = ($1 as? FolderNode)?.folder.name?.lowercased() ?? ($1 as? Conversation)?.titleWithDefault.lowercased() ?? "" + return title1 < title2 + } + + // Separate sorted items back into folders and conversations + let sortedFolderNodes = combinedItems.compactMap { $0 as? FolderNode } + let sortedRootConversations = combinedItems.compactMap { $0 as? Conversation } + + return (sortedFolderNodes, sortedRootConversations) + } catch { + print("Failed to fetch root items: \(error)") + return ([], []) + } + } + + private func createFolderNode(from folder: Folder) -> FolderNode { + let subfolders = folder.subfolders.map { createFolderNode(from: $0) } + let conversations = fetchConversations(for: folder) + + // Combine and sort subfolders and conversations + let combinedItems = (subfolders as [Any] + conversations as [Any]).sorted { + let title1 = ($0 as? FolderNode)?.folder.name?.lowercased() ?? ($0 as? Conversation)?.titleWithDefault.lowercased() ?? "" + let title2 = ($1 as? FolderNode)?.folder.name?.lowercased() ?? ($1 as? Conversation)?.titleWithDefault.lowercased() ?? "" + return title1 < title2 + } + + // Separate sorted items back into subfolders and conversations + let sortedSubfolders = combinedItems.compactMap { $0 as? FolderNode } + let sortedConversations = combinedItems.compactMap { $0 as? Conversation } + + return FolderNode(folder: folder, subfolders: sortedSubfolders, conversations: sortedConversations, isOpen: folder.open) + } + + private func fetchConversations(for folder: Folder) -> [Conversation] { + let fetchRequest: NSFetchRequest = Conversation.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "folder == %@", folder) + + do { + return try viewContext.fetch(fetchRequest) + } catch { + print("Failed to fetch conversations for folder \(folder.name ?? ""): \(error)") + return [] + } + } + + private func sortFolderNodesAlphabetically(_ nodes: [FolderNode]) -> [FolderNode] { + return nodes.sorted { $0.folder.name?.lowercased() ?? "" < $1.folder.name?.lowercased() ?? "" }.map { node in + var sortedNode = node + sortedNode.subfolders = sortFolderNodesAlphabetically(node.subfolders) + sortedNode.conversations = node.conversations.sorted { $0.titleWithDefault.lowercased() < $1.titleWithDefault.lowercased() } + return sortedNode + } + } +} + + diff --git a/mac/FreeChat/Models/Folder+Extensions.swift b/mac/FreeChat/Models/Folder+Extensions.swift new file mode 100644 index 0000000..905bc9a --- /dev/null +++ b/mac/FreeChat/Models/Folder+Extensions.swift @@ -0,0 +1,57 @@ +// +// Folder+Extensions.swift +// FreeChat +// +// Created by Sebastian Gray on 5/7/2024. +// + +import Foundation +import CoreData + + +extension Folder { + + static func create(ctx: NSManagedObjectContext, name: String, parent: Folder? = nil) throws -> Self { + let folder = self.init(context: ctx) + folder.name = name + if let parent = parent { + parent.addSubfolder(folder) // Assuming you have a 'children' relationship + } + try ctx.save() + return folder + } + + func addConversation(_ conversation: Conversation) { + conversation.moveToFolder(self) + } + + var subfolders: [Folder] { + let childFolders = self.child as? Set ?? [] + + return Array(childFolders).sorted { $0.name ?? "" < $1.name ?? "" } + } + + func addSubfolder(_ subfolder: Folder) { + addToChild(subfolder) + } + + func setSysPrompt(_ prompt: String?) { + self.sysPrompt = prompt + } + + func rename(to newName: String) { + if !newName.isEmpty && newName != self.name { + self.name = newName + try? self.managedObjectContext?.save() + } + } + + public override func willSave() { + super.willSave() + + //if !isDeleted, changedValues()["updatedAt"] == nil { + // self.setValue(Date(), forKey: "updatedAt") + //} + } +} + diff --git a/mac/FreeChat/Models/FolderNode.swift b/mac/FreeChat/Models/FolderNode.swift new file mode 100644 index 0000000..8bc80ca --- /dev/null +++ b/mac/FreeChat/Models/FolderNode.swift @@ -0,0 +1,42 @@ +// +// FolderNode.swift +// FreeChat +// +// Created by Sebastian Gray on 10/7/2024. +// + +import Foundation +import CoreData + +public struct FolderNode: Identifiable, Hashable { + public let id = UUID() + public let folder: Folder + public var subfolders: [FolderNode] + public var conversations: [Conversation] + public var isOpen: Bool + + public static func == (lhs: FolderNode, rhs: FolderNode) -> Bool { + return lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +enum NavItem: Identifiable { + case folder(FolderNode) + case conversation(Conversation) + + + + + var id: AnyHashable { + switch self { + case .folder(let folderNode): + return AnyHashable(folderNode.folder.objectID) + case .conversation(let conversation): + return AnyHashable(conversation.id) + } + } +} diff --git a/mac/FreeChat/Models/NPC/freechat-server b/mac/FreeChat/Models/NPC/freechat-server index 9ce4233..cae2906 100755 Binary files a/mac/FreeChat/Models/NPC/freechat-server and b/mac/FreeChat/Models/NPC/freechat-server differ diff --git a/mac/FreeChat/Views/ContentView.swift b/mac/FreeChat/Views/ContentView.swift index 1075ddf..5f562ab 100644 --- a/mac/FreeChat/Views/ContentView.swift +++ b/mac/FreeChat/Views/ContentView.swift @@ -41,8 +41,10 @@ struct ContentView: View { var body: some View { NavigationSplitView { if setInitialSelection { - NavList(selection: $selection, showDeleteConfirmation: $showDeleteConfirmation) - .navigationSplitViewColumnWidth(min: 160, ideal: 160) + HierarchyView(selection: $selection, + showDeleteConfirmation: $showDeleteConfirmation, + viewContext: viewContext) + .navigationSplitViewColumnWidth(min: 160, ideal: 160) } } detail: { if selection.count > 1 { diff --git a/mac/FreeChat/Views/HierarchyView.swift b/mac/FreeChat/Views/HierarchyView.swift new file mode 100644 index 0000000..dbae027 --- /dev/null +++ b/mac/FreeChat/Views/HierarchyView.swift @@ -0,0 +1,540 @@ +import SwiftUI +import CoreData + +struct HierarchyView: View { + @Environment(\.managedObjectContext) private var viewContext + @StateObject private var hierarchyManager: ConversationHierarchyManager + + @Binding var selection: Set + @Binding var showDeleteConfirmation: Bool + + @State private var editingItem: HierarchyItem? + @State private var newTitle = "" + @FocusState private var fieldFocused: Bool + + @State private var selectedItemId: NSManagedObjectID? + @State private var lastSelectedChat: Conversation? + + @State private var showingDeleteFolderConfirmation = false + @State private var folderToDelete: Folder? + + @State private var draggedItem: HierarchyItem? + @State private var dropTargetID: NSManagedObjectID? + + @State private var selectedContextMenuItem: HierarchyItem? + + @State private var contextMenuSelectedItem: HierarchyItem? + + @EnvironmentObject var conversationManager: ConversationManager + + + + init(selection: Binding>, showDeleteConfirmation: Binding, viewContext: NSManagedObjectContext) { + self._selection = selection + self._showDeleteConfirmation = showDeleteConfirmation + self._hierarchyManager = StateObject(wrappedValue: ConversationHierarchyManager(viewContext: viewContext)) + } + + var body: some View { + List(hierarchyManager.hierarchyItems, children: \.children) { item in + HierarchyItemRow(item: item, + selectedItemId: $selectedItemId, + folderToDelete: $folderToDelete, + showingDeleteFolderConfirmation: $showingDeleteFolderConfirmation, + draggedItem: $draggedItem, + dropTargetID: $dropTargetID, + editingItem: $editingItem, // Add this + newTitle: $newTitle, // Add this + viewContext: viewContext, + hierarchyManager: hierarchyManager) + .listRowInsets(EdgeInsets()) + .contentShape(Rectangle()) + //.contentShape(RoundedRectangle(cornerRadius: 8)) + .onTapGesture { + selectedItemId = item.id + } + .contextMenu { + Button(action: { + contextMenuSelectedItem = item + renameItem() + }) { + Label("Rename", systemImage: "pencil") + } + + Button(action: { + contextMenuSelectedItem = item + deleteItem() + }) { + Label("Delete", systemImage: "trash") + } + } + + .listRowBackground( + RoundedRectangle(cornerRadius: 8).fill(rowBackgroundColor(for: item)) + .padding(.horizontal, 8) + ) + + } + .onChange(of: draggedItem) { _ in + if draggedItem == nil { + hierarchyManager.updateItemOrder() + } + } + .onChange(of: selectedItemId) { newValue in + if let id = newValue, + let conversation = hierarchyManager.findConversation(withId: id) { + selection = [conversation] + conversationManager.currentConversation = conversation + } else { + selection = [] + conversationManager.unsetConversation() + } + } + .toolbar { + ToolbarItem { Spacer() } + ToolbarItem { + Button(action: newConversation) { + Label("Add conversation", systemImage: "plus") + } + } + ToolbarItem { + Button(action: createFolder) { + Label("New Folder", systemImage: "folder") + } + } + } + + .alert("Delete Folder", isPresented: $showingDeleteFolderConfirmation, presenting: folderToDelete) { folder in + Button("Yes", role: .destructive) { + deleteFolder(folder) + } + Button("No", role: .cancel) {} + } message: { folder in + Text("Are you sure you want to delete the folder \(folder.name ?? "Unnamed") and all of its contents?") + } + } + + private func rowBackgroundColor(for item: HierarchyItem) -> Color { + selectedItemId == item.id ? Color(NSColor.selectedControlColor) : Color.clear + } + + private func startRenaming(_ item: HierarchyItem) { + print("startRenaming called for item: \(item.name)") + editingItem = item + newTitle = item.name + print("editingItem set to: \(editingItem?.name ?? "nil")") + print("newTitle set to: \(newTitle)") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + print("Setting fieldFocused to true") + fieldFocused = true + } + } + + private func testingThisWorks(){ + print("testing this works") + } + + private func renameItem() { + print("Rename function called") + print("Context menu selected item: \(contextMenuSelectedItem?.name ?? "nil")") + guard let item = contextMenuSelectedItem else { + print("No item selected for renaming") + return + } + print("Calling startRenaming for item: \(item.name)") + startRenaming(item) + } + + private func deleteItem() { + print("Context menu selected item: \(contextMenuSelectedItem?.name ?? "nil")") + guard let item = contextMenuSelectedItem else { + print("No item selected for deletion") + return + } + if item.isFolder { + folderToDelete = item.folder + showingDeleteFolderConfirmation = true + } else { + if let conversation = item.conversation { + deleteConversation(conversation) + } + } + } + + private func newConversation() { + do { + let conversation = try Conversation.create(ctx: viewContext) + if let selectedId = selectedItemId, + let folder = hierarchyManager.findFolder(withId: selectedId) { + conversation.folder = folder + } + try viewContext.save() + hierarchyManager.refreshHierarchy() + selection = [conversation] + selectedItemId = conversation.objectID + } catch { + print("Error creating new conversation: \(error)") + } + } + + private func createFolder() { + let folderName = "New Folder" + do { + let newFolder = try Folder.create(ctx: viewContext, name: folderName, parent: hierarchyManager.findFolder(withId: selectedItemId)) + try viewContext.save() + hierarchyManager.refreshHierarchy() + selectedItemId = newFolder.objectID + } catch { + print("An error occurred while creating the new folder: \(error)") + } + } + + private func deleteFolder(_ folder: Folder) { + do { + try deleteFolder(folder, in: viewContext) + try viewContext.save() + hierarchyManager.refreshHierarchy() + } catch { + print("Error deleting folder: \(error)") + } + } + + private func deleteFolder(_ folder: Folder, in context: NSManagedObjectContext) throws { + // Recursively delete subfolders + for subfolder in folder.subfolders ?? [] { + try deleteFolder(subfolder, in: context) + } + + // Delete all conversations in this folder + if let conversations = folder.conversation as? Set { + for conversation in conversations { + context.delete(conversation) + } + } + + // Delete the folder itself + context.delete(folder) + } + + private func saveNewTitle() { + guard let item = editingItem else { return } + hierarchyManager.renameItem(item, newName: newTitle) + editingItem = nil + fieldFocused = false + hierarchyManager.refreshHierarchy() // Refresh to show the updated name + } + + private func deleteConversation(_ conversation: Conversation) { + viewContext.delete(conversation) + do { + try viewContext.save() + hierarchyManager.refreshHierarchy() + } catch { + print("Error deleting conversation: \(error)") + } + } +} + +struct HierarchyItemRow: View { + var item: HierarchyItem + @Binding var selectedItemId: NSManagedObjectID? + @Binding var folderToDelete: Folder? + @Binding var showingDeleteFolderConfirmation: Bool + @Binding var draggedItem: HierarchyItem? + @Binding var dropTargetID: NSManagedObjectID? + @Binding var editingItem: HierarchyItem? // Add this + @Binding var newTitle: String // Add this + var viewContext: NSManagedObjectContext + var hierarchyManager: ConversationHierarchyManager + + @FocusState private var fieldFocused: Bool + + var body: some View { + HStack { + if item.isFolder { + Text(item.isOpen ? "πŸ“‚" : "πŸ“") + } else { + Text("πŸ“„") + } + + if editingItem?.id == item.id { + TextField("", text: $newTitle, onCommit: saveNewTitle) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .focused($fieldFocused) + .onSubmit { + saveNewTitle() + } + } else { + Text(item.name) + } + + Spacer() + } + .padding(.vertical, 8) + .padding(.horizontal, 6) + //.frame(maxWidth: .infinity, alignment: .leading) + //.background( + // RoundedRectangle(cornerRadius: 8) + // .fill(selectedItemId == item.id ? Color(NSColor.selectedControlColor) : Color.clear) + //) + .contentShape(Rectangle()) // This makes the whole row clickable (above and below the text) - otherwise the tap target is too small + .onTapGesture { + selectedItemId = item.id + } + + .onDrag { + self.draggedItem = item + return NSItemProvider(object: item.id.uriRepresentation().absoluteString as NSString) + } + .onDrop(of: [.text], delegate: HierarchyItemDropDelegate(item: item, + viewContext: viewContext, + hierarchyManager: hierarchyManager, + draggedItem: $draggedItem, + dropTargetID: $dropTargetID)) + } + + private func startRenaming(_ item: HierarchyItem) { + editingItem = item + newTitle = item.name + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + fieldFocused = true + } + } + + private func saveNewTitle() { + print("saveNewTitle called") + guard let item = editingItem else { + print("No item being edited") + return + } + print("Saving new title: \(newTitle) for item: \(item.name)") + hierarchyManager.renameItem(item, newName: newTitle) + editingItem = nil + fieldFocused = false + print("editingItem set to nil, fieldFocused set to false") + hierarchyManager.refreshHierarchy() + } + + private func deleteConversation(_ conversation: Conversation) { + viewContext.delete(conversation) + do { + try viewContext.save() + hierarchyManager.refreshHierarchy() + } catch { + print("Error deleting conversation: \(error)") + } + } +} + +class HierarchyItem: Identifiable, Hashable { + let id: NSManagedObjectID + var name: String + var children: [HierarchyItem]? + var isFolder: Bool + var folder: Folder? + var conversation: Conversation? + var isOpen: Bool + + init(id: NSManagedObjectID, name: String, children: [HierarchyItem]? = nil, isFolder: Bool, folder: Folder? = nil, conversation: Conversation? = nil, isOpen: Bool) { + self.id = id + self.name = name + self.children = children + self.isFolder = isFolder + self.folder = folder + self.conversation = conversation + self.isOpen = isOpen + } + + static func == (lhs: HierarchyItem, rhs: HierarchyItem) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +class ConversationHierarchyManager: ObservableObject { + @Published var hierarchyItems: [HierarchyItem] = [] + private let viewContext: NSManagedObjectContext + + init(viewContext: NSManagedObjectContext) { + self.viewContext = viewContext + refreshHierarchy() + } + + func refreshHierarchy() { + let folderFetchRequest: NSFetchRequest = Folder.fetchRequest() + folderFetchRequest.predicate = NSPredicate(format: "parent == nil") + folderFetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Folder.orderIndex, ascending: true)] + + let conversationFetchRequest: NSFetchRequest = Conversation.fetchRequest() + conversationFetchRequest.predicate = NSPredicate(format: "folder == nil") + conversationFetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Conversation.orderIndex, ascending: true)] + + do { + let rootFolders = try viewContext.fetch(folderFetchRequest) + let rootConversations = try viewContext.fetch(conversationFetchRequest) + + hierarchyItems = rootFolders.map { createHierarchyItem(from: $0) } + + rootConversations.map { createHierarchyItem(from: $0) } + } catch { + print("Failed to fetch root items: \(error)") + } + } + + private func createHierarchyItem(from folder: Folder) -> HierarchyItem { + let subfolderFetchRequest: NSFetchRequest = Folder.fetchRequest() + subfolderFetchRequest.predicate = NSPredicate(format: "parent == %@", folder) + subfolderFetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Folder.orderIndex, ascending: true)] + + let conversationFetchRequest: NSFetchRequest = Conversation.fetchRequest() + conversationFetchRequest.predicate = NSPredicate(format: "folder == %@", folder) + conversationFetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Conversation.orderIndex, ascending: true)] + + do { + let subfolders = try viewContext.fetch(subfolderFetchRequest) + let conversations = try viewContext.fetch(conversationFetchRequest) + + let children = subfolders.map { createHierarchyItem(from: $0) } + + conversations.map { createHierarchyItem(from: $0) } + + return HierarchyItem(id: folder.objectID, name: folder.name ?? "Unnamed Folder", children: children, isFolder: true, folder: folder, isOpen: folder.open) + } catch { + print("Failed to fetch items for folder \(folder.name ?? ""): \(error)") + return HierarchyItem(id: folder.objectID, name: folder.name ?? "Unnamed Folder", children: [], isFolder: true, folder: folder, isOpen: folder.open) + } + } + + private func createHierarchyItem(from conversation: Conversation) -> HierarchyItem { + HierarchyItem(id: conversation.objectID, name: conversation.title ?? conversation.titleWithDefault, isFolder: false, conversation: conversation, isOpen: false) + } + + func toggleFolderOpen(_ item: HierarchyItem) { + guard let folder = item.folder else { return } + folder.open.toggle() + try? viewContext.save() + refreshHierarchy() + } + + func updateItemOrder() { + updateOrder(items: hierarchyItems) + try? viewContext.save() + } + + private func updateOrder(items: [HierarchyItem], parentFolder: Folder? = nil) { + for (index, item) in items.enumerated() { + if item.isFolder { + item.folder?.orderIndex = Int32(index) + item.folder?.parent = parentFolder + if let children = item.children { + updateOrder(items: children, parentFolder: item.folder) + } + } else { + item.conversation?.orderIndex = Int32(index) + item.conversation?.folder = parentFolder + } + } + } + + func renameItem(_ item: HierarchyItem, newName: String) { + if item.isFolder { + item.folder?.name = newName + } else { + item.conversation?.title = newName + } + + do { + try viewContext.save() + refreshHierarchy() + } catch { + print("Error saving new name: \(error)") + } + } + + func findFolder(withId id: NSManagedObjectID?) -> Folder? { + guard let id = id else { return nil } + return findFolder(in: hierarchyItems, withId: id) + } + + private func findFolder(in items: [HierarchyItem], withId id: NSManagedObjectID) -> Folder? { + for item in items { + if item.isFolder && item.id == id { + return item.folder + } + if let children = item.children, + let found = findFolder(in: children, withId: id) { + return found + } + } + return nil + } + + func findConversation(withId id: NSManagedObjectID?) -> Conversation? { + guard let id = id else { return nil } + return findConversation(in: hierarchyItems, withId: id) + } + + private func findConversation(in items: [HierarchyItem], withId id: NSManagedObjectID) -> Conversation? { + for item in items { + if !item.isFolder && item.id == id { + return item.conversation + } + if let children = item.children, + let found = findConversation(in: children, withId: id) { + return found + } + } + return nil + } +} + +struct HierarchyItemDropDelegate: DropDelegate { + let item: HierarchyItem + let viewContext: NSManagedObjectContext + let hierarchyManager: ConversationHierarchyManager + @Binding var draggedItem: HierarchyItem? + @Binding var dropTargetID: NSManagedObjectID? + + func performDrop(info: DropInfo) -> Bool { + guard let sourceItem = draggedItem else { return false } + + if sourceItem.isFolder { + if let sourceFolder = sourceItem.folder, + let destinationFolder = item.isFolder ? item.folder : item.conversation?.folder { + sourceFolder.parent = destinationFolder + } else if !item.isFolder { + sourceItem.folder?.parent = nil + } + } else { + if item.isFolder { + sourceItem.conversation?.folder = item.folder + } else { + sourceItem.conversation?.folder = item.conversation?.folder + } + } + + do { + try viewContext.save() + hierarchyManager.refreshHierarchy() + } catch { + print("Error saving after drop: \(error)") + } + + draggedItem = nil + dropTargetID = nil + return true + } + + func dropEntered(info: DropInfo) { + self.dropTargetID = item.id + } + + func dropExited(info: DropInfo) { + self.dropTargetID = nil + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + return DropProposal(operation: .move) + } +} diff --git a/mac/FreeChat/Views/NavList.swift b/mac/FreeChat/Views/NavList.swift deleted file mode 100644 index 5cf9e20..0000000 --- a/mac/FreeChat/Views/NavList.swift +++ /dev/null @@ -1,150 +0,0 @@ -// -// ConversationNavItem.swift -// Chats -// -// Created by Peter Sugihara on 8/5/23. -// - -import SwiftUI - -struct NavList: View { - @Environment(\.managedObjectContext) private var viewContext - @Environment(\.openWindow) private var openWindow - @EnvironmentObject var conversationManager: ConversationManager - - @FetchRequest( - sortDescriptors: [NSSortDescriptor(keyPath: \Conversation.lastMessageAt, ascending: false)], - animation: .default) - private var items: FetchedResults - - @Binding var selection: Set - @Binding var showDeleteConfirmation: Bool - - @State var editing: Conversation? - @State var newTitle = "" - @FocusState var fieldFocused - - var body: some View { - List(items, id: \.self, selection: $selection) { item in - if editing == item { - TextField(item.titleWithDefault, text: $newTitle) - .textFieldStyle(.plain) - .focused($fieldFocused) - .onSubmit { - saveNewTitle(conversation: item) - } - .onExitCommand { - editing = nil - } - .onChange(of: fieldFocused) { focused in - if !focused { - editing = nil - } - } - .padding(.horizontal, 4) - } else { - Text(item.titleWithDefault).padding(.leading, 4) - } - } - .frame(minWidth: 50) - .toolbar { - ToolbarItem { - Spacer() - } - ToolbarItem { - Button(action: newConversation) { - Label("Add conversation", systemImage: "plus") - } - } - } - .onChange(of: items.count) { _ in - selection = Set([items.first].compactMap { $0 }) - } - .contextMenu(forSelectionType: Conversation.self) { _ in - Button { - deleteSelectedConversations() - } label: { - Label("Delete", systemImage: "trash") - } - } primaryAction: { items in - if items.count > 1 { return } - editing = items.first - fieldFocused = true - } - .confirmationDialog("Are you sure you want to delete \(selection.count == 1 ? "this" : "\(selection.count)") conversation\(selection.count == 1 ? "" : "s")?", isPresented: $showDeleteConfirmation) { - Button("Yes, delete") { - deleteSelectedConversations() - } - .keyboardShortcut(.defaultAction) - } - } - - private func saveNewTitle(conversation: Conversation) { - conversation.title = newTitle - newTitle = "" - do { - try viewContext.save() - - // HACK: trigger a state change so the title will refresh the title bar - selection.remove(conversation) - selection.insert(conversation) - } catch { - let nsError = error as NSError - print("Unresolved error \(nsError), \(nsError.userInfo)") - } - } - - private func deleteSelectedConversations() { - withAnimation { - selection.forEach(viewContext.delete) - do { - try viewContext.save() - selection.removeAll() - if items.count > 0 { - selection.insert(items.first!) - } - } catch { - let nsError = error as NSError - print("Unresolved error \(nsError), \(nsError.userInfo)") - } - } - } - - private func deleteConversation(conversation: Conversation) { - withAnimation { - viewContext.delete(conversation) - do { - try viewContext.save() - } catch { - let nsError = error as NSError - print("Unresolved error \(nsError), \(nsError.userInfo)") - } - } - } - - private func sortedItems() -> [Conversation] { - items.sorted(by: { $0.updatedAt!.compare($1.updatedAt!) == .orderedDescending }) - } - - private func newConversation() { - conversationManager.newConversation(viewContext: viewContext, openWindow: openWindow) - } -} - -#if DEBUG - struct NavList_Previews_Container: View { - @State public var selection: Set = Set() - @State public var showDeleteConfirmation = false - - var body: some View { - NavList(selection: $selection, showDeleteConfirmation: $showDeleteConfirmation) - .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) - } - } - - struct NavList_Previews: PreviewProvider { - static var previews: some View { - NavList_Previews_Container() - } - } -#endif diff --git a/mac/FreeChat/Views/TestHierarchical.swift b/mac/FreeChat/Views/TestHierarchical.swift new file mode 100644 index 0000000..3e05eaf --- /dev/null +++ b/mac/FreeChat/Views/TestHierarchical.swift @@ -0,0 +1,55 @@ +// +// TestHierarchical.swift +// FreeChat +// +// Created by Sebastian Gray on 25/7/2024. +// + +import SwiftUI + +struct TestHierarchicalContentView: View { + struct FileItem: Hashable, Identifiable, CustomStringConvertible { + var id: Self { self } + var name: String + var children: [FileItem]? = nil + var description: String { + switch children { + case nil: + return "πŸ“„ \(name)" + case .some(let children): + return children.isEmpty ? "πŸ“‚ \(name)" : "πŸ“ \(name)" + } + } + } + @State private var selectedItems: Set = [] + let fileHierarchyData: [FileItem] = [ + FileItem(name: "users", children: + [FileItem(name: "user1234", children: + [FileItem(name: "Photos", children: + [FileItem(name: "photo001.jpg"), + FileItem(name: "photo002.jpg")]), + FileItem(name: "Movies", children: + [FileItem(name: "movie001.mp4")]), + FileItem(name: "Documents", children: []) + ]), + FileItem(name: "newuser", children: + [FileItem(name: "Documents", children: []) + ]) + ]), + FileItem(name: "private", children: nil) + ] + + var body: some View { + List(fileHierarchyData, children: \.children, selection: $selectedItems) { item in + Text(item.description) + .foregroundColor(selectedItems.contains(item) ? .blue : .primary) + } + } +} + + +struct TestHierarchicalContentView_Previews: PreviewProvider { + static var previews: some View { + TestHierarchicalContentView() + } +} diff --git a/mac/FreeChat/Views/ts_HierarchyView.swift b/mac/FreeChat/Views/ts_HierarchyView.swift new file mode 100644 index 0000000..11a53ad --- /dev/null +++ b/mac/FreeChat/Views/ts_HierarchyView.swift @@ -0,0 +1,18 @@ +// +// ts_HierarchyView.swift +// FreeChat +// +// Created by Sebastian Gray on 4/8/2024. +// + +import SwiftUI + +struct ts_HierarchyView: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +#Preview { + ts_HierarchyView() +} diff --git a/mac/FreeChatTests/PromptTemplateTests.swift b/mac/FreeChatTests/PromptTemplateTests.swift deleted file mode 100644 index faceb68..0000000 --- a/mac/FreeChatTests/PromptTemplateTests.swift +++ /dev/null @@ -1,182 +0,0 @@ -// -// PromptTemplateTests.swift -// FreeChatTests -// -// Created by Peter Sugihara on 10/6/23. -// - -import XCTest -@testable import FreeChat - -final class PromptTemplateTests: XCTestCase { - var shortConvo: [String] = [ - "Hey baby!", - "Wassup, user?", - "n2m hbu" - ] - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testLlama2Opening() throws { - let p = Llama2Template().run(systemPrompt: "A system prompt", messages: ["sup"]) - let expected = """ - [INST] <> - A system prompt - <> - - sup [/INST] \ - - """ - - XCTAssert(!p.isEmpty) - XCTAssertEqual(p, expected) - } - - func testLlama2ShortConvo() throws { - let p = Llama2Template().run(systemPrompt: "A system prompt", messages: shortConvo) - let expected = """ - [INST] <> - A system prompt - <> - - Hey baby! [/INST] Wassup, user? [INST] n2m hbu [/INST] - """ - - XCTAssert(!p.isEmpty) - XCTAssertEqual(p, expected) - } - - func testVicunaOpening() throws { - let expected = """ - SYSTEM: A system prompt - USER: hi - ASSISTANT: \ - - """ - let p = VicunaTemplate().run(systemPrompt: "A system prompt", messages: ["hi"]) - - XCTAssert(!p.isEmpty) - XCTAssertEqual(p, expected) - } - - func testVicunaShortConvo() throws { - let expected = """ - SYSTEM: A system prompt - USER: Hey baby! - ASSISTANT: Wassup, user? - USER: n2m hbu - ASSISTANT: \ - - """ - let p = VicunaTemplate().run(systemPrompt: "A system prompt", messages: shortConvo) - - XCTAssert(!p.isEmpty) - XCTAssertEqual(p, expected) - } - - func testChatMLOpening() throws { - let expected = """ - <|im_start|>system - A system prompt - <|im_end|> - <|im_start|>user - hi - <|im_end|> - <|im_start|>assistant - - """ - let p = ChatMLTemplate().run(systemPrompt: "A system prompt", messages: ["hi"]) - - XCTAssert(!p.isEmpty) - XCTAssertEqual(p, expected) - } - - func testChatMLShortConvo() throws { - let expected = """ - <|im_start|>system - A system prompt - <|im_end|> - <|im_start|>user - Hey baby! - <|im_end|> - <|im_start|>assistant - Wassup, user? - <|im_end|> - <|im_start|>user - n2m hbu - <|im_end|> - <|im_start|>assistant - - """ - let p = ChatMLTemplate().run(systemPrompt: "A system prompt", messages: shortConvo) - - XCTAssert(!p.isEmpty) - XCTAssertEqual(p, expected) - } - - func testAlpacaOpening() throws { - let expected = """ - ### Instruction: - A system prompt - - Conversation so far: - user: hi - you: - - Respond to user's last line with markdown. - - ### Response: - - """ - let p = AlpacaTemplate().run(systemPrompt: "A system prompt", messages: ["hi"]) - - XCTAssert(!p.isEmpty) - XCTAssertEqual(p, expected) - } - - func testAlpacaShortConvo() throws { - let expected = """ - ### Instruction: - A system prompt - - Conversation so far: - user: Hey baby! - you: Wassup, user? - user: n2m hbu - you: - - Respond to user's last line with markdown. - - ### Response: - - """ - let p = AlpacaTemplate().run(systemPrompt: "A system prompt", messages: shortConvo) - - XCTAssert(!p.isEmpty) - XCTAssertEqual(p, expected) - } - - func testTemplatesHaveMatchingFormats() throws { - for format in TemplateFormat.allCases { - let template = TemplateManager.templates[format] - XCTAssertEqual(template.format, format) - } - } - - func testFormatWithModelName() throws { - XCTAssertEqual(TemplateManager.formatFromModel(nil), .vicuna) - XCTAssertEqual(TemplateManager.formatFromModel(""), .vicuna) - XCTAssertEqual(TemplateManager.formatFromModel("codellama-34b-instruct.Q4_K_M.gguf"), .llama2) - XCTAssertEqual(TemplateManager.formatFromModel("nous-hermes-llama-2-7b.Q5_K_M.gguf"), .alpaca) - XCTAssertEqual(TemplateManager.formatFromModel("airoboros-m-7b-3.1.Q4_0.gguf"), .llama2) - XCTAssertEqual(TemplateManager.formatFromModel("synthia-7b-v1.5.Q3_K_S.gguf"), .vicuna) - XCTAssertEqual(TemplateManager.formatFromModel("openhermes-2-mistral-7b.Q8_0.gguf"), .chatML) - XCTAssertEqual(TemplateManager.formatFromModel("phi-2-openhermes-2.5.Q5_K_M.gguf"), .chatML) - } -} diff --git a/mac/FreeChatUITests/FreeChatUITests.swift b/mac/FreeChatUITests/FreeChatUITests.swift deleted file mode 100644 index c7bd27c..0000000 --- a/mac/FreeChatUITests/FreeChatUITests.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// FreeChatUITests.swift -// FreeChatUITests -// -// Created by Peter Sugihara on 7/31/23. -// - -import XCTest - -final class FreeChatUITests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. - continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() - app.launch() - - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - func testLaunchPerformance() throws { - if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { - // This measures how long it takes to launch your application. - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() - } - } - } -} diff --git a/mac/FreeChatUITests/FreeChatUITestsLaunchTests.swift b/mac/FreeChatUITests/FreeChatUITestsLaunchTests.swift deleted file mode 100644 index 13fee59..0000000 --- a/mac/FreeChatUITests/FreeChatUITestsLaunchTests.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// FreeChatUITestsLaunchTests.swift -// FreeChatUITests -// -// Created by Peter Sugihara on 7/31/23. -// - -import XCTest - -final class FreeChatUITestsLaunchTests: XCTestCase { - - override class var runsForEachTargetApplicationUIConfiguration: Bool { - true - } - - override func setUpWithError() throws { - continueAfterFailure = false - } - - func testLaunch() throws { - let app = XCUIApplication() - app.launch() - - // Insert steps here to perform after app launch but before taking a screenshot, - // such as logging into a test account or navigating somewhere in the app - - let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "Launch Screen" - attachment.lifetime = .keepAlways - add(attachment) - } -}