diff --git a/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs b/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs index f435094..a737a8b 100644 --- a/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs +++ b/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs @@ -796,4 +796,11 @@ public class RoslynAnalysis _workspace.TryApplyChanges(newSolution); } + + public async Task MoveDocument(SharpIdeFile sharpIdeFile, string oldFilePath) + { + var document = _workspace!.CurrentSolution.GetDocumentIdsWithFilePath(oldFilePath).Single(); + var updatedSolution = _workspace.CurrentSolution.WithDocumentFilePath(document, sharpIdeFile.Path); + _workspace.TryApplyChanges(updatedSolution); + } } diff --git a/src/SharpIDE.Application/Features/FileWatching/FileChangedService.cs b/src/SharpIDE.Application/Features/FileWatching/FileChangedService.cs index 4409cd6..e7fd41f 100644 --- a/src/SharpIDE.Application/Features/FileWatching/FileChangedService.cs +++ b/src/SharpIDE.Application/Features/FileWatching/FileChangedService.cs @@ -23,6 +23,15 @@ public class FileChangedService(RoslynAnalysis roslynAnalysis, IdeOpenTabsFileMa public SharpIdeSolutionModel SolutionModel { get; set; } = null!; + public async Task SharpIdeFileMoved(SharpIdeFile file, string oldFilePath) + { + if (file.IsRoslynWorkspaceFile) + { + await HandleWorkspaceFileMoved(file, oldFilePath); + } + // TODO: handle csproj moved + } + public async Task SharpIdeFileAdded(SharpIdeFile file, string content) { if (file.IsRoslynWorkspaceFile) @@ -125,4 +134,15 @@ public class FileChangedService(RoslynAnalysis roslynAnalysis, IdeOpenTabsFileMa GlobalEvents.Instance.SolutionAltered.InvokeParallelFireAndForget(); await _roslynAnalysis.UpdateSolutionDiagnostics(newCts.Token); } + + private async Task HandleWorkspaceFileMoved(SharpIdeFile file, string oldFilePath) + { + var newCts = new CancellationTokenSource(); + var oldCts = Interlocked.Exchange(ref _updateSolutionDiagnosticsCts, newCts); + await oldCts.CancelAsync(); + oldCts.Dispose(); + await _roslynAnalysis.MoveDocument(file, oldFilePath); + GlobalEvents.Instance.SolutionAltered.InvokeParallelFireAndForget(); + await _roslynAnalysis.UpdateSolutionDiagnostics(newCts.Token); + } } diff --git a/src/SharpIDE.Application/Features/FileWatching/IdeFileExternalChangeHandler.cs b/src/SharpIDE.Application/Features/FileWatching/IdeFileExternalChangeHandler.cs index a83dd12..1befe91 100644 --- a/src/SharpIDE.Application/Features/FileWatching/IdeFileExternalChangeHandler.cs +++ b/src/SharpIDE.Application/Features/FileWatching/IdeFileExternalChangeHandler.cs @@ -18,6 +18,24 @@ public class IdeFileExternalChangeHandler GlobalEvents.Instance.FileSystemWatcherInternal.FileCreated.Subscribe(OnFileCreated); GlobalEvents.Instance.FileSystemWatcherInternal.DirectoryCreated.Subscribe(OnFolderCreated); GlobalEvents.Instance.FileSystemWatcherInternal.DirectoryDeleted.Subscribe(OnFolderDeleted); + GlobalEvents.Instance.FileSystemWatcherInternal.DirectoryRenamed.Subscribe(OnFolderRenamed); + } + + // TODO: Test - this most likely only will ever be called on linux - windows and macos(?) does delete + create on rename of folders + private async Task OnFolderRenamed(string oldFolderPath, string newFolderPath) + { + var sharpIdeFolder = SolutionModel.AllFolders.SingleOrDefault(f => f.Path == newFolderPath); + if (sharpIdeFolder is null) + { + return; + } + var isMoveRatherThanRename = Path.GetDirectoryName(oldFolderPath) != Path.GetDirectoryName(newFolderPath); + if (isMoveRatherThanRename) + { + throw new NotImplementedException("Moving folders is not yet supported. Note that this should only be encountered on Linux."); + } + var newFolderName = Path.GetFileName(newFolderPath); + await _sharpIdeSolutionModificationService.RenameDirectory(sharpIdeFolder, newFolderName); } private async Task OnFolderDeleted(string folderPath) diff --git a/src/SharpIDE.Application/Features/FileWatching/IdeFileOperationsService.cs b/src/SharpIDE.Application/Features/FileWatching/IdeFileOperationsService.cs index 9bd54d3..691b7ea 100644 --- a/src/SharpIDE.Application/Features/FileWatching/IdeFileOperationsService.cs +++ b/src/SharpIDE.Application/Features/FileWatching/IdeFileOperationsService.cs @@ -7,6 +7,14 @@ public class IdeFileOperationsService(SharpIdeSolutionModificationService sharpI { private readonly SharpIdeSolutionModificationService _sharpIdeSolutionModificationService = sharpIdeSolutionModificationService; + public async Task RenameDirectory(SharpIdeFolder folder, string newDirectoryName) + { + var parentPath = Path.GetDirectoryName(folder.Path)!; + var newDirectoryPath = Path.Combine(parentPath, newDirectoryName); + Directory.Move(folder.Path, newDirectoryPath); + await _sharpIdeSolutionModificationService.RenameDirectory(folder, newDirectoryName); + } + public async Task CreateDirectory(IFolderOrProject parentNode, string newDirectoryName) { var newDirectoryPath = Path.Combine(parentNode.ChildNodeBasePath, newDirectoryName); diff --git a/src/SharpIDE.Application/Features/FileWatching/IdeFileWatcher.cs b/src/SharpIDE.Application/Features/FileWatching/IdeFileWatcher.cs index 2708383..1adbd6d 100644 --- a/src/SharpIDE.Application/Features/FileWatching/IdeFileWatcher.cs +++ b/src/SharpIDE.Application/Features/FileWatching/IdeFileWatcher.cs @@ -56,14 +56,23 @@ public sealed class IdeFileWatcher : IDisposable case ChangeType.CHANGED: HandleChanged(e.FullPath); break; case ChangeType.CREATED: HandleCreated(e.FullPath); break; case ChangeType.DELETED: HandleDeleted(e.FullPath); break; - case ChangeType.RENAMED: HandleRenamed(e.OldFullPath, e.FullPath); break; + case ChangeType.RENAMED: HandleRenamed(e.OldFullPath!, e.FullPath); break; default: throw new ArgumentOutOfRangeException(); } } - private void HandleRenamed(string? oldFullPath, string fullPath) + private void HandleRenamed(string oldFullPath, string fullPath) { - Console.WriteLine($"FileSystemWatcher: Renamed - {oldFullPath}, {fullPath}"); + var isDirectory = Path.HasExtension(fullPath) is false; + if (isDirectory) + { + GlobalEvents.Instance.FileSystemWatcherInternal.DirectoryRenamed.InvokeParallelFireAndForget(oldFullPath, fullPath); + } + else + { + GlobalEvents.Instance.FileSystemWatcherInternal.FileRenamed.InvokeParallelFireAndForget(oldFullPath, fullPath); + } + //Console.WriteLine($"FileSystemWatcher: Renamed - {oldFullPath}, {fullPath}"); } private void HandleDeleted(string fullPath) diff --git a/src/SharpIDE.Application/Features/FileWatching/SharpIdeSolutionModificationService.cs b/src/SharpIDE.Application/Features/FileWatching/SharpIdeSolutionModificationService.cs index d8eee21..8be3629 100644 --- a/src/SharpIDE.Application/Features/FileWatching/SharpIdeSolutionModificationService.cs +++ b/src/SharpIDE.Application/Features/FileWatching/SharpIdeSolutionModificationService.cs @@ -60,6 +60,40 @@ public class SharpIdeSolutionModificationService(FileChangedService fileChangedS } } + public async Task MoveDirectory(SharpIdeFolder folder, string newDirectoryPath) + { + + } + + public async Task RenameDirectory(SharpIdeFolder folder, string renamedFolderName) + { + var oldFolderPath = folder.Path; + + folder.Name = renamedFolderName; + folder.Path = Path.Combine(Path.GetDirectoryName(oldFolderPath)!, renamedFolderName); + + var stack = new Stack(); + stack.Push(folder); + + while (stack.Count > 0) + { + var current = stack.Pop(); + + foreach (var subfolder in current.Folders) + { + subfolder.Path = Path.Combine(current.Path, subfolder.Name); + stack.Push(subfolder); + } + + foreach (var file in current.Files) + { + var oldPath = file.Path; + file.Path = Path.Combine(current.Path, file.Name); + await _fileChangedService.SharpIdeFileMoved(file, oldPath); + } + } + } + public async Task CreateFile(IFolderOrProject parentNode, string newFilePath, string fileName, string contents) { var sharpIdeFile = new SharpIdeFile(newFilePath, fileName, parentNode, []); diff --git a/src/SharpIDE.Godot/Features/SolutionExplorer/ContextMenus/Dialogs/RenameDirectoryDialog.cs b/src/SharpIDE.Godot/Features/SolutionExplorer/ContextMenus/Dialogs/RenameDirectoryDialog.cs new file mode 100644 index 0000000..462a4d8 --- /dev/null +++ b/src/SharpIDE.Godot/Features/SolutionExplorer/ContextMenus/Dialogs/RenameDirectoryDialog.cs @@ -0,0 +1,68 @@ +using Godot; +using SharpIDE.Application.Features.FileWatching; +using SharpIDE.Application.Features.SolutionDiscovery; +using SharpIDE.Application.Features.SolutionDiscovery.VsPersistence; + +namespace SharpIDE.Godot.Features.SolutionExplorer.ContextMenus.Dialogs; + +public partial class RenameDirectoryDialog : ConfirmationDialog +{ + private LineEdit _nameLineEdit = null!; + + public SharpIdeFolder Folder { get; set; } = null!; + public TreeItem FolderTreeItem { get; set; } = null!; + + [Inject] private readonly IdeFileOperationsService _ideFileOperationsService = null!; + + private bool _isNameValid = true; + private string _folderParentPath = null!; + + public override void _Ready() + { + _folderParentPath = Path.GetDirectoryName(Folder.Path)!; + _nameLineEdit = GetNode("%DirectoryNameLineEdit"); + _nameLineEdit.Text = Folder.Name; + _nameLineEdit.GrabFocus(); + _nameLineEdit.SelectAll(); + _nameLineEdit.TextChanged += ValidateNewDirectoryName; + Confirmed += OnConfirmed; + } + + private void ValidateNewDirectoryName(string newDirectoryNameText) + { + _isNameValid = true; + var newDirectoryName = newDirectoryNameText.Trim(); + if (string.IsNullOrEmpty(newDirectoryName) || Directory.Exists(Path.Combine(_folderParentPath, newDirectoryName))) + { + _isNameValid = false; + } + var textColour = _isNameValid ? new Color(1, 1, 1) : new Color(1, 0, 0); + _nameLineEdit.AddThemeColorOverride("font_color", textColour); + } + + public override void _Input(InputEvent @event) + { + if (@event is InputEventKey { Pressed: true, Keycode: Key.Enter }) + { + EmitSignalConfirmed(); + } + } + + private void OnConfirmed() + { + if (_isNameValid is false) return; + var directoryName = _nameLineEdit.Text.Trim(); + if (string.IsNullOrEmpty(directoryName)) + { + GD.PrintErr("Directory name cannot be empty."); + return; + } + + _ = Task.GodotRun(async () => + { + await _ideFileOperationsService.RenameDirectory(Folder, directoryName); + await this.InvokeAsync(() => FolderTreeItem.SetText(0, directoryName)); + }); + QueueFree(); + } +} \ No newline at end of file diff --git a/src/SharpIDE.Godot/Features/SolutionExplorer/ContextMenus/Dialogs/RenameDirectoryDialog.cs.uid b/src/SharpIDE.Godot/Features/SolutionExplorer/ContextMenus/Dialogs/RenameDirectoryDialog.cs.uid new file mode 100644 index 0000000..794dc03 --- /dev/null +++ b/src/SharpIDE.Godot/Features/SolutionExplorer/ContextMenus/Dialogs/RenameDirectoryDialog.cs.uid @@ -0,0 +1 @@ +uid://br4u8ymur3yg5 diff --git a/src/SharpIDE.Godot/Features/SolutionExplorer/ContextMenus/Dialogs/RenameDirectoryDialog.tscn b/src/SharpIDE.Godot/Features/SolutionExplorer/ContextMenus/Dialogs/RenameDirectoryDialog.tscn new file mode 100644 index 0000000..f9a233d --- /dev/null +++ b/src/SharpIDE.Godot/Features/SolutionExplorer/ContextMenus/Dialogs/RenameDirectoryDialog.tscn @@ -0,0 +1,26 @@ +[gd_scene load_steps=2 format=3 uid="uid://btebkg8bo3b37"] + +[ext_resource type="Script" uid="uid://br4u8ymur3yg5" path="res://Features/SolutionExplorer/ContextMenus/Dialogs/RenameDirectoryDialog.cs" id="1_6fale"] + +[node name="RenameDirectoryDialog" type="ConfirmationDialog"] +oversampling_override = 1.0 +title = "Rename: Directory" +position = Vector2i(0, 36) +size = Vector2i(405, 115) +visible = true +script = ExtResource("1_6fale") + +[node name="VBoxContainer" type="VBoxContainer" parent="."] +offset_left = 8.0 +offset_top = 8.0 +offset_right = 397.0 +offset_bottom = 66.0 + +[node name="Label" type="Label" parent="VBoxContainer"] +layout_mode = 2 +text = "Enter name:" + +[node name="DirectoryNameLineEdit" type="LineEdit" parent="VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +text = "ExistingDirectoryName" diff --git a/src/SharpIDE.Godot/Features/SolutionExplorer/ContextMenus/FolderContextMenu.cs b/src/SharpIDE.Godot/Features/SolutionExplorer/ContextMenus/FolderContextMenu.cs index ee610a4..2d15a41 100644 --- a/src/SharpIDE.Godot/Features/SolutionExplorer/ContextMenus/FolderContextMenu.cs +++ b/src/SharpIDE.Godot/Features/SolutionExplorer/ContextMenus/FolderContextMenu.cs @@ -10,7 +10,8 @@ file enum FolderContextMenuOptions { CreateNew = 1, RevealInFileExplorer = 2, - Delete = 3 + Delete = 3, + Rename = 4 } file enum CreateNewSubmenuOptions @@ -22,7 +23,11 @@ file enum CreateNewSubmenuOptions public partial class SolutionExplorerPanel { [Inject] private readonly IdeFileOperationsService _ideFileOperationsService = null!; - private void OpenContextMenuFolder(SharpIdeFolder folder) + + private readonly PackedScene _newDirectoryDialogScene = GD.Load("uid://bgi4u18y8pt4x"); + private readonly PackedScene _newCsharpFileDialogScene = GD.Load("uid://chnb7gmcdg0ww"); + private readonly PackedScene _renameDirectoryDialogScene = GD.Load("uid://btebkg8bo3b37"); + private void OpenContextMenuFolder(SharpIdeFolder folder, TreeItem folderTreeItem) { var menu = new PopupMenu(); AddChild(menu); @@ -35,6 +40,7 @@ public partial class SolutionExplorerPanel menu.AddItem("Reveal in File Explorer", (int)FolderContextMenuOptions.RevealInFileExplorer); menu.AddItem("Delete", (int)FolderContextMenuOptions.Delete); + menu.AddItem("Rename", (int)FolderContextMenuOptions.Rename); menu.PopupHide += () => menu.QueueFree(); menu.IdPressed += id => { @@ -68,6 +74,14 @@ public partial class SolutionExplorerPanel } }); } + else if (actionId is FolderContextMenuOptions.Rename) + { + var renameDirectoryDialog = _renameDirectoryDialogScene.Instantiate(); + renameDirectoryDialog.Folder = folder; + renameDirectoryDialog.FolderTreeItem = folderTreeItem; + AddChild(renameDirectoryDialog); + renameDirectoryDialog.PopupCentered(); + } }; var globalMousePosition = GetGlobalMousePosition(); @@ -75,8 +89,6 @@ public partial class SolutionExplorerPanel menu.Popup(); } - private readonly PackedScene _newDirectoryDialogScene = GD.Load("uid://bgi4u18y8pt4x"); - private readonly PackedScene _newCsharpFileDialogScene = GD.Load("uid://chnb7gmcdg0ww"); private void OnCreateNewSubmenuPressed(long id, IFolderOrProject folder) { var actionId = (CreateNewSubmenuOptions)id; diff --git a/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs b/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs index de6d950..16f5a79 100644 --- a/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs +++ b/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs @@ -49,7 +49,7 @@ public partial class SolutionExplorerPanel : MarginContainer 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); break; + case (MouseButtonMask.Right, RefCountedContainer folderContainer): OpenContextMenuFolder(folderContainer.Item, selected); break; case (MouseButtonMask.Left, RefCountedContainer): break; default: break; }