using System.Collections.Specialized; using Ardalis.GuardClauses; using Godot; using ObservableCollections; using R3; using SharpIDE.Application; using SharpIDE.Application.Features.Analysis; using SharpIDE.Application.Features.NavigationHistory; using SharpIDE.Application.Features.SolutionDiscovery; using SharpIDE.Application.Features.SolutionDiscovery.VsPersistence; using SharpIDE.Godot.Features.Common; using SharpIDE.Godot.Features.Git; using SharpIDE.Godot.Features.Problems; namespace SharpIDE.Godot.Features.SolutionExplorer; public partial class SolutionExplorerPanel : MarginContainer { [Export] public Texture2D CsharpFileIcon { get; set; } = null!; [Export] public Texture2D FolderIcon { get; set; } = null!; [Export] public Texture2D SlnFolderIcon { get; set; } = null!; [Export] public Texture2D CsprojIcon { get; set; } = null!; [Export] public Texture2D SlnIcon { get; set; } = null!; public SharpIdeSolutionModel SolutionModel { get; set; } = null!; private PanelContainer _panelContainer = null!; private Tree _tree = null!; private TreeItem _rootItem = null!; private enum ClipboardOperation { Cut, Copy } private (List, ClipboardOperation)? _itemsOnClipboard; public override void _Ready() { _panelContainer = GetNode("PanelContainer"); _tree = GetNode("%Tree"); _tree.ItemMouseSelected += TreeOnItemMouseSelected; // Remove the tree from the scene tree for now, we will add it back when we bind to a solution _panelContainer.RemoveChild(_tree); GodotGlobalEvents.Instance.FileExternallySelected.Subscribe(OnFileExternallySelected); } public override void _UnhandledKeyInput(InputEvent @event) { // Copy if (@event is InputEventKey { Pressed: true, Keycode: Key.C, CtrlPressed: true }) { CopySelectedNodesToSlnExplorerClipboard(); } // Cut else if (@event is InputEventKey { Pressed: true, Keycode: Key.X, CtrlPressed: true }) { CutSelectedNodeToSlnExplorerClipboard(); } // Paste else if (@event is InputEventKey { Pressed: true, Keycode: Key.V, CtrlPressed: true }) { CopyNodesFromClipboardToSelectedNode(); } else if (@event is InputEventKey { Pressed: true, Keycode: Key.Delete }) { // TODO: DeleteSelectedNodes(); } else if (@event is InputEventKey { Pressed: true, Keycode: Key.Escape }) { ClearSlnExplorerClipboard(); } } private void TreeOnItemMouseSelected(Vector2 mousePosition, long mouseButtonIndex) { var selected = _tree.GetSelected(); if (selected is null) return; if (HasMultipleNodesSelected()) return; var mouseButtonMask = (MouseButtonMask)mouseButtonIndex; var genericMetadata = selected.GetMetadata(0).As(); switch (mouseButtonMask, genericMetadata) { case (MouseButtonMask.Left, RefCountedContainer fileContainer): GodotGlobalEvents.Instance.FileSelected.InvokeParallelFireAndForget(fileContainer.Item, null); break; case (MouseButtonMask.Right, RefCountedContainer fileContainer): OpenContextMenuFile(fileContainer.Item); break; case (MouseButtonMask.Left, RefCountedContainer): break; case (MouseButtonMask.Right, RefCountedContainer projectContainer): OpenContextMenuProject(projectContainer.Item); break; case (MouseButtonMask.Left, RefCountedContainer): break; case (MouseButtonMask.Right, RefCountedContainer folderContainer): OpenContextMenuFolder(folderContainer.Item, selected); break; case (MouseButtonMask.Left, RefCountedContainer): break; default: break; } } private async Task OnFileExternallySelected(SharpIdeFile file, SharpIdeFileLinePosition? fileLinePosition) { await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); var task = GodotGlobalEvents.Instance.FileSelected.InvokeParallelAsync(file, fileLinePosition); // First check if the file is already selected var selectedItem = _tree.GetSelected(); if (selectedItem is not null) { var selectedFile = selectedItem.GetTypedMetadata?>(0)?.Item; if (selectedFile == file) return; } var item = FindItemRecursive(_tree.GetRoot(), file); if (item is not null) { await this.InvokeAsync(() => { item.UncollapseTree(); _tree.SetSelected(item, 0); _tree.ScrollToItem(item, true); _tree.QueueRedraw(); }); } await task.ConfigureAwait(false); } private static TreeItem? FindItemRecursive(TreeItem item, SharpIdeFile file) { if (item.GetTypedMetadata?>(0)?.Item == file) return item; var child = item.GetFirstChild(); while (child != null) { var result = FindItemRecursive(child, file); if (result != null) return result; child = child.GetNext(); } return null; } public async Task BindToSolution() => await BindToSolution(SolutionModel); [RequiresGodotUiThread] public async Task BindToSolution(SharpIdeSolutionModel solution) { await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); using var _ = SharpIdeOtel.Source.StartActivity($"{nameof(SolutionExplorerPanel)}.{nameof(BindToSolution)}"); // Solutions with hundreds of thousands of files can cause the ui to freeze as the tree is populated // the Tree has been removed from the scene tree in _Ready, so we can operate on it off the ui thread, then add it back _tree.Clear(); // Root var rootItem = _tree.CreateItem(); rootItem.SetText(0, solution.Name); rootItem.SetIcon(0, SlnIcon); _rootItem = rootItem; // Observe Projects var projectsView = solution.Projects.CreateView(y => new TreeItemContainer()); projectsView.Unfiltered.ToList().ForEach(s => s.View.Value = CreateProjectTreeItem(_tree, rootItem, s.Value)); projectsView.ObserveChanged().SubscribeOnThreadPool().ObserveOnThreadPool() .SubscribeAwait(async (e, ct) => await (e.Action switch { NotifyCollectionChangedAction.Add => this.InvokeAsync(() => e.NewItem.View.Value = CreateProjectTreeItem(_tree, _rootItem, e.NewItem.Value)), NotifyCollectionChangedAction.Remove => FreeTreeItem(e.OldItem.View.Value), _ => Task.CompletedTask })).AddToDeferred(this); // Observe Solution Folders var foldersView = solution.SlnFolders.CreateView(y => new TreeItemContainer()); foldersView.Unfiltered.ToList().ForEach(s => s.View.Value = CreateSlnFolderTreeItem(_tree, rootItem, s.Value)); foldersView.ObserveChanged().SubscribeOnThreadPool().ObserveOnThreadPool() .SubscribeAwait(async (e, ct) => await (e.Action switch { NotifyCollectionChangedAction.Add => this.InvokeAsync(() => e.NewItem.View.Value = CreateSlnFolderTreeItem(_tree, _rootItem, e.NewItem.Value)), NotifyCollectionChangedAction.Remove => FreeTreeItem(e.OldItem.View.Value), _ => Task.CompletedTask })).AddToDeferred(this); rootItem.SetCollapsedRecursive(true); rootItem.Collapsed = false; await this.InvokeAsync(() => { _panelContainer.AddChild(_tree); }); } [RequiresGodotUiThread] private TreeItem CreateSlnFolderTreeItem(Tree tree, TreeItem parent, SharpIdeSolutionFolder slnFolder) { var folderItem = tree.CreateItem(parent); folderItem.SetText(0, slnFolder.Name); folderItem.SetIcon(0, SlnFolderIcon); folderItem.SetMetadata(0, new RefCountedContainer(slnFolder)); // Observe folder sub-collections var subFoldersView = slnFolder.Folders.CreateView(y => new TreeItemContainer()); subFoldersView.Unfiltered.ToList().ForEach(s => s.View.Value = CreateSlnFolderTreeItem(_tree, folderItem, s.Value)); subFoldersView.ObserveChanged().SubscribeOnThreadPool().ObserveOnThreadPool() .SubscribeAwait(async (innerEvent, ct) => await (innerEvent.Action switch { NotifyCollectionChangedAction.Add => this.InvokeAsync(() => innerEvent.NewItem.View.Value = CreateSlnFolderTreeItem(_tree, folderItem, innerEvent.NewItem.Value)), NotifyCollectionChangedAction.Remove => FreeTreeItem(innerEvent.OldItem.View.Value), _ => Task.CompletedTask })).AddToDeferred(this); var projectsView = slnFolder.Projects.CreateView(y => new TreeItemContainer()); projectsView.Unfiltered.ToList().ForEach(s => s.View.Value = CreateProjectTreeItem(_tree, folderItem, s.Value)); projectsView.ObserveChanged().SubscribeOnThreadPool().ObserveOnThreadPool() .SubscribeAwait(async (innerEvent, ct) => await (innerEvent.Action switch { NotifyCollectionChangedAction.Add => this.InvokeAsync(() => innerEvent.NewItem.View.Value = CreateProjectTreeItem(_tree, folderItem, innerEvent.NewItem.Value)), NotifyCollectionChangedAction.Remove => FreeTreeItem(innerEvent.OldItem.View.Value), _ => Task.CompletedTask })).AddToDeferred(this); var filesView = slnFolder.Files.CreateView(y => new TreeItemContainer()); filesView.Unfiltered.ToList().ForEach(s => s.View.Value = CreateFileTreeItem(_tree, folderItem, s.Value)); filesView.ObserveChanged().SubscribeOnThreadPool().ObserveOnThreadPool() .SubscribeAwait(async (innerEvent, ct) => await (innerEvent.Action switch { NotifyCollectionChangedAction.Add => this.InvokeAsync(() => innerEvent.NewItem.View.Value = CreateFileTreeItem(_tree, folderItem, innerEvent.NewItem.Value, innerEvent.NewStartingIndex)), NotifyCollectionChangedAction.Remove => FreeTreeItem(innerEvent.OldItem.View.Value), _ => Task.CompletedTask })).AddToDeferred(this); return folderItem; } [RequiresGodotUiThread] private TreeItem CreateProjectTreeItem(Tree tree, TreeItem parent, SharpIdeProjectModel projectModel) { var projectItem = tree.CreateItem(parent); projectItem.SetText(0, projectModel.Name); projectItem.SetIcon(0, CsprojIcon); projectItem.SetMetadata(0, new RefCountedContainer(projectModel)); // Observe project folders var foldersView = projectModel.Folders.CreateView(y => new TreeItemContainer()); foldersView.Unfiltered.ToList().ForEach(s => s.View.Value = CreateFolderTreeItem(_tree, projectItem, s.Value)); foldersView.ObserveChanged().SubscribeOnThreadPool().ObserveOnThreadPool() .SubscribeAwait(async (innerEvent, ct) => await (innerEvent.Action switch { NotifyCollectionChangedAction.Add => this.InvokeAsync(() => innerEvent.NewItem.View.Value = CreateFolderTreeItem(_tree, projectItem, innerEvent.NewItem.Value, innerEvent.NewStartingIndex)), NotifyCollectionChangedAction.Move => MoveTreeItem(_tree, innerEvent.NewItem.View, innerEvent.NewItem.Value, innerEvent.OldStartingIndex, innerEvent.NewStartingIndex), NotifyCollectionChangedAction.Remove => FreeTreeItem(innerEvent.OldItem.View.Value), _ => Task.CompletedTask })).AddToDeferred(this); // Observe project files var filesView = projectModel.Files.CreateView(y => new TreeItemContainer()); filesView.Unfiltered.ToList().ForEach(s => s.View.Value = CreateFileTreeItem(_tree, projectItem, s.Value)); filesView.ObserveChanged().SubscribeOnThreadPool().ObserveOnThreadPool() .SubscribeAwait(async (innerEvent, ct) => await (innerEvent.Action switch { NotifyCollectionChangedAction.Add => this.InvokeAsync(() => innerEvent.NewItem.View.Value = CreateFileTreeItem(_tree, projectItem, innerEvent.NewItem.Value, innerEvent.NewStartingIndex)), NotifyCollectionChangedAction.Move => MoveTreeItem(_tree, innerEvent.NewItem.View, innerEvent.NewItem.Value, innerEvent.OldStartingIndex, innerEvent.NewStartingIndex), NotifyCollectionChangedAction.Remove => FreeTreeItem(innerEvent.OldItem.View.Value), _ => Task.CompletedTask })).AddToDeferred(this); return projectItem; } [RequiresGodotUiThread] private TreeItem CreateFolderTreeItem(Tree tree, TreeItem parent, SharpIdeFolder sharpIdeFolder, int newStartingIndex = -1) { var folderItem = tree.CreateItem(parent, newStartingIndex); folderItem.SetText(0, sharpIdeFolder.Name); folderItem.SetIcon(0, FolderIcon); folderItem.SetMetadata(0, new RefCountedContainer(sharpIdeFolder)); Observable.EveryValueChanged(sharpIdeFolder, folder => folder.Name) .Skip(1).SubscribeOnThreadPool().ObserveOnThreadPool().SubscribeAwait(async (s, ct) => { await this.InvokeAsync(() => folderItem.SetText(0, s)); }).AddToDeferred(this); // Observe subfolders var subFoldersView = sharpIdeFolder.Folders.CreateView(y => new TreeItemContainer()); subFoldersView.Unfiltered.ToList().ForEach(s => s.View.Value = CreateFolderTreeItem(_tree, folderItem, s.Value)); subFoldersView.ObserveChanged().SubscribeOnThreadPool().ObserveOnThreadPool() .SubscribeAwait(async (innerEvent, ct) => await (innerEvent.Action switch { NotifyCollectionChangedAction.Add => this.InvokeAsync(() => innerEvent.NewItem.View.Value = CreateFolderTreeItem(_tree, folderItem, innerEvent.NewItem.Value, innerEvent.NewStartingIndex)), NotifyCollectionChangedAction.Move => MoveTreeItem(_tree, innerEvent.NewItem.View, innerEvent.NewItem.Value, innerEvent.OldStartingIndex, innerEvent.NewStartingIndex), NotifyCollectionChangedAction.Remove => FreeTreeItem(innerEvent.OldItem.View.Value), _ => Task.CompletedTask })).AddToDeferred(this); // Observe files var filesView = sharpIdeFolder.Files.CreateView(y => new TreeItemContainer()); filesView.Unfiltered.ToList().ForEach(s => s.View.Value = CreateFileTreeItem(_tree, folderItem, s.Value)); filesView.ObserveChanged().SubscribeOnThreadPool().ObserveOnThreadPool() .SubscribeAwait(async (innerEvent, ct) => await (innerEvent.Action switch { NotifyCollectionChangedAction.Add => this.InvokeAsync(() => innerEvent.NewItem.View.Value = CreateFileTreeItem(_tree, folderItem, innerEvent.NewItem.Value, innerEvent.NewStartingIndex)), NotifyCollectionChangedAction.Move => MoveTreeItem(_tree, innerEvent.NewItem.View, innerEvent.NewItem.Value, innerEvent.OldStartingIndex, innerEvent.NewStartingIndex), NotifyCollectionChangedAction.Remove => FreeTreeItem(innerEvent.OldItem.View.Value), _ => Task.CompletedTask })).AddToDeferred(this); return folderItem; } [RequiresGodotUiThread] private TreeItem CreateFileTreeItem(Tree tree, TreeItem parent, SharpIdeFile sharpIdeFile, int newStartingIndex = -1) { // We need to offset the starting index by the number of non-file items (folders/projects) in the parent // because the newStartingIndex is calculated based on all children, but we are only inserting files here if (newStartingIndex >= 0) { var sharpIdeParent = sharpIdeFile.Parent as IFolderOrProject; Guard.Against.Null(sharpIdeParent, nameof(sharpIdeParent)); var folderCount = sharpIdeParent.Folders.Count; newStartingIndex += folderCount; } var fileItem = tree.CreateItem(parent, newStartingIndex); fileItem.SetText(0, sharpIdeFile.Name); fileItem.SetIconsForFileExtension(sharpIdeFile); fileItem.SetCustomColor(0, GitColours.GetColorForGitFileStatus(sharpIdeFile.GitStatus)); fileItem.SetMetadata(0, new RefCountedContainer(sharpIdeFile)); Observable.EveryValueChanged(sharpIdeFile, file => file.Name) .Skip(1).SubscribeOnThreadPool().ObserveOnThreadPool().SubscribeAwait(async (s, ct) => { await this.InvokeAsync(() => { fileItem.SetText(0, s); fileItem.SetIconsForFileExtension(sharpIdeFile); }); }).AddToDeferred(this); return fileItem; } private async Task MoveTreeItem(Tree tree, TreeItemContainer treeItemContainer, IFileOrFolder fileOrFolder, int oldStartingIndex, int newStartingIndex) { if (oldStartingIndex == newStartingIndex) throw new InvalidOperationException("Old and new starting indexes are the same"); var treeItem = treeItemContainer.Value!; var isFile = fileOrFolder is SharpIdeFile; if (isFile) { var sharpIdeParent = fileOrFolder.Parent as IFolderOrProject; Guard.Against.Null(sharpIdeParent, nameof(sharpIdeParent)); var folderCount = sharpIdeParent.Folders.Count; newStartingIndex += folderCount; } await this.InvokeAsync(() => { treeItem.MoveToIndexInParent(oldStartingIndex, newStartingIndex); }); } private async Task FreeTreeItem(TreeItem? item) { await this.InvokeAsync(() => item?.Free()); } }