diff --git a/Moonlight/Assets/Core/css/utils.css b/Moonlight/Assets/Core/css/utils.css index a61d8c2..c20546b 100644 --- a/Moonlight/Assets/Core/css/utils.css +++ b/Moonlight/Assets/Core/css/utils.css @@ -14,4 +14,16 @@ tr:hover .table-row-hover-content { .hide-scrollbar { -ms-overflow-style: none; /* IE and Edge */ scrollbar-width: none; /* Firefox */ +} + +.blur-unless-hover { + filter: blur(5px); +} + +.blur-unless-hover:hover { + filter: none; +} + +.blur { + filter: blur(5px); } \ No newline at end of file diff --git a/Moonlight/Assets/FileManager/css/blazorContextMenu.css b/Moonlight/Assets/FileManager/css/blazorContextMenu.css new file mode 100644 index 0000000..e11b49f --- /dev/null +++ b/Moonlight/Assets/FileManager/css/blazorContextMenu.css @@ -0,0 +1,198 @@ +.blazor-context-menu--default { + position: fixed; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + padding: 5px 0; +} + + +.blazor-context-menu__list { + list-style-type: none; + padding-left: 5px; + padding-right: 5px; + margin: 0px; +} + +.blazor-context-menu__seperator { + min-width: 120px; + color: #333; + position: relative; +} + +.blazor-context-menu__seperator__hr { + display: block; + margin-top: 0.5em; + margin-bottom: 0.5em; + margin-left: auto; + margin-right: auto; + border-style: inset; + border-width: 1px; +} + +.blazor-context-menu__item--default { + min-width: 120px; + padding: 6px; + text-align: left; + white-space: nowrap; + position: relative; + cursor: pointer; +} + + .blazor-context-menu__item--default:hover { + background-color: rgba(0,0,0,.05); + } + + +.blazor-context-menu__item--with-submenu:after { + content: ""; + position: absolute; + right: -8px; + top: 50%; + -webkit-transform: translateY(-50%); + transform: translateY(-50%); + border: 6px solid transparent; + border-left-color: #615c5c; +} + +.blazor-context-menu__item--default-disabled { + min-width: 120px; + padding: 6px; + text-align: left; + white-space: nowrap; + position: relative; + cursor: not-allowed; + opacity: 0.6; +} + +.blazor-context-menu--hidden { + display: none; +} + +/*============== ANIMATIONS ==============*/ +/*-------------- FadeIn ------------------*/ +.blazor-context-menu__animations--fadeIn { + animation-name: fadeIn; + animation-direction: reverse; + animation-duration: 0.2s; +} + +.blazor-context-menu__animations--fadeIn-shown { + animation-name: fadeIn; + animation-duration: 0.2s; +} + +@keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + + +/*-------------- Grow ------------------*/ + +.blazor-context-menu__animations--grow { + animation-name: grow; + animation-direction: reverse; + animation-duration: 0.2s; +} + +.blazor-context-menu__animations--grow-shown { + animation-name: grow; + animation-duration: 0.2s; +} + +@keyframes grow { + 0% { + transform: scale(0); + transform-origin: top left; + opacity: 0; + } + + 100% { + transform: scale(1); + transform-origin: top left; + opacity: 1; + } +} + +/*-------------- Slide ------------------*/ + +.blazor-context-menu.blazor-context-menu__animations--slide { + animation-name: slide; + animation-direction: reverse; + animation-duration: 0.2s; +} + +.blazor-context-menu.blazor-context-menu__animations--slide-shown { + animation-name: slide; + animation-duration: 0.2s; +} + +@keyframes slide { + 0% { + transform: translateX(-5px); + opacity: 0; + } + + 100% { + transform: translateX(0px); + opacity: 1; + } +} + +.blazor-context-submenu.blazor-context-menu__animations--slide { + animation-name: slide-submenu; + animation-direction: reverse; + animation-duration: 0.2s; +} + +.blazor-context-submenu.blazor-context-menu__animations--slide-shown { + animation-name: slide-submenu; + animation-duration: 0.2s; +} + +@keyframes slide-submenu { + 0% { + transform: translateX(-25px); + z-index: -1; + opacity: 0; + } + 90% { + z-index: -1; + } + 100% { + transform: translateX(0px); + z-index: unset; + opacity: 1; + } +} + +/*-------------- Zoom ------------------*/ + +.blazor-context-menu__animations--zoom { + animation-name: zoom; + animation-direction: reverse; + animation-duration: 0.2s; +} + +.blazor-context-menu__animations--zoom-shown { + animation-name: zoom; + animation-duration: 0.2s; +} + +@keyframes zoom { + 0% { + transform: scale(0.5); + opacity: 0; + } + + 100% { + transform: scale(1); + opacity: 1; + } +} \ No newline at end of file diff --git a/Moonlight/Assets/FileManager/js/blazorContextMenu.js b/Moonlight/Assets/FileManager/js/blazorContextMenu.js new file mode 100644 index 0000000..d77b82f --- /dev/null +++ b/Moonlight/Assets/FileManager/js/blazorContextMenu.js @@ -0,0 +1,313 @@ +"use strict"; + +var blazorContextMenu = function (blazorContextMenu) { + + var closest = null; + if (window.Element && !Element.prototype.closest) { + closest = function (el, s) { + var matches = (el.document || el.ownerDocument).querySelectorAll(s), i; + do { + i = matches.length; + while (--i >= 0 && matches.item(i) !== el) { }; + } while ((i < 0) && (el = el.parentElement)); + return el; + }; + } + else { + closest = function (el, s) { + return el.closest(s); + }; + } + + + var openMenus = []; + + //Helper functions + //======================================== + function guid() { + function s4() { + return Math.floor((1 + Math.random()) * 0x10000) + .toString(16) + .substring(1); + } + return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4(); + } + + function findFirstChildByClass(element, className) { + var foundElement = null; + function recurse(element, className, found) { + for (var i = 0; i < element.children.length && !found; i++) { + var el = element.children[i]; + if (el.classList.contains(className)) { + found = true; + foundElement = element.children[i]; + break; + } + if (found) + break; + recurse(element.children[i], className, found); + } + } + recurse(element, className, false); + return foundElement; + } + + function findAllChildsByClass(element, className) { + var foundElements = new Array(); + function recurse(element, className) { + for (var i = 0; i < element.children.length; i++) { + var el = element.children[i]; + if (el.classList.contains(className)) { + foundElements.push(element.children[i]); + } + recurse(element.children[i], className); + } + } + recurse(element, className); + return foundElements; + } + + function removeItemFromArray(array, item) { + for (var i = 0; i < array.length; i++) { + if (array[i] === item) { + array.splice(i, 1); + } + } + } + + var sleepUntil = function (f, timeoutMs) { + return new Promise(function (resolve, reject){ + var timeWas = new Date(); + var wait = setInterval(function () { + if (f()) { + clearInterval(wait); + resolve(); + } else if (new Date() - timeWas > timeoutMs) { + clearInterval(wait); + reject(); + } + }, 20); + }); + } + + + //=========================================== + + var menuHandlerReference = null; + //var openingMenu = false; + + blazorContextMenu.SetMenuHandlerReference = function (dotnetRef) { + if (!menuHandlerReference) { + menuHandlerReference = dotnetRef; + } + } + + var addToOpenMenus = function (menu, menuId, target) { + var instanceId = guid(); + openMenus.push({ + id: menuId, + target: target, + instanceId: instanceId + }); + menu.dataset["instanceId"] = instanceId; + }; + + blazorContextMenu.ManualShow = function (menuId, x, y) { + //openingMenu = true; + var menu = document.getElementById(menuId); + if (!menu) throw new Error("No context menu with id '" + menuId + "' was found"); + addToOpenMenus(menu, menuId, null); + showMenuCommon(menu, menuId, x, y, null, null); + } + + blazorContextMenu.OnContextMenu = function (e, menuId, stopPropagation) { + //openingMenu = true; + var menu = document.getElementById(menuId); + if (!menu) throw new Error("No context menu with id '" + menuId + "' was found"); + addToOpenMenus(menu, menuId, e.target); + var triggerDotnetRef = JSON.parse(e.currentTarget.dataset["dotnetref"]); + showMenuCommon(menu, menuId, e.x, e.y, e.target, triggerDotnetRef); + e.preventDefault(); + if (stopPropagation) { + e.stopPropagation(); + } + return false; + }; + + var showMenuCommon = function (menu, menuId, x, y, target, triggerDotnetRef) { + return blazorContextMenu.Show(menuId, x, y, target, triggerDotnetRef).then(function () { + return sleepUntil(function () { return menu.clientWidth > 0 }, 1000); //Wait until the menu has spawned so clientWidth and offsetLeft report correctly + }).then(function () { + //check for overflow + var leftOverflownPixels = menu.offsetLeft + menu.clientWidth - window.innerWidth; + if (leftOverflownPixels > 0) { + menu.style.left = (menu.offsetLeft - menu.clientWidth) + "px"; + } + + var topOverflownPixels = menu.offsetTop + menu.clientHeight - window.innerHeight; + if (topOverflownPixels > 0) { + menu.style.top = (menu.offsetTop - menu.clientHeight) + "px"; + } + + //openingMenu = false; + }); + } + + blazorContextMenu.Init = function () { + document.addEventListener("mouseup", function (e) { + handleAutoHideEvent(e, "mouseup"); + }); + + document.addEventListener("mousedown", function (e) { + handleAutoHideEvent(e, "mousedown"); + }); + + function handleAutoHideEvent(e, autoHideEvent) { + if (openMenus.length > 0) { + for (var i = 0; i < openMenus.length; i++) { + var currentMenu = openMenus[i]; + var menuElement = document.getElementById(currentMenu.id); + if (menuElement && menuElement.dataset["autohide"] == "true" && menuElement.dataset["autohideevent"] == autoHideEvent) { + var clickedInsideMenu = menuElement.contains(e.target); + if (!clickedInsideMenu) { + blazorContextMenu.Hide(currentMenu.id); + } + } + + } + } + } + + window.addEventListener('resize', function () { + if (openMenus.length > 0) { + for (var i = 0; i < openMenus.length; i++) { + var currentMenu = openMenus[i]; + var menuElement = document.getElementById(currentMenu.id); + if (menuElement && menuElement.dataset["autohide"] == "true") { + blazorContextMenu.Hide(currentMenu.id); + } + } + } + }, true); + }; + + + blazorContextMenu.Show = function (menuId, x, y, target, triggerDotnetRef) { + var targetId = null; + if (target) { + if (!target.id) { + //add an id to the target dynamically so that it can be referenced later + //TODO: Rewrite this once this Blazor limitation is lifted + target.id = guid(); + } + targetId = target.id; + } + + return menuHandlerReference.invokeMethodAsync('ShowMenu', menuId, x.toString(), y.toString(), targetId, triggerDotnetRef); + } + + blazorContextMenu.Hide = function (menuId) { + var menuElement = document.getElementById(menuId); + var instanceId = menuElement.dataset["instanceId"]; + return menuHandlerReference.invokeMethodAsync('HideMenu', menuId).then(function (hideSuccessful) { + if (menuElement.classList.contains("blazor-context-menu") && hideSuccessful) { + //this is a root menu. Remove from openMenus list + var openMenu = openMenus.find(function (item) { + return item.instanceId == instanceId; + }); + if (openMenu) { + removeItemFromArray(openMenus, openMenu); + } + } + }); + } + + blazorContextMenu.IsMenuShown = function (menuId) { + var menuElement = document.getElementById(menuId); + var instanceId = menuElement.dataset["instanceId"]; + var menu = openMenus.find(function (item) { + return item.instanceId == instanceId; + }); + return typeof(menu) != 'undefined' && menu != null; + } + + var subMenuTimeout = null; + blazorContextMenu.OnMenuItemMouseOver = function (e, xOffset, currentItemElement) { + if (closest(e.target, ".blazor-context-menu__wrapper") != closest(currentItemElement, ".blazor-context-menu__wrapper")) { + //skip child menu mouseovers + return; + } + if (currentItemElement.getAttribute("itemEnabled") != "true") return; + + var subMenu = findFirstChildByClass(currentItemElement, "blazor-context-submenu"); + if (!subMenu) return; //item does not contain a submenu + + subMenuTimeout = setTimeout(function () { + subMenuTimeout = null; + + var currentMenu = closest(currentItemElement, ".blazor-context-menu__wrapper"); + var currentMenuList = currentMenu.children[0]; + var rootMenu = closest(currentItemElement, ".blazor-context-menu"); + var targetRect = currentItemElement.getBoundingClientRect(); + var x = targetRect.left + currentMenu.clientWidth - xOffset; + var y = targetRect.top; + var instanceId = rootMenu.dataset["instanceId"]; + + var openMenu = openMenus.find(function (item) { + return item.instanceId == instanceId; + }); + blazorContextMenu.Show(subMenu.id, x, y, openMenu.target).then(function () { + var leftOverflownPixels = subMenu.offsetLeft + subMenu.clientWidth - window.innerWidth; + if (leftOverflownPixels > 0) { + subMenu.style.left = (subMenu.offsetLeft - subMenu.clientWidth - currentMenu.clientWidth - xOffset) + "px" + } + + var topOverflownPixels = subMenu.offsetTop + subMenu.clientHeight - window.innerHeight; + if (topOverflownPixels > 0) { + subMenu.style.top = (subMenu.offsetTop - topOverflownPixels) + "px"; + } + + var closeSubMenus = function () { + var childSubMenus = findAllChildsByClass(currentItemElement, "blazor-context-submenu"); + var i = childSubMenus.length; + while (i--) { + var subMenu = childSubMenus[i]; + blazorContextMenu.Hide(subMenu.id); + } + + i = currentMenuList.childNodes.length; + while (i--) { + var child = currentMenuList.children[i]; + if (child == currentItemElement) continue; + child.removeEventListener("mouseover", closeSubMenus); + } + }; + + var i = currentMenuList.childNodes.length; + while (i--) { + var child = currentMenuList.childNodes[i]; + if (child == currentItemElement) continue; + + child.addEventListener("mouseover", closeSubMenus); + } + }); + }, 200); + } + + blazorContextMenu.OnMenuItemMouseOut = function (e) { + if (subMenuTimeout) { + clearTimeout(subMenuTimeout); + } + } + + + blazorContextMenu.RegisterTriggerReference = function (triggerElement, triggerDotNetRef) { + if (triggerElement) { + triggerElement.dataset["dotnetref"] = JSON.stringify(triggerDotNetRef.serializeAsArg()); + } + } + + return blazorContextMenu; +}({}); + +blazorContextMenu.Init(); \ No newline at end of file diff --git a/Moonlight/Assets/FileManager/js/filemanager.js b/Moonlight/Assets/FileManager/js/filemanager.js new file mode 100644 index 0000000..164e63b --- /dev/null +++ b/Moonlight/Assets/FileManager/js/filemanager.js @@ -0,0 +1,199 @@ +window.filemanager = { + + urlCache: new Map(), + + dropzone: { + init: function (id, urlId, progressReporter) { + function preventDefaults(e) { + e.preventDefault(); + e.stopPropagation(); + } + + async function handleDrop(e) { + e.preventDefault(); + + if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { + + moonlight.toasts.create("dropZoneUploadProgress", "Preparing to upload"); + + await performUpload(e.dataTransfer.items); + + moonlight.toasts.remove("dropZoneUploadProgress"); + moonlight.toasts.success("", "Successfully uploaded files", 5000); + + progressReporter.invokeMethodAsync("UpdateStatus"); + } + + //TODO: HANDLE UNSUPPORTED which would call else + } + + async function performUpload(items) { + const fileEntries = []; + + // Collect file entries from DataTransferItemList + for (let i = 0; i < items.length; i++) { + if (items[i].kind === 'file') { + const entry = items[i].webkitGetAsEntry(); + if (entry.isFile) { + fileEntries.push(entry); + } else if (entry.isDirectory) { + await readDirectory(entry, fileEntries); + } + } + } + + // Upload files one by one + for (const fileEntry of fileEntries) { + moonlight.toasts.modify("dropZoneUploadProgress", `Uploading '${fileEntry.name}'`); + + await uploadFile(fileEntry); + } + } + + async function readDirectory(directoryEntry, fileEntries = []) { + const directoryReader = directoryEntry.createReader(); + + return new Promise(async (resolve, reject) => { + const readBatch = async () => { + directoryReader.readEntries(async function (entries) { + for (const entry of entries) { + if (entry.isFile) { + fileEntries.push(entry); + } else if (entry.isDirectory) { + await readDirectory(entry, fileEntries); + } + } + + // If there are more entries to read, call readBatch again + if (entries.length === 100) { + await readBatch(); + } else { + resolve(); + } + }, reject); + }; + + // Start reading the first batch + await readBatch(); + }); + } + + async function uploadFile(file) { + // Upload the file to the server + let formData = new FormData(); + formData.append('file', await getFile(file)); + formData.append("path", file.fullPath); + + var url = filemanager.urlCache.get(urlId); + + // Create a new fetch request + let request = new Request(url, { + method: 'POST', + body: formData + }); + + request.onprogress = function (event) { + if (event.lengthComputable) { + let percentComplete = (event.loaded / event.total) * 100; + console.log(`Upload progress: ${percentComplete.toFixed(2)}%`); + console.log(`Bytes transferred: ${event.loaded} of ${event.total}`); + } + }; + + try { + // Use the fetch API to send the request + var response = await fetch(request); + + if (!response.ok) { + var errorText = await response.text(); + + moonlight.toasts.danger(`Failed to upload '${file.name}'`, errorText, 5000); + } + } catch (error) { + moonlight.toasts.danger(`Failed to upload '${file.name}'`, error.toString(), 5000); + } + } + + async function getFile(fileEntry) { + try { + return new Promise((resolve, reject) => fileEntry.file(resolve, reject)); + } catch (err) { + console.log(err); + } + } + + const dropArea = document.getElementById(id); + + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { + dropArea.addEventListener(eventName, preventDefaults, false); + }); + + // Handle dropped files and folders + dropArea.addEventListener('drop', handleDrop, false); + } + }, + fileselect: { + init: function (id, urlId, progressReporter) { + const inputElement = document.getElementById(id); + + inputElement.addEventListener("change", handleFiles, false); + + async function handleFiles() { + const fileList = inputElement.files; + + moonlight.toasts.create("selectUploadProgress", "Preparing to upload"); + + for (const file of fileList) { + moonlight.toasts.modify("selectUploadProgress", `Uploading '${file.name}'`); + await uploadFile(file); + } + + moonlight.toasts.remove("selectUploadProgress"); + moonlight.toasts.success("", "Successfully uploaded files", 5000); + + progressReporter.invokeMethodAsync("UpdateStatus"); + + inputElement.value = ""; + } + + async function uploadFile(file) { + // Upload the file to the server + let formData = new FormData(); + formData.append('file', file); + formData.append("path", file.name); + + var url = filemanager.urlCache.get(urlId); + + // Create a new fetch request + let request = new Request(url, { + method: 'POST', + body: formData + }); + + request.onprogress = function (event) { + if (event.lengthComputable) { + let percentComplete = (event.loaded / event.total) * 100; + console.log(`Upload progress: ${percentComplete.toFixed(2)}%`); + console.log(`Bytes transferred: ${event.loaded} of ${event.total}`); + } + }; + + try { + // Use the fetch API to send the request + var response = await fetch(request); + + if (!response.ok) { + var errorText = await response.text(); + + moonlight.toasts.danger(`Failed to upload '${file.name}'`, errorText, 5000); + } + } catch (error) { + moonlight.toasts.danger(`Failed to upload '${file.name}'`, error.toString(), 5000); + } + } + } + }, + updateUrl: function (urlId, url) { + filemanager.urlCache.set(urlId, url); + } +}; \ No newline at end of file diff --git a/Moonlight/Core/Helpers/HostFileActions.cs b/Moonlight/Core/Helpers/HostFileActions.cs index 57ea20e..860340d 100644 --- a/Moonlight/Core/Helpers/HostFileActions.cs +++ b/Moonlight/Core/Helpers/HostFileActions.cs @@ -1,4 +1,5 @@ -using Moonlight.Features.FileManager.Models.Abstractions.FileAccess; +using MoonCore.Helpers; +using Moonlight.Features.FileManager.Models.Abstractions.FileAccess; namespace Moonlight.Core.Helpers; @@ -39,18 +40,26 @@ public class HostFileActions : IFileActions return Task.FromResult(entries.ToArray()); } - public Task Delete(string path) + public Task DeleteFile(string path) { var fullPath = GetFullPath(path); if (File.Exists(fullPath)) File.Delete(fullPath); - else if (Directory.Exists(fullPath)) - Directory.Delete(fullPath, true); return Task.CompletedTask; } + public Task DeleteDirectory(string path) + { + var fullPath = GetFullPath(path); + + if (Directory.Exists(fullPath)) + Directory.Delete(fullPath, true); + + return Task.CompletedTask; + } + public Task Move(string from, string to) { var source = GetFullPath(from); @@ -64,42 +73,47 @@ public class HostFileActions : IFileActions return Task.CompletedTask; } - public Task CreateDirectory(string name) + public Task CreateDirectory(string path) { - var fullPath = GetFullPath(name); + var fullPath = GetFullPath(path); Directory.CreateDirectory(fullPath); return Task.CompletedTask; } - public Task CreateFile(string name) + public Task CreateFile(string path) { - var fullPath = GetFullPath(name); + var fullPath = GetFullPath(path); File.Create(fullPath).Close(); return Task.CompletedTask; } - public Task ReadFile(string name) + public Task ReadFile(string path) { - var fullPath = GetFullPath(name); + var fullPath = GetFullPath(path); return File.ReadAllTextAsync(fullPath); } - public Task WriteFile(string name, string content) + public Task WriteFile(string path, string content) { - var fullPath = GetFullPath(name); + var fullPath = GetFullPath(path); + + EnsureDir(fullPath); + File.WriteAllText(fullPath, content); return Task.CompletedTask; } - public Task ReadFileStream(string name) + public Task ReadFileStream(string path) { - var fullPath = GetFullPath(name); + var fullPath = GetFullPath(path); return Task.FromResult(File.OpenRead(fullPath)); } - public Task WriteFileStream(string name, Stream dataStream) + public Task WriteFileStream(string path, Stream dataStream) { - var fullPath = GetFullPath(name); + var fullPath = GetFullPath(path); + + EnsureDir(fullPath); using (var fileStream = File.Create(fullPath)) dataStream.CopyTo(fileStream); @@ -107,6 +121,12 @@ public class HostFileActions : IFileActions return Task.CompletedTask; } + private void EnsureDir(string path) + { + var pathWithoutFileName = Formatter.ReplaceEnd(path, Path.GetFileName(path), ""); + Directory.CreateDirectory(pathWithoutFileName); + } + public IFileActions Clone() { return new HostFileActions(RootDirectory); diff --git a/Moonlight/Core/Services/PluginService.cs b/Moonlight/Core/Services/PluginService.cs index e47f7f8..20c66fe 100644 --- a/Moonlight/Core/Services/PluginService.cs +++ b/Moonlight/Core/Services/PluginService.cs @@ -127,7 +127,7 @@ public class PluginService { try { - var assembly = Assembly.LoadFile(dllFile); + var assembly = Assembly.LoadFile(Path.GetFullPath(dllFile)); var pluginTypes = assembly .GetTypes() diff --git a/Moonlight/Features/FileManager/FileManagerFeature.cs b/Moonlight/Features/FileManager/FileManagerFeature.cs index 21c2370..482c6ca 100644 --- a/Moonlight/Features/FileManager/FileManagerFeature.cs +++ b/Moonlight/Features/FileManager/FileManagerFeature.cs @@ -3,6 +3,8 @@ using MoonCore.Services; using Moonlight.Core.Configuration; using Moonlight.Core.Models.Abstractions.Feature; using Moonlight.Core.Services; +using Moonlight.Features.FileManager.Implementations; +using Moonlight.Features.FileManager.Interfaces; using Moonlight.Features.FileManager.Models.Enums; namespace Moonlight.Features.FileManager; @@ -25,14 +27,46 @@ public class FileManagerFeature : MoonlightFeature context.Builder.Services.AddSingleton(new JwtService(config.Get().Security.Token)); context.AddAsset("FileManager", "js/dropzone.js"); + context.AddAsset("FileManager", "js/filemanager.js"); context.AddAsset("FileManager", "editor/ace.css"); context.AddAsset("FileManager", "editor/ace.js"); + // Add blazor context menu + context.Builder.Services.AddBlazorContextMenu(builder => + { + /* + builder.ConfigureTemplate(template => + { + + });*/ + }); + + context.AddAsset("FileManager", "js/blazorContextMenu.js"); + context.AddAsset("FileManager", "css/blazorContextMenu.css"); + return Task.CompletedTask; } + public override async Task OnInitialized(InitContext context) + { + // Register default file manager actions in plugin service + var pluginService = context.Application.Services.GetRequiredService(); + + await pluginService.RegisterImplementation(new RenameContextAction()); + await pluginService.RegisterImplementation(new MoveContextAction()); + await pluginService.RegisterImplementation(new DownloadContextAction()); + await pluginService.RegisterImplementation(new DeleteContextAction()); + + await pluginService.RegisterImplementation(new MoveSelectionAction()); + await pluginService.RegisterImplementation(new DeleteSelectionAction()); + + await pluginService.RegisterImplementation(new CreateFileAction()); + await pluginService.RegisterImplementation(new CreateFolderAction()); + } + public override async Task OnSessionInitialized(SessionInitContext context) { + // Register hotkeys var hotKeyService = context.ServiceProvider.GetRequiredService(); await hotKeyService.RegisterHotkey("KeyS", "ctrl", "save"); diff --git a/Moonlight/Features/FileManager/Http/Controllers/UploadController.cs b/Moonlight/Features/FileManager/Http/Controllers/UploadController.cs index 73a560d..93255e0 100644 --- a/Moonlight/Features/FileManager/Http/Controllers/UploadController.cs +++ b/Moonlight/Features/FileManager/Http/Controllers/UploadController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using MoonCore.Helpers; using MoonCore.Services; using Moonlight.Core.Services; using Moonlight.Features.FileManager.Models.Enums; @@ -46,6 +47,20 @@ public class UploadController : Controller if (Request.Form.Files.Count > 1) return BadRequest("Too many files sent"); + if (!Request.Form.ContainsKey("path")) + return BadRequest("Path is missing"); + + var path = Request.Form["path"].First() ?? ""; + + if (string.IsNullOrEmpty(path)) + return BadRequest("Path is missing"); + + if (path.Contains("..")) + { + Logger.Warn("A path transversal attack has been detected while processing upload path", "security"); + return BadRequest("Invalid path. This attempt has been logged ;)"); + } + // Validate request if (!await JwtService.Validate(uploadToken, FileManagerJwtType.FileAccess)) return StatusCode(403); @@ -66,7 +81,7 @@ public class UploadController : Controller // Actually upload the file var file = Request.Form.Files.First(); - await fileAccess.WriteFileStream(file.FileName, file.OpenReadStream()); + await fileAccess.WriteFileStream(path, file.OpenReadStream()); // Cleanup fileAccess.Dispose(); diff --git a/Moonlight/Features/FileManager/Implementations/CreateFileAction.cs b/Moonlight/Features/FileManager/Implementations/CreateFileAction.cs new file mode 100644 index 0000000..68589af --- /dev/null +++ b/Moonlight/Features/FileManager/Implementations/CreateFileAction.cs @@ -0,0 +1,34 @@ +using MoonCoreUI.Services; +using Moonlight.Features.FileManager.Interfaces; +using Moonlight.Features.FileManager.Models.Abstractions.FileAccess; + +namespace Moonlight.Features.FileManager.Implementations; + +public class CreateFileAction : IFileManagerCreateAction +{ + public string Name => "File"; + public string Icon => "bx-file"; + public string Color => "primary"; + + public async Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, IServiceProvider provider) + { + var alertService = provider.GetRequiredService(); + + var name = await alertService.Text("Enter a name for the new file"); + + if (string.IsNullOrEmpty(name) || name.Contains("..")) + return; + + await access.CreateFile(name); + + // We build a virtual entry here so we dont need to fetch one + await fileManager.OpenEditor(new() + { + Name = name, + Size = 0, + IsFile = true, + IsDirectory = false, + LastModifiedAt = DateTime.UtcNow + }); + } +} \ No newline at end of file diff --git a/Moonlight/Features/FileManager/Implementations/CreateFolderAction.cs b/Moonlight/Features/FileManager/Implementations/CreateFolderAction.cs new file mode 100644 index 0000000..1b8fb36 --- /dev/null +++ b/Moonlight/Features/FileManager/Implementations/CreateFolderAction.cs @@ -0,0 +1,29 @@ +using MoonCoreUI.Services; +using Moonlight.Features.FileManager.Interfaces; +using Moonlight.Features.FileManager.Models.Abstractions.FileAccess; +using Moonlight.Features.FileManager.UI.Components; + +namespace Moonlight.Features.FileManager.Implementations; + +public class CreateFolderAction : IFileManagerCreateAction +{ + public string Name => "Folder"; + public string Icon => "bx-folder"; + public string Color => "primary"; + + public async Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, IServiceProvider provider) + { + var alertService = provider.GetRequiredService(); + var toastService = provider.GetRequiredService(); + + var name = await alertService.Text("Enter a name for the new directory"); + + if (string.IsNullOrEmpty(name) || name.Contains("..")) + return; + + await access.CreateDirectory(name); + + await toastService.Success("Successfully created directory"); + await fileManager.View.Refresh(); + } +} \ No newline at end of file diff --git a/Moonlight/Features/FileManager/Implementations/DeleteContextAction.cs b/Moonlight/Features/FileManager/Implementations/DeleteContextAction.cs new file mode 100644 index 0000000..223509c --- /dev/null +++ b/Moonlight/Features/FileManager/Implementations/DeleteContextAction.cs @@ -0,0 +1,24 @@ +using MoonCoreUI.Services; +using Moonlight.Features.FileManager.Interfaces; +using Moonlight.Features.FileManager.Models.Abstractions.FileAccess; +using Moonlight.Features.FileManager.UI.Components; + +namespace Moonlight.Features.FileManager.Implementations; + +public class DeleteContextAction : IFileManagerContextAction +{ + public string Name => "Delete"; + public string Icon => "bxs-trash"; + public string Color => "danger"; + public Func Filter => _ => true; + + public async Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, FileEntry entry, IServiceProvider serviceProvider) + { + await access.Delete(entry); + + await fileManager.View.Refresh(); + + var toastService = serviceProvider.GetRequiredService(); + await toastService.Success("Successfully deleted item"); + } +} \ No newline at end of file diff --git a/Moonlight/Features/FileManager/Implementations/DeleteSelectionAction.cs b/Moonlight/Features/FileManager/Implementations/DeleteSelectionAction.cs new file mode 100644 index 0000000..fbc0142 --- /dev/null +++ b/Moonlight/Features/FileManager/Implementations/DeleteSelectionAction.cs @@ -0,0 +1,35 @@ +using MoonCoreUI.Services; +using Moonlight.Features.FileManager.Interfaces; +using Moonlight.Features.FileManager.Models.Abstractions.FileAccess; +using Moonlight.Features.FileManager.UI.Components; + +namespace Moonlight.Features.FileManager.Implementations; + +public class DeleteSelectionAction : IFileManagerSelectionAction +{ + public string Name => "Delete"; + public string Color => "danger"; + + public async Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, FileEntry[] entries, IServiceProvider provider) + { + var alertService = provider.GetRequiredService(); + var toastService = provider.GetRequiredService(); + + if(!await alertService.YesNo($"Do you really want to delete {entries.Length} item(s)?")) + return; + + await toastService.CreateProgress("fileManagerSelectionDelete", "Deleting items"); + + foreach (var entry in entries) + { + await toastService.ModifyProgress("fileManagerSelectionDelete", $"Deleting '{entry.Name}'"); + + await access.Delete(entry); + } + + await toastService.RemoveProgress("fileManagerSelectionDelete"); + + await toastService.Success("Successfully deleted selection"); + await fileManager.View.Refresh(); + } +} \ No newline at end of file diff --git a/Moonlight/Features/FileManager/Implementations/DownloadContextAction.cs b/Moonlight/Features/FileManager/Implementations/DownloadContextAction.cs new file mode 100644 index 0000000..6a56bee --- /dev/null +++ b/Moonlight/Features/FileManager/Implementations/DownloadContextAction.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Components; +using MoonCore.Helpers; +using MoonCoreUI.Services; +using Moonlight.Features.FileManager.Interfaces; +using Moonlight.Features.FileManager.Models.Abstractions.FileAccess; +using Moonlight.Features.FileManager.Services; +using Moonlight.Features.FileManager.UI.Components; + +namespace Moonlight.Features.FileManager.Implementations; + +public class DownloadContextAction : IFileManagerContextAction +{ + public string Name => "Download"; + public string Icon => "bxs-cloud-download"; + public string Color => "primary"; + public Func Filter => entry => entry.IsFile; + + public async Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, FileEntry entry, IServiceProvider serviceProvider) + { + var fileAccessService = serviceProvider.GetRequiredService(); + var navigation = serviceProvider.GetRequiredService(); + var toastService = serviceProvider.GetRequiredService(); + + try + { + var token = await fileAccessService.GenerateToken(access); + var url = $"/api/download?token={token}&name={entry.Name}"; + + await toastService.Info("Starting download..."); + navigation.NavigateTo(url, true); + } + catch (Exception e) + { + Logger.Warn("Unable to start download"); + Logger.Warn(e); + + await toastService.Danger("Failed to start download"); + } + } +} \ No newline at end of file diff --git a/Moonlight/Features/FileManager/Implementations/MoveContextAction.cs b/Moonlight/Features/FileManager/Implementations/MoveContextAction.cs new file mode 100644 index 0000000..b5f3a7c --- /dev/null +++ b/Moonlight/Features/FileManager/Implementations/MoveContextAction.cs @@ -0,0 +1,26 @@ +using MoonCoreUI.Services; +using Moonlight.Features.FileManager.Interfaces; +using Moonlight.Features.FileManager.Models.Abstractions.FileAccess; + +namespace Moonlight.Features.FileManager.Implementations; + +public class MoveContextAction : IFileManagerContextAction +{ + public string Name => "Move"; + public string Icon => "bx-move"; + public string Color => "info"; + public Func Filter => _ => true; + + public async Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, FileEntry entry, IServiceProvider provider) + { + await fileManager.OpenFolderSelect("Select the location to move the item to", async path => + { + var toastService = provider.GetRequiredService(); + + await access.Move(entry, path + entry.Name); + + await toastService.Success("Successfully moved item"); + await fileManager.View.Refresh(); + }); + } +} \ No newline at end of file diff --git a/Moonlight/Features/FileManager/Implementations/MoveSelectionAction.cs b/Moonlight/Features/FileManager/Implementations/MoveSelectionAction.cs new file mode 100644 index 0000000..981d983 --- /dev/null +++ b/Moonlight/Features/FileManager/Implementations/MoveSelectionAction.cs @@ -0,0 +1,33 @@ +using MoonCoreUI.Services; +using Moonlight.Features.FileManager.Interfaces; +using Moonlight.Features.FileManager.Models.Abstractions.FileAccess; + +namespace Moonlight.Features.FileManager.Implementations; + +public class MoveSelectionAction : IFileManagerSelectionAction +{ + public string Name => "Move"; + public string Color => "primary"; + + public async Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, FileEntry[] entries, IServiceProvider provider) + { + await fileManager.OpenFolderSelect("Select the location to move the items to", async path => + { + var toastService = provider.GetRequiredService(); + + await toastService.CreateProgress("fileManagerSelectionMove", "Moving items"); + + foreach (var entry in entries) + { + await toastService.ModifyProgress("fileManagerSelectionMove", $"Moving '{entry.Name}'"); + + await access.Move(entry, path + entry.Name); + } + + await toastService.RemoveProgress("fileManagerSelectionMove"); + + await toastService.Success("Successfully moved selection"); + await fileManager.View.Refresh(); + }); + } +} \ No newline at end of file diff --git a/Moonlight/Features/FileManager/Implementations/RenameContextAction.cs b/Moonlight/Features/FileManager/Implementations/RenameContextAction.cs new file mode 100644 index 0000000..996dffd --- /dev/null +++ b/Moonlight/Features/FileManager/Implementations/RenameContextAction.cs @@ -0,0 +1,30 @@ +using MoonCoreUI.Services; +using Moonlight.Features.FileManager.Interfaces; +using Moonlight.Features.FileManager.Models.Abstractions.FileAccess; +using Moonlight.Features.FileManager.UI.Components; + +namespace Moonlight.Features.FileManager.Implementations; + +public class RenameContextAction : IFileManagerContextAction +{ + public string Name => "Rename"; + public string Icon => "bxs-rename"; + public string Color => "info"; + public Func Filter => _ => true; + + public async Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, FileEntry entry, IServiceProvider provider) + { + var alertService = provider.GetRequiredService(); + var toastService = provider.GetRequiredService(); + + var newName = await alertService.Text($"Enter a new name for '{entry.Name}'", "", entry.Name); + + if (string.IsNullOrEmpty(newName)) + return; + + await access.Rename(entry.Name, newName); + + await fileManager.View.Refresh(); + await toastService.Success("Successfully renamed file"); + } +} \ No newline at end of file diff --git a/Moonlight/Features/FileManager/Interfaces/IFileManagerContextAction.cs b/Moonlight/Features/FileManager/Interfaces/IFileManagerContextAction.cs new file mode 100644 index 0000000..a12109c --- /dev/null +++ b/Moonlight/Features/FileManager/Interfaces/IFileManagerContextAction.cs @@ -0,0 +1,14 @@ +using Moonlight.Features.FileManager.Models.Abstractions.FileAccess; +using Moonlight.Features.FileManager.UI.Components; + +namespace Moonlight.Features.FileManager.Interfaces; + +public interface IFileManagerContextAction +{ + public string Name { get; } + public string Icon { get; } + public string Color { get; } + public Func Filter { get; } + + public Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, FileEntry entry, IServiceProvider provider); +} \ No newline at end of file diff --git a/Moonlight/Features/FileManager/Interfaces/IFileManagerCreateAction.cs b/Moonlight/Features/FileManager/Interfaces/IFileManagerCreateAction.cs new file mode 100644 index 0000000..87c0478 --- /dev/null +++ b/Moonlight/Features/FileManager/Interfaces/IFileManagerCreateAction.cs @@ -0,0 +1,13 @@ +using Moonlight.Features.FileManager.Models.Abstractions.FileAccess; +using Moonlight.Features.FileManager.UI.Components; + +namespace Moonlight.Features.FileManager.Interfaces; + +public interface IFileManagerCreateAction +{ + public string Name { get; } + public string Icon { get; } + public string Color { get; } + + public Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, IServiceProvider provider); +} \ No newline at end of file diff --git a/Moonlight/Features/FileManager/Interfaces/IFileManagerSelectionAction.cs b/Moonlight/Features/FileManager/Interfaces/IFileManagerSelectionAction.cs new file mode 100644 index 0000000..9dea939 --- /dev/null +++ b/Moonlight/Features/FileManager/Interfaces/IFileManagerSelectionAction.cs @@ -0,0 +1,12 @@ +using Moonlight.Features.FileManager.Models.Abstractions.FileAccess; +using Moonlight.Features.FileManager.UI.Components; + +namespace Moonlight.Features.FileManager.Interfaces; + +public interface IFileManagerSelectionAction +{ + public string Name { get; } + public string Color { get; } + + public Task Execute(BaseFileAccess access, UI.Components.FileManager fileManager, FileEntry[] entries, IServiceProvider provider); +} \ No newline at end of file diff --git a/Moonlight/Features/FileManager/Models/Abstractions/FileAccess/BaseFileAccess.cs b/Moonlight/Features/FileManager/Models/Abstractions/FileAccess/BaseFileAccess.cs index 08c5ee4..0eac0d0 100644 --- a/Moonlight/Features/FileManager/Models/Abstractions/FileAccess/BaseFileAccess.cs +++ b/Moonlight/Features/FileManager/Models/Abstractions/FileAccess/BaseFileAccess.cs @@ -2,7 +2,7 @@ namespace Moonlight.Features.FileManager.Models.Abstractions.FileAccess; -public class BaseFileAccess : IFileAccess +public class BaseFileAccess : IDisposable { private readonly IFileActions Actions; @@ -46,20 +46,31 @@ public class BaseFileAccess : IFileAccess return Task.FromResult(CurrentDirectory); } - public async Task Delete(string path) + public async Task Delete(FileEntry entry) { - var finalPath = CurrentDirectory + path; + var finalPath = CurrentDirectory + entry.Name; - await Actions.Delete(finalPath); + if(entry.IsFile) + await Actions.DeleteFile(finalPath); + else + await Actions.DeleteDirectory(finalPath); } - public async Task Move(string from, string to) + public async Task Move(FileEntry entry, string to) { - var finalPathFrom = CurrentDirectory + from; + var finalPathFrom = CurrentDirectory + entry.Name; await Actions.Move(finalPathFrom, to); } + public async Task Rename(string from, string to) + { + var finalPathFrom = CurrentDirectory + from; + var finalPathTo = CurrentDirectory + to; + + await Actions.Move(finalPathFrom, finalPathTo); + } + public async Task CreateDirectory(string name) { var finalPath = CurrentDirectory + name; @@ -102,7 +113,7 @@ public class BaseFileAccess : IFileAccess await Actions.WriteFileStream(finalPath, dataStream); } - public IFileAccess Clone() + public BaseFileAccess Clone() { return new BaseFileAccess(Actions.Clone()) { diff --git a/Moonlight/Features/FileManager/Models/Abstractions/FileAccess/IFileActions.cs b/Moonlight/Features/FileManager/Models/Abstractions/FileAccess/IFileActions.cs index abb431f..5fba42b 100644 --- a/Moonlight/Features/FileManager/Models/Abstractions/FileAccess/IFileActions.cs +++ b/Moonlight/Features/FileManager/Models/Abstractions/FileAccess/IFileActions.cs @@ -3,13 +3,14 @@ public interface IFileActions : IDisposable { public Task List(string path); - public Task Delete(string path); + public Task DeleteFile(string path); + public Task DeleteDirectory(string path); public Task Move(string from, string to); - public Task CreateDirectory(string name); - public Task CreateFile(string name); - public Task ReadFile(string name); - public Task WriteFile(string name, string content); - public Task ReadFileStream(string name); - public Task WriteFileStream(string name, Stream dataStream); + public Task CreateDirectory(string path); + public Task CreateFile(string path); + public Task ReadFile(string path); + public Task WriteFile(string path, string content); + public Task ReadFileStream(string path); + public Task WriteFileStream(string path, Stream dataStream); public IFileActions Clone(); } \ No newline at end of file diff --git a/Moonlight/Features/FileManager/Services/FileManagerInteropService.cs b/Moonlight/Features/FileManager/Services/FileManagerInteropService.cs new file mode 100644 index 0000000..977f868 --- /dev/null +++ b/Moonlight/Features/FileManager/Services/FileManagerInteropService.cs @@ -0,0 +1,41 @@ +using Microsoft.JSInterop; +using MoonCore.Attributes; +using MoonCore.Helpers; + +namespace Moonlight.Features.FileManager.Services; + +[Scoped] +public class FileManagerInteropService +{ + private readonly IJSRuntime JsRuntime; + + public SmartEventHandler OnUploadStateChanged { get; set; } = new(); + + public FileManagerInteropService(IJSRuntime jsRuntime) + { + JsRuntime = jsRuntime; + } + + public async Task InitDropzone(string id, string urlId) + { + var reference = DotNetObjectReference.Create(this); + await JsRuntime.InvokeVoidAsync("filemanager.dropzone.init", id, urlId, reference); + } + + public async Task InitFileSelect(string id, string urlId) + { + var reference = DotNetObjectReference.Create(this); + await JsRuntime.InvokeVoidAsync("filemanager.fileselect.init", id, urlId, reference); + } + + public async Task UpdateUrl(string urlId, string url) + { + await JsRuntime.InvokeVoidAsync("filemanager.updateUrl", urlId, url); + } + + [JSInvokable] + public async Task UpdateStatus() + { + await OnUploadStateChanged.Invoke(); + } +} \ No newline at end of file diff --git a/Moonlight/Features/FileManager/Services/SharedFileAccessService.cs b/Moonlight/Features/FileManager/Services/SharedFileAccessService.cs index ca6515a..e03b0e3 100644 --- a/Moonlight/Features/FileManager/Services/SharedFileAccessService.cs +++ b/Moonlight/Features/FileManager/Services/SharedFileAccessService.cs @@ -10,14 +10,14 @@ namespace Moonlight.Features.FileManager.Services; public class SharedFileAccessService { private readonly JwtService JwtService; - private readonly List FileAccesses = new(); + private readonly List FileAccesses = new(); public SharedFileAccessService(JwtService jwtService) { JwtService = jwtService; } - public Task Register(IFileAccess fileAccess) + public Task Register(BaseFileAccess fileAccess) { lock (FileAccesses) { @@ -28,7 +28,7 @@ public class SharedFileAccessService return Task.FromResult(fileAccess.GetHashCode()); } - public Task Unregister(IFileAccess fileAccess) + public Task Unregister(BaseFileAccess fileAccess) { lock (FileAccesses) { @@ -39,20 +39,20 @@ public class SharedFileAccessService return Task.CompletedTask; } - public Task Get(int id) + public Task Get(int id) { lock (FileAccesses) { var fileAccess = FileAccesses.FirstOrDefault(x => x.GetHashCode() == id); if (fileAccess == null) - return Task.FromResult(null); + return Task.FromResult(null); - return Task.FromResult(fileAccess.Clone()); + return Task.FromResult(fileAccess.Clone()); } } - public async Task GenerateToken(IFileAccess fileAccess) + public async Task GenerateToken(BaseFileAccess fileAccess) { var token = await JwtService.Create(data => { diff --git a/Moonlight/Features/FileManager/UI/Components/FileEditor.razor b/Moonlight/Features/FileManager/UI/Components/FileEditor.razor index 9faaf0b..c63ed45 100644 --- a/Moonlight/Features/FileManager/UI/Components/FileEditor.razor +++ b/Moonlight/Features/FileManager/UI/Components/FileEditor.razor @@ -34,7 +34,7 @@ { [Parameter] public FileEntry File { get; set; } - [Parameter] public IFileAccess FileAccess { get; set; } + [Parameter] public BaseFileAccess FileAccess { get; set; } [Parameter] public bool CloseOnSave { get; set; } = false; diff --git a/Moonlight/Features/FileManager/UI/Components/FileManager.razor b/Moonlight/Features/FileManager/UI/Components/FileManager.razor index de110fa..16e3701 100644 --- a/Moonlight/Features/FileManager/UI/Components/FileManager.razor +++ b/Moonlight/Features/FileManager/UI/Components/FileManager.razor @@ -1,282 +1,363 @@ -@using Moonlight.Core.Configuration @using MoonCore.Helpers @using MoonCore.Services @using MoonCoreUI.Services +@using Moonlight.Core.Configuration +@using Moonlight.Core.Services +@using Moonlight.Features.FileManager.Interfaces @using Moonlight.Features.FileManager.Models.Abstractions.FileAccess +@using Moonlight.Features.FileManager.Services @inject AlertService AlertService -@inject ConfigService ConfigService @inject ToastService ToastService +@inject FileManagerInteropService FileManagerInteropService +@inject SharedFileAccessService FileAccessService +@inject ConfigService ConfigService +@inject PluginService PluginService +@inject IServiceProvider ServiceProvider -
-
-
-
+@implements IDisposable + +
+
+
+
@{ - var elements = Path + var parts = Path .Split("/") .Where(x => !string.IsNullOrEmpty(x)) - .ToList(); + .ToArray(); - int i = 1; - var root = "/"; + var i = 1; } - / - @foreach (var element in elements) - { - var pathToCd = "/" + string.Join('/', elements.Take(i)); + / - @(element) + @foreach (var part in parts) + { + var x = i + 0; + + @(part)
/
i++; }
-
- @if (ShowFileUploader) +
+ @if (View != null && View.Selection.Any()) { - + foreach (var action in SelectionActions) + { + var cssClass = $"btn btn-{action.Color} mx-2"; + + + } } else { - - - Launch - - + + + + + }
-
- @if (ShowFileUploader) - { - - } - else if (ShowFileEditor) - { - - } - else - { - - } -
- -