diff --git a/Sources/UntoldEditor/Editor/AssetBrowser/AssetBrowserView+Contents.swift b/Sources/UntoldEditor/Editor/AssetBrowser/AssetBrowserView+Contents.swift index 4b1570b..fd05f22 100644 --- a/Sources/UntoldEditor/Editor/AssetBrowser/AssetBrowserView+Contents.swift +++ b/Sources/UntoldEditor/Editor/AssetBrowser/AssetBrowserView+Contents.swift @@ -156,6 +156,17 @@ extension AssetBrowserView { } } } + if let sourceFolder = asset.sourceFolder { + Button { + if selectedDirURL != nil { + selectedDirURL = sourceFolder + } else if selectedCategory != AssetCategory.scripts.rawValue { + folderPathStack.append(sourceFolder) + } + } label: { + Label("Reveal Source Folder", systemImage: "folder") + } + } Button(role: .destructive) { pendingDeleteAsset = asset showDeleteConfirmation = true @@ -183,11 +194,25 @@ extension AssetBrowserView { @ViewBuilder func folderContentsView(for folder: URL, selectionManager _: SelectionManager) -> some View { if let contents = try? FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) { + // A folder that shares its stem with a sibling runtime asset is that + // asset's cook source (see `runtimeExportLocation`): it's not listed on + // its own, only reachable via that asset's "Reveal Source Folder" menu. + let sourceFolderByStem: [String: URL] = contents.reduce(into: [:]) { result, item in + guard allRuntimeAssetExtensions.contains(item.pathExtension.lowercased()) else { return } + let stem = item.deletingPathExtension().lastPathComponent + let candidateFolder = folder.appendingPathComponent(stem, isDirectory: true) + var isDir: ObjCBool = false + if FileManager.default.fileExists(atPath: candidateFolder.path, isDirectory: &isDir), isDir.boolValue { + result[stem] = candidateFolder + } + } + let items = contents.compactMap { item -> Asset? in var isDir: ObjCBool = false if FileManager.default.fileExists(atPath: item.path, isDirectory: &isDir) { let itemCategory = selectedCategory ?? inferCategory(for: item)?.rawValue ?? "" if isDir.boolValue { + guard sourceFolderByStem[item.lastPathComponent] == nil else { return nil } return Asset(name: item.lastPathComponent, category: itemCategory, path: item, isFolder: true) } else { // Imported sources (USD, .blend) are listed too: they stay in the @@ -200,9 +225,14 @@ extension AssetBrowserView { .union(sourceAssetExtensions) guard allowedExtensions.contains(itemExtension) else { return nil } + let sourceFolder = allRuntimeAssetExtensions.contains(itemExtension) + ? sourceFolderByStem[item.deletingPathExtension().lastPathComponent] + : nil + return Asset(name: item.lastPathComponent, category: itemCategory, - path: item) + path: item, + sourceFolder: sourceFolder) } } return nil diff --git a/Sources/UntoldEditor/Editor/AssetBrowser/AssetBrowserView+Loading.swift b/Sources/UntoldEditor/Editor/AssetBrowser/AssetBrowserView+Loading.swift index 24b7e32..61a7a95 100644 --- a/Sources/UntoldEditor/Editor/AssetBrowser/AssetBrowserView+Loading.swift +++ b/Sources/UntoldEditor/Editor/AssetBrowser/AssetBrowserView+Loading.swift @@ -50,10 +50,24 @@ extension AssetBrowserView { includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] ) { + // A folder that shares its stem with a sibling runtime asset is that + // asset's cook source (see `runtimeExportLocation`): it's not listed on + // its own, only reachable via that asset's "Reveal Source Folder" menu. + let sourceFolderByStem: [String: URL] = contents.reduce(into: [:]) { result, item in + guard allRuntimeAssetExtensions.contains(item.pathExtension.lowercased()) else { return } + let stem = item.deletingPathExtension().lastPathComponent + let candidateFolder = categoryPath.appendingPathComponent(stem, isDirectory: true) + var isDir: ObjCBool = false + if FileManager.default.fileExists(atPath: candidateFolder.path, isDirectory: &isDir), isDir.boolValue { + result[stem] = candidateFolder + } + } + for item in contents { var isDir: ObjCBool = false if FileManager.default.fileExists(atPath: item.path, isDirectory: &isDir) { if isDir.boolValue { + guard sourceFolderByStem[item.lastPathComponent] == nil else { continue } // It’s a folder — valid for all categories categoryAssets.append(Asset(name: item.lastPathComponent, category: category.rawValue, @@ -81,11 +95,16 @@ extension AssetBrowserView { path: item, isFolder: false)) } else if category == .models || category == .animations { - if runtimeAssetExtensions(for: category).contains(item.pathExtension.lowercased()) || sourceAssetExtensions.contains(item.pathExtension.lowercased()) { + let itemExtension = item.pathExtension.lowercased() + if runtimeAssetExtensions(for: category).contains(itemExtension) || sourceAssetExtensions.contains(itemExtension) { + let sourceFolder = allRuntimeAssetExtensions.contains(itemExtension) + ? sourceFolderByStem[item.deletingPathExtension().lastPathComponent] + : nil categoryAssets.append(Asset(name: item.lastPathComponent, category: category.rawValue, path: item, - isFolder: false)) + isFolder: false, + sourceFolder: sourceFolder)) } } else if category == .scripts { // Not used anymore due to flat listing, but keep for safety (won’t execute due to continue above) diff --git a/Sources/UntoldEditor/Editor/AssetBrowser/AssetModel.swift b/Sources/UntoldEditor/Editor/AssetBrowser/AssetModel.swift index e8f43a3..da65f83 100644 --- a/Sources/UntoldEditor/Editor/AssetBrowser/AssetModel.swift +++ b/Sources/UntoldEditor/Editor/AssetBrowser/AssetModel.swift @@ -17,6 +17,10 @@ public struct Asset: Identifiable { public let category: String public let path: URL var isFolder: Bool = false + /// The source/working folder this runtime asset was cooked from (same stem, + /// e.g. `bl.untoldpack` <- `bl/`), if one exists. Hidden from the listing in + /// its own right; reachable via the asset's "Reveal Source Folder" context menu. + var sourceFolder: URL? } enum AssetCategory: String, CaseIterable {