// MIT License - Copyright (c) 2025 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Visuals.UIToolkit { using System; using System.Collections.Generic; using System.IO; using UnityEngine; using UnityEngine.UIElements; using WallstopStudios.UnityHelpers.Core.Helper; /// /// Multi-file selector overlay built with UI Toolkit. /// /// /// Purpose /// - Lets users quickly navigate folders (constrained to the Unity project root/Assets) and select multiple files. /// - Optimized for large directories via virtualization and pooled buffers to keep the Editor responsive. /// /// Highlights /// - Virtualized ListView with folder-first ordering and live search filter. /// - Zero-allocation streaming directory enumeration with pooled lists (see Buffers<T>). /// - HashSet-backed selection with Select All / Clear / Invert helpers. /// - Clickable breadcrumbs and double-click directory navigation. /// - Smart persistence of last directory and search per logical scope or provided key. /// /// When to use /// - Any Editor tooling requiring an in-UI multi-file picker where the native file dialog is too limited or needs custom selection semantics. /// - Scenarios where you need to keep selections across multiple navigations and filter by file extension. /// /// Usage /// /// // Typical usage inside an EditorWindow /// var selector = new MultiFileSelectorElement( /// initialPath: "Assets/Animations", /// filterExtensions: new[] { ".anim" }, /// persistenceKey: "AnimationViewer" /// ); /// selector.OnFilesSelectedReadOnly += files => Debug.Log($"Selected {files.Count} files"); /// selector.OnCancelled += () => Debug.Log("Selection cancelled"); /// rootVisualElement.Add(selector); /// // To hide: selector.parent.Remove(selector); /// public sealed class MultiFileSelectorElement : VisualElement { private const string DefaultRootPath = "Assets"; private const string PrefKey_LastDir = "WallstopStudios.MultiFileSelector.lastDirectory"; private const string PrefKey_LastSearch = "WallstopStudios.MultiFileSelector.lastSearch"; private const string PrefKey_LastUsed = "WallstopStudios.MultiFileSelector.lastUsed"; private const string PrefKey_ScopesIndex = "WallstopStudios.MultiFileSelector.scopes"; /// /// Invoked when the user confirms selection. Provides absolute file system paths. /// [Obsolete("Use OnFilesSelectedReadOnly for allocation-free callbacks.")] public event Action> OnFilesSelected; /// /// Invoked when the user confirms selection without allocating a copy of the paths. /// public event Action> OnFilesSelectedReadOnly; /// /// Invoked when the user cancels the dialog without confirming. /// public event Action OnCancelled; private readonly TextField _pathField; private readonly Button _upButton; private readonly ListView _listView; private readonly HashSet _filterExtensions; private readonly HashSet _selectedSet = new(StringComparer.OrdinalIgnoreCase); private readonly List _items = new(); private readonly string _projectRootPath; private readonly string _assetsFullPath; private readonly Button _confirmButton; private readonly TextField _searchField; private readonly VisualElement _breadcrumbBar; private string _currentDirectory; private string _searchText = string.Empty; private readonly string _prefsScope; private readonly struct Item { public readonly bool isDirectory; public readonly string fullPath; public readonly string name; public Item(bool isDirectory, string fullPath, string name) { this.isDirectory = isDirectory; this.fullPath = fullPath; this.name = name; } } /// /// Creates a new multi-file selector. /// /// Project-relative path (e.g., "Assets/..."). If null or invalid, defaults to Assets. /// Allowed file extensions (with or without leading dot). Empty/null means all files. /// Optional key to persist last directory and search across sessions for this selector. If null or empty, no persistence occurs. public MultiFileSelectorElement( string initialPath, string[] filterExtensions, string persistenceKey = null ) { _projectRootPath = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); _assetsFullPath = Path.GetFullPath(Application.dataPath); // Normalize filters: ensure leading '.' and OrdinalIgnoreCase if (filterExtensions is { Length: > 0 }) { _filterExtensions = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (string ext in filterExtensions) { if (string.IsNullOrWhiteSpace(ext)) { continue; } string e = ext.StartsWith(".", StringComparison.Ordinal) ? ext : "." + ext; _filterExtensions.Add(e); } } else { _filterExtensions = new HashSet(StringComparer.OrdinalIgnoreCase); } _prefsScope = BuildScope(initialPath, _filterExtensions, persistenceKey); if (!string.IsNullOrEmpty(_prefsScope)) { RegisterScope(_prefsScope); } style.position = Position.Absolute; style.left = style.top = style.right = style.bottom = 0; style.backgroundColor = new StyleColor(new Color(0.1f, 0.1f, 0.1f, 0.9f)); style.paddingLeft = style.paddingRight = style.paddingTop = style.paddingBottom = 20; style.alignItems = Align.Center; style.justifyContent = Justify.Center; VisualElement contentBox = new() { style = { width = new Length(80, LengthUnit.Percent), height = new Length(80, LengthUnit.Percent), maxWidth = 700, maxHeight = 500, backgroundColor = new StyleColor(Color.gray), paddingLeft = 10, paddingRight = 10, paddingTop = 10, paddingBottom = 10, flexDirection = FlexDirection.Column, borderTopLeftRadius = 5, borderTopRightRadius = 5, borderBottomLeftRadius = 5, borderBottomRightRadius = 5, }, }; Add(contentBox); VisualElement headerControls = new() { style = { flexDirection = FlexDirection.Row, marginBottom = 5, alignItems = Align.Center, }, }; Button assetsFolderButton = new(() => NavigateTo(DefaultRootPath)) { text = "Assets", style = { width = 60, marginRight = 5 }, }; _upButton = new Button(NavigateUp) { text = "Up", style = { width = 40, marginRight = 5 }, }; _pathField = new TextField(null) { isReadOnly = true, style = { flexGrow = 1, flexShrink = 1, marginRight = 5, }, }; _searchField = new TextField(null) { isReadOnly = false, style = { width = 150, marginRight = 5 }, tooltip = "Filter by name", }; _searchField.RegisterValueChangedCallback(evt => { _searchText = evt.newValue ?? string.Empty; PersistString(ScopedKey(PrefKey_LastSearch), _searchText); UpdateLastUsedNow(); PopulateFileList(); }); // Initialize search from persisted value string persistedSearch = LoadString(ScopedKey(PrefKey_LastSearch), string.Empty); if (!string.IsNullOrEmpty(persistedSearch)) { _searchText = persistedSearch; _searchField.SetValueWithoutNotify(persistedSearch); } headerControls.Add(assetsFolderButton); headerControls.Add(_upButton); headerControls.Add(_pathField); headerControls.Add(_searchField); #if UNITY_EDITOR Button openInExplorer = new(OpenInExplorer) { text = "Open", style = { width = 60 } }; headerControls.Add(openInExplorer); #endif contentBox.Add(headerControls); _breadcrumbBar = new VisualElement { style = { flexDirection = FlexDirection.Row, marginBottom = 5, flexWrap = Wrap.Wrap, alignItems = Align.Center, }, }; contentBox.Add(_breadcrumbBar); _listView = new ListView { selectionType = SelectionType.None, virtualizationMethod = CollectionVirtualizationMethod.DynamicHeight, style = { flexGrow = 1, borderTopWidth = 1, borderBottomWidth = 1, borderLeftWidth = 1, borderRightWidth = 1, borderBottomColor = Color.black, borderTopColor = Color.black, borderLeftColor = Color.black, borderRightColor = Color.black, marginBottom = 5, backgroundColor = Color.white * 0.15f, }, makeItem = MakeRow, bindItem = BindRow, itemsSource = _items, }; contentBox.Add(_listView); VisualElement footerControls = new() { style = { flexDirection = FlexDirection.Row, justifyContent = Justify.FlexEnd, marginTop = 5, }, }; Button cancelButton = new(() => OnCancelled?.Invoke()) { text = "Cancel", style = { marginRight = 10 }, }; Button selectAllButton = new(SelectAllInView) { text = "Select All", style = { marginRight = 5 }, }; Button clearButton = new(ClearSelectionInView) { text = "Clear", style = { marginRight = 5 }, }; Button invertButton = new(InvertSelectionInView) { text = "Invert", style = { marginRight = 10 }, }; _confirmButton = new Button(ConfirmSelection) { text = "Add Selected (0)" }; footerControls.Add(selectAllButton); footerControls.Add(clearButton); footerControls.Add(invertButton); footerControls.Add(cancelButton); footerControls.Add(_confirmButton); contentBox.Add(footerControls); string validInitialPath = initialPath; // Prefer persisted directory when scope is present string persistedStart = LoadString(ScopedKey(PrefKey_LastDir), null); if (!string.IsNullOrEmpty(persistedStart)) { validInitialPath = persistedStart; } else if (string.IsNullOrEmpty(validInitialPath)) { validInitialPath = DefaultRootPath; } if ( string.IsNullOrEmpty(validInitialPath) || !Directory.Exists(Path.Combine(Application.dataPath, "..", validInitialPath)) ) { validInitialPath = DefaultRootPath; } NavigateTo(validInitialPath); } internal void NavigateTo(string path) { string fullPath = Path.GetFullPath(Path.Combine(Application.dataPath, "..", path)); if (!fullPath.StartsWith(_projectRootPath, StringComparison.OrdinalIgnoreCase)) { fullPath = _projectRootPath; } if ( string.Equals(DefaultRootPath, "Assets", StringComparison.Ordinal) && !fullPath.StartsWith(_assetsFullPath, StringComparison.OrdinalIgnoreCase) && fullPath.StartsWith(_projectRootPath) ) { fullPath = _assetsFullPath; } if (!Directory.Exists(fullPath)) { Debug.LogWarning($"Directory does not exist: {fullPath}. Resetting to Assets."); _currentDirectory = _assetsFullPath; } else { _currentDirectory = fullPath; } if (_currentDirectory.Equals(_projectRootPath, StringComparison.OrdinalIgnoreCase)) { _pathField.SetValueWithoutNotify("Project Root"); } else if ( _currentDirectory.StartsWith(_projectRootPath, StringComparison.OrdinalIgnoreCase) ) { _pathField.SetValueWithoutNotify( _currentDirectory.Substring(_projectRootPath.Length + 1).SanitizePath() ); } else { _pathField.SetValueWithoutNotify(_currentDirectory.SanitizePath()); } PopulateFileList(); DirectoryInfo parentDirectory = Directory.GetParent(_currentDirectory); _upButton.SetEnabled( !_currentDirectory.Equals(_assetsFullPath, StringComparison.OrdinalIgnoreCase) && parentDirectory != null && parentDirectory.FullName.Length >= _projectRootPath.Length ); BuildBreadcrumbs(); // Persist relative path (scoped) if persistence is enabled string rel = _currentDirectory.StartsWith( _projectRootPath, StringComparison.OrdinalIgnoreCase ) ? _currentDirectory.Substring(_projectRootPath.Length + 1) : _currentDirectory; string dirKey = ScopedKey(PrefKey_LastDir); if (!string.IsNullOrEmpty(rel) && !string.IsNullOrEmpty(dirKey)) { PersistString(dirKey, rel.SanitizePath()); UpdateLastUsedNow(); } } private void NavigateUp() { if (string.IsNullOrEmpty(_currentDirectory)) { return; } DirectoryInfo directoryParent = Directory.GetParent(_currentDirectory); if (directoryParent != null) { if ( directoryParent.FullName.Length < _projectRootPath.Length || _currentDirectory.Equals(_assetsFullPath, StringComparison.OrdinalIgnoreCase) ) { Debug.Log("Cannot navigate above Assets folder."); NavigateTo(DefaultRootPath); return; } NavigateTo(directoryParent.FullName.Substring(_projectRootPath.Length + 1)); } } private void PopulateFileList() { _items.Clear(); try { // Collect and sort directories using ( Utils.PooledResource> dirLease = Utils.Buffers.List.Get( out List dirs ) ) { try { foreach (string d in Directory.EnumerateDirectories(_currentDirectory)) { string name = Path.GetFileName(d); if ( !string.IsNullOrEmpty(_searchText) && name?.IndexOf(_searchText, StringComparison.OrdinalIgnoreCase) < 0 ) { continue; } dirs.Add(d); } } catch (Exception) { // Ignore individual access issues during enumeration } dirs.Sort(StringComparer.OrdinalIgnoreCase); foreach (string d in dirs) { string name = Path.GetFileName(d); _items.Add(new Item(true, d, name)); } } // Collect and sort files using ( Utils.PooledResource> fileLease = Utils.Buffers.List.Get( out List files ) ) { try { foreach (string f in Directory.EnumerateFiles(_currentDirectory)) { string ext = Path.GetExtension(f); if (_filterExtensions.Count > 0 && !_filterExtensions.Contains(ext)) { continue; } string name = Path.GetFileName(f); if ( !string.IsNullOrEmpty(_searchText) && name?.IndexOf(_searchText, StringComparison.OrdinalIgnoreCase) < 0 ) { continue; } files.Add(f); } } catch (Exception) { // Ignore individual access issues during enumeration } files.Sort(StringComparer.OrdinalIgnoreCase); foreach (string f in files) { string name = Path.GetFileName(f); _items.Add(new Item(false, f, name)); } } } catch (Exception e) { Debug.LogError($"Error accessing path {_currentDirectory}: {e.Message}"); } _listView.RefreshItems(); UpdateConfirmButtonText(); } private void ConfirmSelection() { OnFilesSelectedReadOnly?.Invoke(_selectedSet); if (OnFilesSelected != null) { OnFilesSelected.Invoke(new List(_selectedSet)); } } /// /// Clears selection and navigates to a new starting directory. /// /// Project-relative path (e.g., "Assets/..."). public void ResetAndShow(string newInitialPath) { _selectedSet.Clear(); NavigateTo(newInitialPath); } private VisualElement MakeRow() { VisualElement row = new() { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center, marginBottom = 1, }, }; Toggle toggle = new() { style = { marginRight = 5 } }; Label label = new() { style = { unityTextAlign = TextAnchor.MiddleLeft, flexGrow = 1 }, }; row.Add(toggle); row.Add(label); return row; } private void BindRow(VisualElement row, int index) { if (index < 0 || index >= _items.Count) { return; } Item item = _items[index]; Toggle toggle = row.ElementAt(0) as Toggle; Label label = row.ElementAt(1) as Label; if (item.isDirectory) { // Directory rows: no toggle, clickable to navigate toggle.SetEnabled(false); toggle.value = false; label.text = $"📁 {item.name}"; label.style.opacity = 1f; label.tooltip = item.fullPath.SanitizePath(); // Remove potential previous bindings row.UnregisterCallback(OnDirectoryClick); label.UnregisterCallback(OnLabelClick); label.userData = null; row.RegisterCallback(OnDirectoryClick, TrickleDown.NoTrickleDown); row.userData = item.fullPath; } else { // File rows: toggleable selection row.UnregisterCallback(OnDirectoryClick); row.userData = null; toggle.SetEnabled(true); bool isSelected = _selectedSet.Contains(item.fullPath); toggle.SetValueWithoutNotify(isSelected); toggle.tooltip = item.fullPath.SanitizePath(); label.text = item.name; label.style.opacity = 1f; toggle.UnregisterValueChangedCallback(OnToggleChanged); toggle.RegisterValueChangedCallback(OnToggleChanged); toggle.userData = item.fullPath; // Clicking the label toggles selection label.UnregisterCallback(OnLabelClick); label.userData = toggle; label.RegisterCallback(OnLabelClick); } } private void OnDirectoryClick(ClickEvent evt) { if (evt.button != 0 || evt.clickCount < 2) { return; } string path = evt.currentTarget is VisualElement ve ? ve.userData as string : null; if (string.IsNullOrEmpty(path)) { return; } // Navigate on single or double click string relative = path; if (relative.StartsWith(_projectRootPath, StringComparison.OrdinalIgnoreCase)) { relative = relative.Substring(_projectRootPath.Length + 1); } NavigateTo(relative); } private void OnToggleChanged(ChangeEvent evt) { string filePath = (evt.target as Toggle)?.userData as string; if (string.IsNullOrEmpty(filePath)) { return; } if (evt.newValue) { _selectedSet.Add(filePath); } else { _selectedSet.Remove(filePath); } UpdateConfirmButtonText(); } private void UpdateConfirmButtonText() { _confirmButton.text = $"Add Selected ({_selectedSet.Count})"; } /// /// Returns the current visible entry names (folders and files) for diagnostics and tests. /// Folder names are returned without UI decorations. /// public IReadOnlyList DebugGetVisibleEntryNames() { using Utils.PooledResource> lease = Utils.Buffers.List.Get( out List list ); for (int i = 0; i < _items.Count; i++) { list.Add(_items[i].name); } return list.ToArray(); } /// /// Returns the currently selected file paths for diagnostics and tests. /// public IReadOnlyCollection DebugGetSelectedFilePaths() { return _selectedSet; } private void BuildBreadcrumbs() { _breadcrumbBar.Clear(); string display; string rel; if (_currentDirectory.StartsWith(_projectRootPath, StringComparison.OrdinalIgnoreCase)) { rel = _currentDirectory.Substring(_projectRootPath.Length).TrimStart('/', '\\'); } else { rel = _currentDirectory; } AddCrumb("Assets", "Assets"); if (!string.IsNullOrEmpty(rel)) { // If rel starts with Assets, strip it for subsequent segments display = rel.SanitizePath(); if (display.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) { display = display.Substring("Assets/".Length); } string cumulative = "Assets"; string[] parts = display.Split( new[] { '/' }, StringSplitOptions.RemoveEmptyEntries ); foreach (string part in parts) { cumulative = cumulative + "/" + part; AddCrumb(part, cumulative); } } // Remove trailing separator if exists if ( _breadcrumbBar.childCount > 0 && _breadcrumbBar.ElementAt(_breadcrumbBar.childCount - 1) is Label ) { _breadcrumbBar.RemoveAt(_breadcrumbBar.childCount - 1); } return; // Start with root segment void AddCrumb(string title, string navigateTo) { Button b = new(() => NavigateTo(navigateTo)) { text = title, style = { marginRight = 4 }, }; _breadcrumbBar.Add(b); Label sep = new("/") { style = { marginRight = 4 } }; _breadcrumbBar.Add(sep); } } private void OnLabelClick(PointerDownEvent evt) { if (evt.button != 0) { return; } if ((evt.currentTarget as VisualElement)?.userData is Toggle toggle) { toggle.value = !toggle.value; } } internal void SelectAllInView() { for (int i = 0; i < _items.Count; i++) { Item it = _items[i]; if (!it.isDirectory) { _selectedSet.Add(it.fullPath); } } _listView.RefreshItems(); UpdateConfirmButtonText(); } internal void ClearSelectionInView() { for (int i = 0; i < _items.Count; i++) { Item it = _items[i]; if (!it.isDirectory) { _selectedSet.Remove(it.fullPath); } } _listView.RefreshItems(); UpdateConfirmButtonText(); } internal void InvertSelectionInView() { for (int i = 0; i < _items.Count; i++) { Item it = _items[i]; if (!it.isDirectory) { if (_selectedSet.Contains(it.fullPath)) { _selectedSet.Remove(it.fullPath); } else { _selectedSet.Add(it.fullPath); } } } _listView.RefreshItems(); UpdateConfirmButtonText(); } #if UNITY_EDITOR private void OpenInExplorer() { try { UnityEditor.EditorUtility.RevealInFinder(_currentDirectory); } catch (Exception e) { Debug.LogError($"Failed to open in Explorer/Finder: {e.Message}"); } } #endif private static string LoadString(string key, string defaultValue) { if (string.IsNullOrEmpty(key)) { return defaultValue; } #if UNITY_EDITOR return UnityEditor.EditorPrefs.GetString(key, defaultValue); #else return PlayerPrefs.GetString(key, defaultValue); #endif } private static void PersistString(string key, string value) { if (string.IsNullOrEmpty(key)) { return; } #if UNITY_EDITOR UnityEditor.EditorPrefs.SetString(key, value ?? string.Empty); #else PlayerPrefs.SetString(key, value ?? string.Empty); PlayerPrefs.Save(); #endif } private static void RegisterScope(string scope) { if (string.IsNullOrEmpty(scope)) { return; } string index = LoadString(PrefKey_ScopesIndex, string.Empty); if (string.IsNullOrEmpty(index)) { PersistString(PrefKey_ScopesIndex, scope); return; } string[] parts = index.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries); for (int i = 0; i < parts.Length; i++) { if (string.Equals(parts[i], scope, StringComparison.Ordinal)) { return; } } PersistString(PrefKey_ScopesIndex, index + ";" + scope); } private void UpdateLastUsedNow() { string key = ScopedKey(PrefKey_LastUsed); if (!string.IsNullOrEmpty(key)) { PersistString(key, DateTime.UtcNow.Ticks.ToString()); RegisterScope(_prefsScope); } } /// /// Removes persisted entries for scopes that have not been used within the provided time window. /// Only affects entries written by this element and maintains a scope index for safe deletion. /// /// Scopes with last-used older than now - maxAge are cleaned. public static void CleanupStalePersistenceEntries(TimeSpan maxAge) { string index = LoadString(PrefKey_ScopesIndex, string.Empty); if (string.IsNullOrEmpty(index)) { return; } string[] scopes = index.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries); DateTime cutoff = DateTime.UtcNow - maxAge; bool changed = false; using Utils.PooledResource> lease = Utils.Buffers.List.Get( out List survivors ); for (int i = 0; i < scopes.Length; i++) { string scope = scopes[i]; string lastUsedStr = LoadString(PrefKey_LastUsed + "." + scope, string.Empty); bool stale = true; if ( !string.IsNullOrEmpty(lastUsedStr) && long.TryParse(lastUsedStr, out long ticks) ) { DateTime last = new(ticks, DateTimeKind.Utc); stale = last < cutoff; } if (stale) { #if UNITY_EDITOR UnityEditor.EditorPrefs.DeleteKey(PrefKey_LastUsed + "." + scope); UnityEditor.EditorPrefs.DeleteKey(PrefKey_LastSearch + "." + scope); UnityEditor.EditorPrefs.DeleteKey(PrefKey_LastDir + "." + scope); #else PlayerPrefs.DeleteKey(PrefKey_LastUsed + "." + scope); PlayerPrefs.DeleteKey(PrefKey_LastSearch + "." + scope); PlayerPrefs.DeleteKey(PrefKey_LastDir + "." + scope); PlayerPrefs.Save(); #endif changed = true; } else { survivors.Add(scope); } } if (changed) { string rebuilt = string.Join(";", survivors); PersistString(PrefKey_ScopesIndex, rebuilt); } } private string ScopedKey(string baseKey) { if (string.IsNullOrEmpty(baseKey) || string.IsNullOrEmpty(_prefsScope)) { return null; } return baseKey + "." + _prefsScope; } private static string BuildScope(string initialPath, HashSet extensions, string key) { // Only enable persistence when an explicit key is provided by the caller. if (!string.IsNullOrWhiteSpace(key)) { return SanitizeKey(key); } return null; } private static string SanitizeKey(string key) { char[] buffer = new char[key.Length]; int n = 0; for (int i = 0; i < key.Length; i++) { char c = key[i]; if (char.IsLetterOrDigit(c) || c == '_' || c == '-' || c == '.') { buffer[n++] = c; } else if (char.IsWhiteSpace(c) || c == '|' || c == '/' || c == '\\' || c == ',') { buffer[n++] = '_'; } } return new string(buffer, 0, n).Trim('_'); } } }