diff --git a/Sources/UI/AssistantDragReorder.swift b/Sources/UI/AssistantDragReorder.swift new file mode 100644 index 0000000..d839ae2 --- /dev/null +++ b/Sources/UI/AssistantDragReorder.swift @@ -0,0 +1,61 @@ +import SwiftUI +import UniformTypeIdentifiers + +/// Drag/drop reorder for assistant tiles. Uses the legacy `.onDrag` / +/// `.onDrop` API with the standard `public.text` UTI and an NSString +/// payload — the modern Transferable + custom-UTType path silently +/// dropped on macOS, and so did `.ownProcess` NSItemProvider visibility +/// + bespoke UTI. (List ancestor + Button wrapper + visibility + +/// custom UTI declaration each independently kill the drop.) +struct AssistantDragReorderModifier: ViewModifier { + let isEnabled: Bool + let assistantID: String + let onReorder: (String) -> Void + + @State private var isTargeted = false + + func body(content: Content) -> some View { + if isEnabled { + content + .overlay { + if isTargeted { + RoundedRectangle(cornerRadius: JinRadius.medium, style: .continuous) + .stroke(Color.accentColor, lineWidth: 2) + } + } + .onDrag { + NSItemProvider(object: assistantID as NSString) + } + .onDrop(of: [.text], isTargeted: $isTargeted) { providers in + guard let provider = providers.first else { return false } + let target = assistantID + let handler = onReorder + provider.loadObject(ofClass: NSString.self) { object, _ in + guard let nsString = object as? NSString else { return } + let sourceID = nsString as String + guard !sourceID.isEmpty, sourceID != target else { return } + DispatchQueue.main.async { + handler(sourceID) + } + } + return true + } + } else { + content + } + } +} + +extension View { + func assistantDragReorder( + isEnabled: Bool, + assistantID: String, + onReorder: @escaping (String) -> Void + ) -> some View { + modifier(AssistantDragReorderModifier( + isEnabled: isEnabled, + assistantID: assistantID, + onReorder: onReorder + )) + } +} diff --git a/Sources/UI/ContentView+AssistantManagement.swift b/Sources/UI/ContentView+AssistantManagement.swift index 79b4154..cfd1d70 100644 --- a/Sources/UI/ContentView+AssistantManagement.swift +++ b/Sources/UI/ContentView+AssistantManagement.swift @@ -67,6 +67,35 @@ extension ContentView { assistantPendingDeletion = nil } + /// Reorders `sourceID` so it skips past `targetID` in the direction it was + /// dragged: forward drags land the source immediately *after* the target, + /// backward drags land it immediately *before*. Returns `true` if the + /// order changed and was persisted. + @discardableResult + func reorderAssistant(sourceID: String, onto targetID: String) -> Bool { + guard sourceID != targetID else { return false } + var ordered = assistants + guard let sourceIdx = ordered.firstIndex(where: { $0.id == sourceID }), + let targetIdx = ordered.firstIndex(where: { $0.id == targetID }) else { + return false + } + + let item = ordered.remove(at: sourceIdx) + ordered.insert(item, at: targetIdx) + + let now = Date() + var changed = false + for (newOrder, assistant) in ordered.enumerated() where assistant.sortOrder != newOrder { + assistant.sortOrder = newOrder + assistant.updatedAt = now + changed = true + } + guard changed else { return false } + + try? modelContext.save() + return true + } + // MARK: - Sidebar Layout Helpers var displayedAssistants: [AssistantEntity] { diff --git a/Sources/UI/ContentView+SidebarSections.swift b/Sources/UI/ContentView+SidebarSections.swift index e1a039e..d3167a6 100644 --- a/Sources/UI/ContentView+SidebarSections.swift +++ b/Sources/UI/ContentView+SidebarSections.swift @@ -12,164 +12,185 @@ extension ContentView { } @ViewBuilder - var assistantsSection: some View { - Section { - switch assistantSidebarLayout { - case .dropdown: - HStack(spacing: 8) { - Text("Assistant") - .foregroundStyle(.secondary) - Spacer(minLength: 8) - Picker("", selection: selectedAssistantIDBinding) { - ForEach(displayedAssistants) { assistant in - Text(assistant.displayName).tag(assistant.id) + var assistantsArea: some View { + VStack(spacing: 0) { + assistantsAreaHeader + assistantsAreaBody + newAssistantButton + } + .padding(.bottom, JinSpacing.xSmall) + .onDeleteCommand { + guard let selectedAssistant else { return } + requestDeleteAssistant(selectedAssistant) + } + } + + @ViewBuilder + private var assistantsAreaHeader: some View { + HStack { + Text("Assistants") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + Spacer() + Menu { + Toggle("Assistant Name", isOn: $assistantSidebarShowName) + .disabled(assistantSidebarShowName && !assistantSidebarShowIcon) + Toggle("Assistant Icon", isOn: $assistantSidebarShowIcon) + .disabled(assistantSidebarShowIcon && !assistantSidebarShowName) + + Divider() + + Picker("", selection: assistantSidebarGridColumnsBinding) { + Text("1 Column").tag(1) + Text("2 Columns").tag(2) + Text("3 Columns").tag(3) + Text("4 Columns").tag(4) + } + .labelsHidden() + .pickerStyle(.inline) + + Divider() + + Toggle("Dropdown View", isOn: assistantSidebarUsesDropdownBinding) + + Divider() + + Menu("Sort") { + Picker("", selection: assistantSidebarSortBinding) { + ForEach(AssistantSidebarSort.allCases, id: \.rawValue) { option in + Text(option.label).tag(option) } } .labelsHidden() - .pickerStyle(.menu) - .contextMenu { - if let assistant = selectedAssistant ?? assistants.first(where: { $0.id == "default" }) { - assistantContextMenu(for: assistant) - } - } + .pickerStyle(.inline) } - .listRowInsets(EdgeInsets(top: 8, leading: 14, bottom: 8, trailing: 14)) - .contextMenu { - if let assistant = selectedAssistant ?? assistants.first(where: { $0.id == "default" }) { - assistantContextMenu(for: assistant) - } + + SettingsLink { + Text("Settings") } + } label: { + Image(systemName: "slider.horizontal.3") + } + .buttonStyle(.borderless) + .menuIndicator(.hidden) + .help("Assistant view options") + } + .padding(.horizontal, JinSpacing.medium + 2) + .padding(.top, JinSpacing.xSmall) + .padding(.bottom, JinSpacing.xSmall + 1) + } - case .grid: - LazyVGrid( - columns: Array( - repeating: GridItem(.flexible(minimum: 44), spacing: 8), - count: assistantSidebarGridColumnCount - ), - spacing: 8 - ) { + @ViewBuilder + private var assistantsAreaBody: some View { + switch assistantSidebarLayout { + case .dropdown: + HStack(spacing: 8) { + Text("Assistant") + .foregroundStyle(.secondary) + Spacer(minLength: 8) + Picker("", selection: selectedAssistantIDBinding) { ForEach(displayedAssistants) { assistant in - let isSelected = selectedAssistant?.id == assistant.id - Button { - selectAssistant(assistant) - } label: { - AssistantTileView( - assistant: assistant, - isSelected: isSelected, - showsName: assistantSidebarEffectiveShowName, - showsIcon: assistantSidebarEffectiveShowIcon - ) - } - .buttonStyle(.plain) - .onHover { isHovering in - if isHovering { - assistantContextMenuTargetID = assistant.id - } else if assistantContextMenuTargetID == assistant.id { - assistantContextMenuTargetID = nil - } - } + Text(assistant.displayName).tag(assistant.id) } } + .labelsHidden() + .pickerStyle(.menu) .contextMenu { - if let assistant = resolveAssistantForContextMenu() { + if let assistant = selectedAssistant ?? assistants.first(where: { $0.id == "default" }) { assistantContextMenu(for: assistant) } } - .listRowInsets(EdgeInsets(top: 6, leading: 14, bottom: 8, trailing: 14)) - .listRowBackground(Color.clear) + } + .padding(EdgeInsets(top: 8, leading: 14, bottom: 8, trailing: 14)) + .contextMenu { + if let assistant = selectedAssistant ?? assistants.first(where: { $0.id == "default" }) { + assistantContextMenu(for: assistant) + } + } - case .list: + case .grid: + LazyVGrid( + columns: Array( + repeating: GridItem(.flexible(minimum: 44), spacing: 8), + count: assistantSidebarGridColumnCount + ), + spacing: 8 + ) { ForEach(displayedAssistants) { assistant in let isSelected = selectedAssistant?.id == assistant.id - Button { - selectAssistant(assistant) - } label: { - AssistantRowView( - assistant: assistant, - chatCount: conversationCountsByAssistantID[assistant.id, default: 0], - isSelected: isSelected - ) + AssistantTileView( + assistant: assistant, + isSelected: isSelected, + showsName: assistantSidebarEffectiveShowName, + showsIcon: assistantSidebarEffectiveShowIcon + ) + .contentShape(Rectangle()) + .onTapGesture { selectAssistant(assistant) } + .onHover { isHovering in + if isHovering { + assistantContextMenuTargetID = assistant.id + } else if assistantContextMenuTargetID == assistant.id { + assistantContextMenuTargetID = nil + } + } + .assistantDragReorder( + isEnabled: assistantSidebarSort == .custom, + assistantID: assistant.id + ) { sourceID in + _ = reorderAssistant(sourceID: sourceID, onto: assistant.id) } - .buttonStyle(.plain) - .listRowBackground(isSelected ? Color.accentColor.opacity(0.10) : Color.clear) - .contextMenu { assistantContextMenu(for: assistant) } } } + .animation(.spring(duration: 0.3), value: displayedAssistants.map(\.id)) + .contextMenu { + if let assistant = resolveAssistantForContextMenu() { + assistantContextMenu(for: assistant) + } + } + .padding(EdgeInsets(top: 6, leading: 14, bottom: 8, trailing: 14)) - Button { - createAssistant() - } label: { - Label("New Assistant", systemImage: "plus") + case .list: + VStack(spacing: 0) { + ForEach(displayedAssistants) { assistant in + let isSelected = selectedAssistant?.id == assistant.id + AssistantRowView( + assistant: assistant, + chatCount: conversationCountsByAssistantID[assistant.id, default: 0], + isSelected: isSelected + ) .padding(.horizontal, 14) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 4) + .background(isSelected ? Color.accentColor.opacity(0.10) : Color.clear) .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .foregroundStyle(.secondary) - .help("New Assistant") - .keyboardShortcut(shortcutsStore.keyboardShortcut(for: .newAssistant)) - .listRowInsets(.init()) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } header: { - HStack { - Text("Assistants") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.secondary) - Spacer() - Menu { - Toggle("Assistant Name", isOn: $assistantSidebarShowName) - .disabled(assistantSidebarShowName && !assistantSidebarShowIcon) - Toggle("Assistant Icon", isOn: $assistantSidebarShowIcon) - .disabled(assistantSidebarShowIcon && !assistantSidebarShowName) - - Divider() - - Picker("", selection: assistantSidebarGridColumnsBinding) { - Text("1 Column").tag(1) - Text("2 Columns").tag(2) - Text("3 Columns").tag(3) - Text("4 Columns").tag(4) - } - .labelsHidden() - .pickerStyle(.inline) - - Divider() - - Toggle("Dropdown View", isOn: assistantSidebarUsesDropdownBinding) - - Divider() - - Menu("Sort") { - Picker("", selection: assistantSidebarSortBinding) { - ForEach(AssistantSidebarSort.allCases, id: \.rawValue) { option in - Text(option.label).tag(option) - } - } - .labelsHidden() - .pickerStyle(.inline) - } - - SettingsLink { - Text("Settings") + .onTapGesture { selectAssistant(assistant) } + .contextMenu { assistantContextMenu(for: assistant) } + .assistantDragReorder( + isEnabled: assistantSidebarSort == .custom, + assistantID: assistant.id + ) { sourceID in + _ = reorderAssistant(sourceID: sourceID, onto: assistant.id) } - } label: { - Image(systemName: "slider.horizontal.3") } - .buttonStyle(.borderless) - .menuIndicator(.hidden) - .help("Assistant view options") } - .padding(.horizontal, JinSpacing.medium + 2) - .padding(.top, JinSpacing.xSmall) - .padding(.bottom, JinSpacing.xSmall + 1) + .animation(.spring(duration: 0.3), value: displayedAssistants.map(\.id)) } - .textCase(nil) - .onDeleteCommand { - guard let selectedAssistant else { return } - requestDeleteAssistant(selectedAssistant) + } + + @ViewBuilder + private var newAssistantButton: some View { + Button { + createAssistant() + } label: { + Label("New Assistant", systemImage: "plus") + .padding(.horizontal, 14) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help("New Assistant") + .keyboardShortcut(shortcutsStore.keyboardShortcut(for: .newAssistant)) } @ViewBuilder diff --git a/Sources/UI/ContentView.swift b/Sources/UI/ContentView.swift index da970a9..4895fc9 100644 --- a/Sources/UI/ContentView.swift +++ b/Sources/UI/ContentView.swift @@ -126,8 +126,9 @@ struct ContentView: View { VStack(spacing: 0) { sidebarPinnedChrome + assistantsArea + List(selection: conversationListSelectionBinding) { - assistantsSection chatsSection } .frame(maxWidth: .infinity, maxHeight: .infinity)