Skip to content
Open
14 changes: 14 additions & 0 deletions LocalPackage/Sources/Model/Stores/CustomMetricsSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,45 @@
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 {
ForEach(store.customMetricsSources) { source in
CustomMetricsSourceRowView(
source: source,
isErrorDetected: store.failedCustomMetricsSourceIDs.contains(source.id),
dragStarted: {
dragState.begin(sourceID: source.id)
},
removeButtonTapped: {
await store.send(.removeCustomMetricsSourceButtonTapped(source.id))
},
sourceLinkTapped: {
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()
Expand Down Expand Up @@ -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<Void, Never>?

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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState>(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")
Expand Down