diff --git a/LocalPackage/Sources/Model/Stores/CustomMetricsSettings.swift b/LocalPackage/Sources/Model/Stores/CustomMetricsSettings.swift index facd5bd..6b87d04 100644 --- a/LocalPackage/Sources/Model/Stores/CustomMetricsSettings.swift +++ b/LocalPackage/Sources/Model/Stores/CustomMetricsSettings.swift @@ -115,6 +115,19 @@ public final class CustomMetricsSettings: Composable { case .removingCustomMetricsSourceCancelled: pendingRemovalSourceID = nil + case let .customMetricsSourceMoved(sourceID, destinationID): + guard let sourceIndex = customMetricsSources.firstIndex(where: { $0.id == sourceID }), + let destinationIndex = customMetricsSources.firstIndex(where: { $0.id == destinationID }), + sourceIndex != destinationIndex else { + return + } + let source = customMetricsSources.remove(at: sourceIndex) + customMetricsSources.insert(source, at: destinationIndex) + var configuration = userDefaultsRepository.customMetricsConfiguration + configuration.sources = customMetricsSources + userDefaultsRepository.customMetricsConfiguration = configuration + customMetricsService.emitConfigurationChange() + case let .customMetricsSourceLinkTapped(source): do { try customMetricsService.perform( @@ -150,6 +163,7 @@ public final class CustomMetricsSettings: Composable { case removeCustomMetricsSourceButtonTapped(UUID) case removingCustomMetricsSourceConfirmed case removingCustomMetricsSourceCancelled + case customMetricsSourceMoved(UUID, UUID) case customMetricsSourceLinkTapped(CustomMetricsSource) case onError(RCNError) } diff --git a/LocalPackage/Sources/UserInterface/Views/Settings/MetricsSettings/CustomMetricsSettingsSectionView.swift b/LocalPackage/Sources/UserInterface/Views/Settings/MetricsSettings/CustomMetricsSettingsSectionView.swift index ae19f6e..409ccee 100644 --- a/LocalPackage/Sources/UserInterface/Views/Settings/MetricsSettings/CustomMetricsSettingsSectionView.swift +++ b/LocalPackage/Sources/UserInterface/Views/Settings/MetricsSettings/CustomMetricsSettingsSectionView.swift @@ -18,11 +18,15 @@ limitations under the License. */ +import AppKit import Model +import Observation import SwiftUI +import UniformTypeIdentifiers struct CustomMetricsSettingsSectionView: View { @State var store: CustomMetricsSettings + @State private var dragState = CustomMetricsSourceDragState() var body: some View { Section { @@ -30,6 +34,9 @@ struct CustomMetricsSettingsSectionView: View { CustomMetricsSourceRowView( source: source, isErrorDetected: store.failedCustomMetricsSourceIDs.contains(source.id), + dragStarted: { + dragState.begin(sourceID: source.id) + }, removeButtonTapped: { await store.send(.removeCustomMetricsSourceButtonTapped(source.id)) }, @@ -37,6 +44,19 @@ struct CustomMetricsSettingsSectionView: View { await store.send(.customMetricsSourceLinkTapped(source)) } ) + .opacity(dragState.hiddenSourceID == source.id ? 0 : 1) + .onDrop( + of: [.text], + delegate: CustomMetricsSourceDropDelegate( + destinationSourceID: source.id, + dragState: dragState, + move: { sourceID, destinationID in + Task { + await store.send(.customMetricsSourceMoved(sourceID, destinationID)) + } + } + ) + ) } HStack { Spacer() @@ -116,9 +136,70 @@ struct CustomMetricsSettingsSectionView: View { await store.send(.task) } .onDisappear { + dragState.end() Task { await store.send(.onDisappear) } } } } + +@MainActor @Observable +private final class CustomMetricsSourceDragState { + var sourceID: UUID? + var hiddenSourceID: UUID? + + @ObservationIgnored private var mouseUpMonitor: Any? + @ObservationIgnored private var revealTask: Task? + + func begin(sourceID: UUID) { + revealTask?.cancel() + self.sourceID = sourceID + hiddenSourceID = sourceID + guard mouseUpMonitor == nil else { return } + mouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in + self?.end() + return event + } + } + + func end() { + let wasDragging = sourceID != nil + sourceID = nil + if let mouseUpMonitor { + NSEvent.removeMonitor(mouseUpMonitor) + self.mouseUpMonitor = nil + } + guard wasDragging else { return } + revealTask?.cancel() + revealTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(200)) + guard !Task.isCancelled else { return } + self?.hiddenSourceID = nil + self?.revealTask = nil + } + } +} + +private struct CustomMetricsSourceDropDelegate: DropDelegate { + var destinationSourceID: UUID + var dragState: CustomMetricsSourceDragState + var move: (UUID, UUID) -> Void + + func dropEntered(info: DropInfo) { + guard let draggedSourceID = dragState.sourceID, + draggedSourceID != destinationSourceID else { + return + } + move(draggedSourceID, destinationSourceID) + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + dragState.end() + return true + } +} diff --git a/LocalPackage/Sources/UserInterface/Views/Settings/MetricsSettings/CustomMetricsSourceRowView.swift b/LocalPackage/Sources/UserInterface/Views/Settings/MetricsSettings/CustomMetricsSourceRowView.swift index 4328975..cf4e59a 100644 --- a/LocalPackage/Sources/UserInterface/Views/Settings/MetricsSettings/CustomMetricsSourceRowView.swift +++ b/LocalPackage/Sources/UserInterface/Views/Settings/MetricsSettings/CustomMetricsSourceRowView.swift @@ -22,22 +22,32 @@ import DataSource import SwiftUI struct CustomMetricsSourceRowView: View { + private let controlWidth: CGFloat = 24 + private let controlSpacing: CGFloat = 8 + var source: CustomMetricsSource var isErrorDetected: Bool + var dragStarted: () -> Void var removeButtonTapped: () async -> Void var sourceLinkTapped: () async -> Void var body: some View { LabeledContent { - Button(role: .destructive) { - Task { - await removeButtonTapped() + HStack(spacing: controlSpacing) { + Image(systemName: "line.3.horizontal") + .foregroundStyle(.secondary) + .frame(width: controlWidth) + Button(role: .destructive) { + Task { + await removeButtonTapped() + } + } label: { + Image(systemName: "minus.circle") + .foregroundStyle(Color.red) } - } label: { - Image(systemName: "minus.circle") - .foregroundStyle(Color.red) + .buttonStyle(.borderless) + .frame(width: controlWidth) } - .buttonStyle(.borderless) } label: { Text(source.displayName) .truncationMode(.middle) @@ -55,5 +65,31 @@ struct CustomMetricsSourceRowView: View { } } } + .contentShape( + .interaction, + CustomMetricsSourceDragHandleShape( + width: controlWidth, + trailingInset: controlWidth + controlSpacing + ) + ) + .contentShape(.dragPreview, Rectangle()) + .onDrag { + dragStarted() + return NSItemProvider(object: source.id.uuidString as NSString) + } + } +} + +private struct CustomMetricsSourceDragHandleShape: Shape { + var width: CGFloat + var trailingInset: CGFloat + + func path(in rect: CGRect) -> Path { + Path(CGRect( + x: rect.maxX - trailingInset - width, + y: rect.minY, + width: width, + height: rect.height + )) } } diff --git a/LocalPackage/Tests/ModelTests/StoreTests/CustomMetricsSettingsTests.swift b/LocalPackage/Tests/ModelTests/StoreTests/CustomMetricsSettingsTests.swift index 71e1ffc..2bf3c86 100644 --- a/LocalPackage/Tests/ModelTests/StoreTests/CustomMetricsSettingsTests.swift +++ b/LocalPackage/Tests/ModelTests/StoreTests/CustomMetricsSettingsTests.swift @@ -234,6 +234,30 @@ struct CustomMetricsSettingsTests { #expect(sut.customMetricsSources.count == 1) } + @MainActor @Test + func send_customMetricsSourceMoved_reorders_sources_persists_and_emits_change() async { + let appState = AllocatedUnfairLock(initialState: .init()) + let sources = (1...3).map { + CustomMetricsSource( + id: UUID($0), + displayName: "Source \($0)", + fileURL: URL(filePath: "/tmp/source-\($0).json"), + bookmark: Data(), + createdAt: Date(timeIntervalSince1970: 0) + ) + } + let storage = UserDefaultsClient.storage(initialSources: sources) + let sut = CustomMetricsSettings(.testDependencies( + appStateClient: .testDependency(appState), + userDefaultsClient: storage.client + )) + await sut.send(.customMetricsSourceMoved(sources[0].id, sources[2].id)) + let expectedSources = [sources[1], sources[2], sources[0]] + #expect(sut.customMetricsSources == expectedSources) + #expect(storage.currentConfiguration()?.sources == expectedSources) + #expect(appState.withLock(\.customMetricsConfigurationChanges.latestValue) != nil) + } + @MainActor @Test func send_customMetricsSourceLinkTapped_activates_file_viewer_with_resolved_url() async { let resolvedURL = URL(filePath: "/tmp/resolved.json")