Compare commits
4 Commits
ffb6e497ec
...
da53ee4e9b
| Author | SHA1 | Date | |
|---|---|---|---|
| da53ee4e9b | |||
| 0e52db80df | |||
| f5dfec00a3 | |||
| 9ec6b905bc |
@ -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 }
|
var id: String { "\(folderName)::\(fingerprint)" }
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated struct SourceSnapshot: Hashable, Sendable, Codable {
|
nonisolated struct SourceSnapshot: Hashable, Sendable, Codable {
|
||||||
|
|||||||
@ -76,11 +76,19 @@ nonisolated struct SourceCandidate: Identifiable, Hashable, Sendable {
|
|||||||
var id: String {
|
var id: String {
|
||||||
[
|
[
|
||||||
providerID,
|
providerID,
|
||||||
sourceRootURL.standardizedFileURL.absoluteString
|
sourceIdentityKey(for: sourceRootURL)
|
||||||
].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
|
||||||
|
|||||||
@ -965,7 +965,7 @@ enum JavaContentScanner {
|
|||||||
under root: URL,
|
under root: URL,
|
||||||
providerID: PlatformProviderID
|
providerID: PlatformProviderID
|
||||||
) -> [SourceCandidate] {
|
) -> [SourceCandidate] {
|
||||||
let uniqueCandidates = Dictionary(grouping: candidates, by: \.sourceRootURL).compactMap { _, groupedCandidates in
|
let uniqueCandidates = Dictionary(grouping: candidates, by: { sourceIdentityKey(for: $0.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(normalizedURL.path).inserted else {
|
guard seen.insert(sourceIdentityKey(for: normalizedURL)).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(standardizedURL.path).inserted else {
|
guard seen.insert(sourceIdentityKey(for: standardizedURL)).inserted else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 sources.contains(where: { $0.id == normalizedURL }) {
|
if let existingSourceID = existingSourceID(matching: normalizedURL) {
|
||||||
sourceCandidates.removeAll { $0.sourceRootURL == normalizedURL }
|
sourceCandidates.removeAll { sourceIdentityKey(for: $0.sourceRootURL) == sourceIdentityKey(for: normalizedURL) }
|
||||||
updateSource(normalizedURL) { source in
|
updateSource(existingSourceID) { 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: normalizedURL, mode: .fullScan)
|
startScan(for: existingSourceID, mode: .fullScan)
|
||||||
return normalizedURL
|
return existingSourceID
|
||||||
}
|
}
|
||||||
|
|
||||||
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 { $0.sourceRootURL == sourceID }
|
sourceCandidates.removeAll { sourceIdentityKey(for: $0.sourceRootURL) == sourceIdentityKey(for: 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 sources.contains(where: { $0.id == normalizedURL }) {
|
if let existingSourceID = existingSourceID(matching: normalizedURL) {
|
||||||
updateSource(normalizedURL) { source in
|
updateSource(existingSourceID) { 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
|
||||||
}
|
}
|
||||||
sourceCandidates.removeAll { $0.id == candidate.id || $0.sourceRootURL == normalizedURL }
|
removeSourceCandidates(matching: candidate, sourceID: existingSourceID)
|
||||||
startScan(for: normalizedURL, mode: .fullScan)
|
startScan(for: existingSourceID, mode: .fullScan)
|
||||||
return normalizedURL
|
return existingSourceID
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
sourceCandidates.removeAll { $0.id == candidate.id || $0.sourceRootURL == sourceID }
|
removeSourceCandidates(matching: candidate, sourceID: 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 sources.contains(where: { $0.id == source.id }) {
|
if let existingSourceID = existingSourceID(matching: source.id) {
|
||||||
updateSource(source.id) { existingSource in
|
updateSource(existingSourceID) { 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: source.id)
|
persistSourceIfAvailable(withID: existingSourceID(matching: source.id) ?? source.id)
|
||||||
}
|
}
|
||||||
if shouldScan {
|
if shouldScan {
|
||||||
startScan(for: source.id, mode: .fullScan)
|
startScan(for: existingSourceID(matching: source.id) ?? source.id, mode: .fullScan)
|
||||||
}
|
}
|
||||||
|
|
||||||
return source.id
|
return existingSourceID(matching: source.id) ?? source.id
|
||||||
}
|
}
|
||||||
|
|
||||||
func source(withID sourceID: URL) -> MinecraftSource? {
|
func source(withID sourceID: URL) -> MinecraftSource? {
|
||||||
@ -704,8 +704,26 @@ 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
|
||||||
source.id == candidate.sourceRootURL.standardizedFileURL
|
sourceIdentityKey(for: source.id) == sourceIdentityKey(for: candidate.sourceRootURL)
|
||||||
|| source.folderURL == candidate.sourceRootURL.standardizedFileURL
|
|| sourceIdentityKey(for: source.folderURL) == sourceIdentityKey(for: candidate.sourceRootURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -214,20 +214,18 @@ enum SourceRestoration {
|
|||||||
_ currentCollections: [CollectionSnapshot],
|
_ currentCollections: [CollectionSnapshot],
|
||||||
persistedCollections: [CollectionSnapshot]
|
persistedCollections: [CollectionSnapshot]
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
let currentCollectionsByName = Dictionary(
|
let currentCollectionsByName = Dictionary(grouping: currentCollections, by: \.folderName)
|
||||||
uniqueKeysWithValues: currentCollections.map { ($0.folderName, $0) }
|
.mapValues { $0.map(\.fingerprint).sorted() }
|
||||||
)
|
let persistedCollectionsByName = Dictionary(grouping: persistedCollections, by: \.folderName)
|
||||||
let persistedCollectionsByName = Dictionary(
|
.mapValues { $0.map(\.fingerprint).sorted() }
|
||||||
uniqueKeysWithValues: persistedCollections.map { ($0.folderName, $0) }
|
|
||||||
)
|
|
||||||
|
|
||||||
if currentCollectionsByName.count != persistedCollectionsByName.count {
|
if currentCollectionsByName.count != persistedCollectionsByName.count {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
for (folderName, persistedCollection) in persistedCollectionsByName {
|
for (folderName, persistedFingerprints) in persistedCollectionsByName {
|
||||||
guard let currentCollection = currentCollectionsByName[folderName],
|
guard let currentFingerprints = currentCollectionsByName[folderName],
|
||||||
currentCollection.fingerprint == persistedCollection.fingerprint else {
|
currentFingerprints == persistedFingerprints else {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,21 @@ 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]
|
||||||
@ -46,42 +61,25 @@ struct SourcesSidebarView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List(selection: $selection) {
|
List(selection: $selection) {
|
||||||
if !sources.isEmpty {
|
if !libraryNodes.isEmpty {
|
||||||
Section {
|
Section {
|
||||||
ForEach(sources) { source in
|
OutlineGroup(libraryNodes, children: \.children, content: sidebarNodeRow)
|
||||||
sourceSectionRows(for: source)
|
|
||||||
}
|
|
||||||
} header: {
|
} header: {
|
||||||
SidebarSourcesSectionHeaderView(title: "Libraries")
|
SidebarSourcesSectionHeaderView(title: "Libraries")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !connectedDevices.isEmpty {
|
if !deviceNodes.isEmpty {
|
||||||
Section {
|
Section {
|
||||||
ForEach(connectedDevices) { entry in
|
OutlineGroup(deviceNodes, children: \.children, content: sidebarNodeRow)
|
||||||
connectedDeviceSectionRows(for: entry)
|
|
||||||
}
|
|
||||||
} header: {
|
} header: {
|
||||||
SidebarSourcesSectionHeaderView(title: "Available Devices")
|
SidebarSourcesSectionHeaderView(title: "Available Devices")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !sourceCandidates.isEmpty {
|
if !candidateNodes.isEmpty {
|
||||||
Section {
|
Section {
|
||||||
ForEach(sourceCandidates) { candidate in
|
OutlineGroup(candidateNodes, children: \.children, content: sidebarNodeRow)
|
||||||
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")
|
||||||
}
|
}
|
||||||
@ -118,19 +116,52 @@ struct SourcesSidebarView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
private var libraryNodes: [SidebarNode] {
|
||||||
private func sourceSectionRows(for source: MinecraftSource) -> some View {
|
sources.map { source in
|
||||||
let sourceFilters = filters(source)
|
let childNodes = filters(source).map { filter in
|
||||||
|
SidebarNode(
|
||||||
SourceHeaderRow(
|
id: filter.selection,
|
||||||
source: source,
|
row: .filter(filter),
|
||||||
onSelect: {
|
children: nil
|
||||||
selection = .source(sourceID: source.id)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
.tag(SidebarSelection.source(sourceID: source.id) as SidebarSelection?)
|
}
|
||||||
|
|
||||||
|
return SidebarNode(
|
||||||
|
id: .source(sourceID: source.id),
|
||||||
|
row: .source(source),
|
||||||
|
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
|
||||||
|
private func sidebarNodeRow(_ node: SidebarNode) -> some View {
|
||||||
|
switch node.row {
|
||||||
|
case .source(let source):
|
||||||
|
SourceHeaderRow(source: source)
|
||||||
|
.tag(node.selection as SidebarSelection?)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 0, trailing: 0))
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button("Rescan \"\(source.displayName)\"") {
|
Button("Rescan \"\(source.displayName)\"") {
|
||||||
rescanSourceAction(source)
|
rescanSourceAction(source)
|
||||||
@ -142,33 +173,35 @@ struct SourcesSidebarView: View {
|
|||||||
removeSourceAction(source)
|
removeSourceAction(source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case .filter(let filter):
|
||||||
ForEach(sourceFilters) { filter in
|
SidebarFilterRow(filter: filter)
|
||||||
SidebarFilterRow(filter: filter, isIndented: true)
|
.tag(node.selection as SidebarSelection?)
|
||||||
.tag(filter.selection as SidebarSelection?)
|
case .connectedDevice(let entry):
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func connectedDeviceSectionRows(for entry: ConnectedDeviceSidebarEntry) -> some View {
|
|
||||||
ConnectedDeviceRow(
|
ConnectedDeviceRow(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
onSelect: {
|
|
||||||
selection = .connectedDevice(deviceID: entry.id)
|
|
||||||
},
|
|
||||||
addAction: entry.hasMinecraftContainer ? {
|
addAction: entry.hasMinecraftContainer ? {
|
||||||
addConnectedDeviceAction(entry)
|
addConnectedDeviceAction(entry)
|
||||||
} : nil
|
} : nil
|
||||||
)
|
)
|
||||||
.tag(SidebarSelection.connectedDevice(deviceID: entry.id) as SidebarSelection?)
|
.tag(node.selection as SidebarSelection?)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 0, trailing: 8))
|
.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 {
|
||||||
@ -195,8 +228,6 @@ private struct SourceCandidateRow: View {
|
|||||||
.appMiniProminentButton()
|
.appMiniProminentButton()
|
||||||
.help("Add Source")
|
.help("Add Source")
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture(perform: onSelect)
|
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -217,7 +248,6 @@ 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) {
|
||||||
@ -232,7 +262,6 @@ private struct SidebarFilterRow: View {
|
|||||||
Text(filter.count, format: .number)
|
Text(filter.count, format: .number)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.padding(.leading, isIndented ? 16 : 0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -246,7 +275,6 @@ 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) {
|
||||||
@ -273,10 +301,8 @@ private struct SourceHeaderRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.leading, 5)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 5)
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture(perform: onSelect)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var connection: DeviceConnection? {
|
private var connection: DeviceConnection? {
|
||||||
@ -400,7 +426,6 @@ 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 {
|
||||||
@ -430,8 +455,6 @@ 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 {
|
||||||
|
|||||||
@ -385,6 +385,36 @@ 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)
|
||||||
@ -413,6 +443,60 @@ 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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user