Compare commits

..

No commits in common. "da53ee4e9bacabc5ee937fb6fcc07c43d2422f71" and "ffb6e497ec94986ca073b56f6cfc68fd3e606e0d" have entirely different histories.

7 changed files with 108 additions and 239 deletions

View File

@ -152,7 +152,7 @@ nonisolated struct CollectionSnapshot: Identifiable, Hashable, Sendable, Codable
let childDirectoryCount: Int let childDirectoryCount: Int
let fingerprint: String let fingerprint: String
var id: String { "\(folderName)::\(fingerprint)" } var id: String { folderName }
} }
nonisolated struct SourceSnapshot: Hashable, Sendable, Codable { nonisolated struct SourceSnapshot: Hashable, Sendable, Codable {

View File

@ -76,19 +76,11 @@ nonisolated struct SourceCandidate: Identifiable, Hashable, Sendable {
var id: String { var id: String {
[ [
providerID, providerID,
sourceIdentityKey(for: sourceRootURL) sourceRootURL.standardizedFileURL.absoluteString
].joined(separator: "::") ].joined(separator: "::")
} }
} }
nonisolated func sourceIdentityKey(for url: URL) -> String {
if url.isFileURL {
return url.standardizedFileURL.resolvingSymlinksInPath().path.lowercased()
}
return url.standardized.absoluteString.lowercased()
}
nonisolated enum WorkStageState: String, Hashable, Sendable, Codable { nonisolated enum WorkStageState: String, Hashable, Sendable, Codable {
case pending case pending
case running case running

View File

@ -965,7 +965,7 @@ enum JavaContentScanner {
under root: URL, under root: URL,
providerID: PlatformProviderID providerID: PlatformProviderID
) -> [SourceCandidate] { ) -> [SourceCandidate] {
let uniqueCandidates = Dictionary(grouping: candidates, by: { sourceIdentityKey(for: $0.sourceRootURL) }).compactMap { _, groupedCandidates in let uniqueCandidates = Dictionary(grouping: candidates, by: \.sourceRootURL).compactMap { _, groupedCandidates in
groupedCandidates.max { lhs, rhs in groupedCandidates.max { lhs, rhs in
lhs.confidence < rhs.confidence lhs.confidence < rhs.confidence
} }
@ -1066,7 +1066,7 @@ enum JavaContentScanner {
while !queue.isEmpty && folders.count < maxFolderCount { while !queue.isEmpty && folders.count < maxFolderCount {
let current = queue.removeFirst() let current = queue.removeFirst()
let normalizedURL = current.url.standardizedFileURL let normalizedURL = current.url.standardizedFileURL
guard seen.insert(sourceIdentityKey(for: normalizedURL)).inserted else { guard seen.insert(normalizedURL.path).inserted else {
continue continue
} }
@ -1121,7 +1121,7 @@ enum JavaContentScanner {
for url in urls { for url in urls {
let standardizedURL = url.standardizedFileURL let standardizedURL = url.standardizedFileURL
guard seen.insert(sourceIdentityKey(for: standardizedURL)).inserted else { guard seen.insert(standardizedURL.path).inserted else {
continue continue
} }

View File

@ -183,9 +183,9 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
let providerID = probe?.providerID ?? LocalFolderSourceAccess().accessorIdentifier let providerID = probe?.providerID ?? LocalFolderSourceAccess().accessorIdentifier
let edition = probe?.edition ?? .bedrock let edition = probe?.edition ?? .bedrock
if let existingSourceID = existingSourceID(matching: normalizedURL) { if sources.contains(where: { $0.id == normalizedURL }) {
sourceCandidates.removeAll { sourceIdentityKey(for: $0.sourceRootURL) == sourceIdentityKey(for: normalizedURL) } sourceCandidates.removeAll { $0.sourceRootURL == normalizedURL }
updateSource(existingSourceID) { source in updateSource(normalizedURL) { source in
if source.bookmarkData == nil { if source.bookmarkData == nil {
source.bookmarkData = bookmarkData source.bookmarkData = bookmarkData
} }
@ -204,8 +204,8 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
} }
} }
} }
startScan(for: existingSourceID, mode: .fullScan) startScan(for: normalizedURL, mode: .fullScan)
return existingSourceID return normalizedURL
} }
var source = MinecraftSource( var source = MinecraftSource(
@ -224,7 +224,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
source.scanDiagnostic = warning source.scanDiagnostic = warning
} }
let sourceID = addSource(source, shouldPersist: true, shouldScan: true) let sourceID = addSource(source, shouldPersist: true, shouldScan: true)
sourceCandidates.removeAll { sourceIdentityKey(for: $0.sourceRootURL) == sourceIdentityKey(for: sourceID) } sourceCandidates.removeAll { $0.sourceRootURL == sourceID }
return sourceID return sourceID
} }
@ -232,8 +232,8 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
let normalizedURL = candidate.sourceRootURL.standardizedFileURL let normalizedURL = candidate.sourceRootURL.standardizedFileURL
let bookmarkData = securityScopedBookmarkData(for: normalizedURL) let bookmarkData = securityScopedBookmarkData(for: normalizedURL)
if let existingSourceID = existingSourceID(matching: normalizedURL) { if sources.contains(where: { $0.id == normalizedURL }) {
updateSource(existingSourceID) { source in updateSource(normalizedURL) { source in
if source.bookmarkData == nil { if source.bookmarkData == nil {
source.bookmarkData = bookmarkData source.bookmarkData = bookmarkData
} }
@ -247,9 +247,9 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
source.displayName = candidate.displayName source.displayName = candidate.displayName
source.capabilities = source.origin.defaultCapabilities source.capabilities = source.origin.defaultCapabilities
} }
removeSourceCandidates(matching: candidate, sourceID: existingSourceID) sourceCandidates.removeAll { $0.id == candidate.id || $0.sourceRootURL == normalizedURL }
startScan(for: existingSourceID, mode: .fullScan) startScan(for: normalizedURL, mode: .fullScan)
return existingSourceID return normalizedURL
} }
var source = MinecraftSource( var source = MinecraftSource(
@ -266,14 +266,14 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
source.displayName = candidate.displayName source.displayName = candidate.displayName
let sourceID = addSource(source, shouldPersist: true, shouldScan: true) let sourceID = addSource(source, shouldPersist: true, shouldScan: true)
removeSourceCandidates(matching: candidate, sourceID: sourceID) sourceCandidates.removeAll { $0.id == candidate.id || $0.sourceRootURL == sourceID }
return sourceID return sourceID
} }
@discardableResult @discardableResult
func addSource(_ source: MinecraftSource, shouldPersist: Bool = false, shouldScan: Bool = true) -> URL { func addSource(_ source: MinecraftSource, shouldPersist: Bool = false, shouldScan: Bool = true) -> URL {
if let existingSourceID = existingSourceID(matching: source.id) { if sources.contains(where: { $0.id == source.id }) {
updateSource(existingSourceID) { existingSource in updateSource(source.id) { existingSource in
existingSource.origin = source.origin existingSource.origin = source.origin
existingSource.accessDescriptor = source.accessDescriptor existingSource.accessDescriptor = source.accessDescriptor
existingSource.providerID = source.accessDescriptor.accessorIdentifier existingSource.providerID = source.accessDescriptor.accessorIdentifier
@ -300,13 +300,13 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
} }
if shouldPersist { if shouldPersist {
persistSourceIfAvailable(withID: existingSourceID(matching: source.id) ?? source.id) persistSourceIfAvailable(withID: source.id)
} }
if shouldScan { if shouldScan {
startScan(for: existingSourceID(matching: source.id) ?? source.id, mode: .fullScan) startScan(for: source.id, mode: .fullScan)
} }
return existingSourceID(matching: source.id) ?? source.id return source.id
} }
func source(withID sourceID: URL) -> MinecraftSource? { func source(withID sourceID: URL) -> MinecraftSource? {
@ -704,26 +704,8 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
private func candidateAlreadyAdded(_ candidate: SourceCandidate) -> Bool { private func candidateAlreadyAdded(_ candidate: SourceCandidate) -> Bool {
sources.contains { source in sources.contains { source in
sourceIdentityKey(for: source.id) == sourceIdentityKey(for: candidate.sourceRootURL) source.id == candidate.sourceRootURL.standardizedFileURL
|| sourceIdentityKey(for: source.folderURL) == sourceIdentityKey(for: candidate.sourceRootURL) || source.folderURL == candidate.sourceRootURL.standardizedFileURL
}
}
private func existingSourceID(matching url: URL) -> URL? {
let identity = sourceIdentityKey(for: url)
return sources.first { source in
sourceIdentityKey(for: source.id) == identity
|| sourceIdentityKey(for: source.folderURL) == identity
}?.id
}
private func removeSourceCandidates(matching candidate: SourceCandidate, sourceID: URL) {
let candidateIdentity = sourceIdentityKey(for: candidate.sourceRootURL)
let sourceIdentity = sourceIdentityKey(for: sourceID)
sourceCandidates.removeAll {
$0.id == candidate.id
|| sourceIdentityKey(for: $0.sourceRootURL) == candidateIdentity
|| sourceIdentityKey(for: $0.sourceRootURL) == sourceIdentity
} }
} }

View File

@ -214,18 +214,20 @@ enum SourceRestoration {
_ currentCollections: [CollectionSnapshot], _ currentCollections: [CollectionSnapshot],
persistedCollections: [CollectionSnapshot] persistedCollections: [CollectionSnapshot]
) -> Bool { ) -> Bool {
let currentCollectionsByName = Dictionary(grouping: currentCollections, by: \.folderName) let currentCollectionsByName = Dictionary(
.mapValues { $0.map(\.fingerprint).sorted() } uniqueKeysWithValues: currentCollections.map { ($0.folderName, $0) }
let persistedCollectionsByName = Dictionary(grouping: persistedCollections, by: \.folderName) )
.mapValues { $0.map(\.fingerprint).sorted() } let persistedCollectionsByName = Dictionary(
uniqueKeysWithValues: persistedCollections.map { ($0.folderName, $0) }
)
if currentCollectionsByName.count != persistedCollectionsByName.count { if currentCollectionsByName.count != persistedCollectionsByName.count {
return true return true
} }
for (folderName, persistedFingerprints) in persistedCollectionsByName { for (folderName, persistedCollection) in persistedCollectionsByName {
guard let currentFingerprints = currentCollectionsByName[folderName], guard let currentCollection = currentCollectionsByName[folderName],
currentFingerprints == persistedFingerprints else { currentCollection.fingerprint == persistedCollection.fingerprint else {
return true return true
} }
} }

View File

@ -29,21 +29,6 @@ struct SidebarFilter: Identifiable, Hashable {
let selection: SidebarSelection let selection: SidebarSelection
} }
private struct SidebarNode: Identifiable, Hashable {
let id: SidebarSelection
let row: SidebarNodeRow
let children: [SidebarNode]?
var selection: SidebarSelection { id }
}
private enum SidebarNodeRow: Hashable {
case source(MinecraftSource)
case filter(SidebarFilter)
case connectedDevice(ConnectedDeviceSidebarEntry)
case sourceCandidate(SourceCandidate)
}
struct SourcesSidebarView: View { struct SourcesSidebarView: View {
let sources: [MinecraftSource] let sources: [MinecraftSource]
let connectedDevices: [ConnectedDeviceSidebarEntry] let connectedDevices: [ConnectedDeviceSidebarEntry]
@ -61,25 +46,42 @@ struct SourcesSidebarView: View {
var body: some View { var body: some View {
List(selection: $selection) { List(selection: $selection) {
if !libraryNodes.isEmpty { if !sources.isEmpty {
Section { Section {
OutlineGroup(libraryNodes, children: \.children, content: sidebarNodeRow) ForEach(sources) { source in
sourceSectionRows(for: source)
}
} header: { } header: {
SidebarSourcesSectionHeaderView(title: "Libraries") SidebarSourcesSectionHeaderView(title: "Libraries")
} }
} }
if !deviceNodes.isEmpty { if !connectedDevices.isEmpty {
Section { Section {
OutlineGroup(deviceNodes, children: \.children, content: sidebarNodeRow) ForEach(connectedDevices) { entry in
connectedDeviceSectionRows(for: entry)
}
} header: { } header: {
SidebarSourcesSectionHeaderView(title: "Available Devices") SidebarSourcesSectionHeaderView(title: "Available Devices")
} }
} }
if !candidateNodes.isEmpty { if !sourceCandidates.isEmpty {
Section { Section {
OutlineGroup(candidateNodes, children: \.children, content: sidebarNodeRow) ForEach(sourceCandidates) { candidate in
SourceCandidateRow(
candidate: candidate,
onSelect: {
selection = .sourceCandidate(candidateID: candidate.id)
},
addAction: {
addCandidateSourceAction(candidate)
}
)
.tag(SidebarSelection.sourceCandidate(candidateID: candidate.id) as SidebarSelection?)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
}
} header: { } header: {
SidebarSourcesSectionHeaderView(title: "Found Sources") SidebarSourcesSectionHeaderView(title: "Found Sources")
} }
@ -116,92 +118,57 @@ struct SourcesSidebarView: View {
} }
} }
private var libraryNodes: [SidebarNode] { @ViewBuilder
sources.map { source in private func sourceSectionRows(for source: MinecraftSource) -> some View {
let childNodes = filters(source).map { filter in let sourceFilters = filters(source)
SidebarNode(
id: filter.selection, SourceHeaderRow(
row: .filter(filter), source: source,
children: nil onSelect: {
) selection = .source(sourceID: source.id)
}
)
.tag(SidebarSelection.source(sourceID: source.id) as SidebarSelection?)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 0, trailing: 0))
.contextMenu {
Button("Rescan \"\(source.displayName)\"") {
rescanSourceAction(source)
}
Divider()
Button("Remove \"\(source.displayName)\"", role: .destructive) {
removeSourceAction(source)
}
} }
return SidebarNode( ForEach(sourceFilters) { filter in
id: .source(sourceID: source.id), SidebarFilterRow(filter: filter, isIndented: true)
row: .source(source), .tag(filter.selection as SidebarSelection?)
children: childNodes.isEmpty ? nil : childNodes
)
}
}
private var deviceNodes: [SidebarNode] {
connectedDevices.map { entry in
SidebarNode(
id: .connectedDevice(deviceID: entry.id),
row: .connectedDevice(entry),
children: nil
)
}
}
private var candidateNodes: [SidebarNode] {
sourceCandidates.map { candidate in
SidebarNode(
id: .sourceCandidate(candidateID: candidate.id),
row: .sourceCandidate(candidate),
children: nil
)
} }
} }
@ViewBuilder @ViewBuilder
private func sidebarNodeRow(_ node: SidebarNode) -> some View { private func connectedDeviceSectionRows(for entry: ConnectedDeviceSidebarEntry) -> some View {
switch node.row { ConnectedDeviceRow(
case .source(let source): entry: entry,
SourceHeaderRow(source: source) onSelect: {
.tag(node.selection as SidebarSelection?) selection = .connectedDevice(deviceID: entry.id)
.listRowSeparator(.hidden) },
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) addAction: entry.hasMinecraftContainer ? {
.contextMenu { addConnectedDeviceAction(entry)
Button("Rescan \"\(source.displayName)\"") { } : nil
rescanSourceAction(source) )
} .tag(SidebarSelection.connectedDevice(deviceID: entry.id) as SidebarSelection?)
.listRowSeparator(.hidden)
Divider() .listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 0, trailing: 8))
Button("Remove \"\(source.displayName)\"", role: .destructive) {
removeSourceAction(source)
}
}
case .filter(let filter):
SidebarFilterRow(filter: filter)
.tag(node.selection as SidebarSelection?)
case .connectedDevice(let entry):
ConnectedDeviceRow(
entry: entry,
addAction: entry.hasMinecraftContainer ? {
addConnectedDeviceAction(entry)
} : nil
)
.tag(node.selection as SidebarSelection?)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 0, trailing: 8))
case .sourceCandidate(let candidate):
SourceCandidateRow(
candidate: candidate,
addAction: {
addCandidateSourceAction(candidate)
}
)
.tag(node.selection as SidebarSelection?)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
}
} }
} }
private struct SourceCandidateRow: View { private struct SourceCandidateRow: View {
let candidate: SourceCandidate let candidate: SourceCandidate
let onSelect: () -> Void
let addAction: () -> Void let addAction: () -> Void
var body: some View { var body: some View {
@ -228,6 +195,8 @@ private struct SourceCandidateRow: View {
.appMiniProminentButton() .appMiniProminentButton()
.help("Add Source") .help("Add Source")
} }
.contentShape(Rectangle())
.onTapGesture(perform: onSelect)
.padding(.vertical, 4) .padding(.vertical, 4)
} }
@ -248,6 +217,7 @@ private struct SourceCandidateRow: View {
private struct SidebarFilterRow: View { private struct SidebarFilterRow: View {
let filter: SidebarFilter let filter: SidebarFilter
let isIndented: Bool
var body: some View { var body: some View {
HStack(spacing: 10) { HStack(spacing: 10) {
@ -262,6 +232,7 @@ private struct SidebarFilterRow: View {
Text(filter.count, format: .number) Text(filter.count, format: .number)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.padding(.leading, isIndented ? 16 : 0)
} }
} }
@ -275,6 +246,7 @@ private struct SidebarSourcesSectionHeaderView: View {
private struct SourceHeaderRow: View { private struct SourceHeaderRow: View {
let source: MinecraftSource let source: MinecraftSource
let onSelect: () -> Void
var body: some View { var body: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
@ -301,8 +273,10 @@ private struct SourceHeaderRow: View {
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8) .padding(.leading, 5)
.padding(.vertical, 5) .padding(.vertical, 6)
.contentShape(Rectangle())
.onTapGesture(perform: onSelect)
} }
private var connection: DeviceConnection? { private var connection: DeviceConnection? {
@ -426,6 +400,7 @@ private struct CircularScanProgressView: View {
private struct ConnectedDeviceRow: View { private struct ConnectedDeviceRow: View {
let entry: ConnectedDeviceSidebarEntry let entry: ConnectedDeviceSidebarEntry
let onSelect: () -> Void
let addAction: (() -> Void)? let addAction: (() -> Void)?
var body: some View { var body: some View {
@ -455,6 +430,8 @@ private struct ConnectedDeviceRow: View {
} }
} }
.opacity(addAction == nil ? 0.68 : 1) .opacity(addAction == nil ? 0.68 : 1)
.contentShape(Rectangle())
.onTapGesture(perform: onSelect)
} }
private var iconName: String { private var iconName: String {

View File

@ -385,36 +385,6 @@ struct World_Manager_for_MinecraftTests {
#expect(candidates.first?.detectedKinds.contains(.mod) == true) #expect(candidates.first?.detectedKinds.contains(.mod) == true)
} }
@Test func javaProviderDeduplicatesCaseVariantSourceRoots() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let upperRootURL = workingURL.appendingPathComponent("CurseForge/Minecraft", isDirectory: true)
let lowerRootURL = workingURL.appendingPathComponent("curseforge/minecraft", isDirectory: true)
let firstInstanceURL = upperRootURL.appendingPathComponent("Instances/ExampleOne", isDirectory: true)
let secondInstanceURL = upperRootURL.appendingPathComponent("Instances/ExampleTwo", isDirectory: true)
defer { try? fileManager.removeItem(at: workingURL) }
for instanceURL in [firstInstanceURL, secondInstanceURL] {
try fileManager.createDirectory(
at: instanceURL.appendingPathComponent("mods", isDirectory: true),
withIntermediateDirectories: true
)
try Data("jar".utf8).write(to: instanceURL.appendingPathComponent("mods/ExampleMod.jar"))
}
guard fileManager.fileExists(atPath: lowerRootURL.path) else {
return
}
let candidates = JavaContentScanner.discoverSourceCandidates(
providerID: JavaLocalFolderSourceAccess().accessorIdentifier,
searchRoots: [upperRootURL, lowerRootURL]
)
#expect(candidates.count == 1)
#expect(sourceIdentityKey(for: candidates[0].sourceRootURL) == sourceIdentityKey(for: upperRootURL))
}
@Test func javaAggregateRootDiscoversNestedInstanceItems() async throws { @Test func javaAggregateRootDiscoversNestedInstanceItems() async throws {
let fileManager = FileManager.default let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
@ -443,60 +413,6 @@ struct World_Manager_for_MinecraftTests {
#expect(snapshots.map(\.folderName).contains("a/e/f/resourcepacks")) #expect(snapshots.map(\.folderName).contains("a/e/f/resourcepacks"))
} }
@Test func sourceRestorationComparesDuplicateCollectionNamesWithoutCrashing() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? fileManager.removeItem(at: workingURL) }
try fileManager.createDirectory(at: workingURL, withIntermediateDirectories: true)
let collectionSnapshots = [
CollectionSnapshot(
folderName: "mods",
modifiedDate: Date(timeIntervalSince1970: 100),
childDirectoryCount: 1,
fingerprint: "a/b/c/mods::1::100"
),
CollectionSnapshot(
folderName: "mods",
modifiedDate: Date(timeIntervalSince1970: 200),
childDirectoryCount: 2,
fingerprint: "x/y/z/mods::2::200"
)
]
var source = MinecraftSource(
folderURL: workingURL,
origin: .javaLocalFolder(bookmarkData: nil),
accessDescriptor: SourceAccessDescriptor(
accessorIdentifier: JavaLocalFolderSourceAccess().accessorIdentifier,
kind: .localFolder,
refreshStrategy: .eagerFullScan
),
availability: .available
)
source.edition = .java
source.snapshot = SourceSnapshot(
sourceID: workingURL,
rootModifiedDate: nil,
collectionSnapshots: collectionSnapshots,
itemSnapshots: []
)
#expect(SourceRestoration.needsReconcile(source) { _, _ in collectionSnapshots } == false)
#expect(
SourceRestoration.needsReconcile(source) { _, _ in
[
collectionSnapshots[0],
CollectionSnapshot(
folderName: "mods",
modifiedDate: Date(timeIntervalSince1970: 300),
childDirectoryCount: 3,
fingerprint: "x/y/z/mods::3::300"
)
]
} == true
)
}
@Test func sourceLibraryAddSourceCandidatePreservesJavaAggregateProvider() async throws { @Test func sourceLibraryAddSourceCandidatePreservesJavaAggregateProvider() async throws {
let fileManager = FileManager.default let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)