Время прочтения: 10 мин.

Однажды HR предложили мне в качестве тестового задания сделать проводник на вебе. Примерное описание ТЗ содержится в заголовке. Задание меня заинтересовало.

Непродолжительный поиск в гугле ничего похожего не дал. Тем интереснее.

Для работы с файлами нам достаточно стандартных классов:

(System.IO) DriveInfo- Предоставляет информацию о дисках. Так мы узнаем какие диски подключены, их имена, емкость и свободное пространство

(System.IO) DirectoryInfo — Предоставляет информацию о папках. получение информации о вложенных папках и файлах

(System.IO) FileInfo — Предоставляет информацию о файлах. получение размера

Первое с чего предлагаю начать – создадим модельку которая смотрит подключенные диски, чтобы смотреть по каким путям нам вообще ходить, показать какие диски есть и отобразить их размеры:

Для этого в папке Models создал папку FilesModels и в ней DisksModel.cs и вставляем код:

public class DisksModel
    {
        public decimal TotalFreeSpace { get; }
        public decimal TotalSize { get; }
        public List<DriveInfo> Disks { get; }
        public DisksModel()
        {
            Disks = DriveInfo.GetDrives().Where(r => r.IsReady).Where(r => r.DriveType == DriveType.Fixed).ToList();
            TotalSize = Disks.Sum(r => r.TotalSize);
            TotalFreeSpace = Disks.Sum(r => r.TotalFreeSpace);
        }
}

В конструкторе мы получаем информацию по имеющимся дискам, выбираем только готовые к работе и не берем флэшки.

Также дополнительно, чтобы возвращать размерность в нужных единицах измерения заодно вставил статичный класс.

public static class DiskMetods
    {
        public static decimal ToKB(this decimal size, int decimals = 2)
        {
            return Math.Round(size / 1024, decimals);
        }
        public static decimal ToMB(this decimal size, int decimals = 2)
        {
            return Math.Round(size.ToKB(0) / 1024, decimals);
        }
        public static decimal ToGB(this decimal size, int decimals = 2)
        {
            return Math.Round(size.ToMB(0) / 1024, decimals);
        }
        public static decimal ToTB(this decimal size, int decimals = 2)
        {
            return Math.Round(size.ToGB(0) / 1024, decimals);
        }
        public static decimal ToKB(this long size, int decimals = 2)
        {
            return Math.Round((decimal)size / 1024, decimals);
        }
        public static decimal ToMB(this long size, int decimals = 2)
        {
            return Math.Round(size.ToKB(0) / 1024, decimals);
        }
        public static decimal ToGB(this long size, int decimals = 2)
        {
            return Math.Round(size.ToMB(0) / 1024, decimals);
        }
        public static decimal ToTB(this long size, int decimals = 2)
        {
            return Math.Round(size.ToGB(0) / 1024, decimals);
        }
    }

Создаем контролер для работы с файлами в папке FilesControllers создаем FilesController.cs

Указываем какую страницу открывать и в нем же статично получаем информацию о дисках для дальнейшего использования.

public static DisksModel disk = new DisksModel();
        [HttpGet]
        public IActionResult Files()
        {
            return View("~/Views/FilesViews/Files.cshtml");
        }

Далее в папке Views создаем папку FilesViews и страницу Files.cshtml.

И создаём там таблицы, в которых будут отображаться информация о дисках на компьютере заодно в прогресс баре указывается сколько места заполнено. И как раз используется информация из статического класса в контроллере и переводим в гигабайты за счет ранее написанных методов.

<div id="diskInfo">
    <div id="tableDisk">
        <table onclick="VieUnvie()" style="cursor: pointer">
            <tr>
                <td>
                    <img src="~/img/hd_disk_harddisk_162.png" style="height:45px;width:45px" />
                </td>
                <td>
                    <table>
                        <tr>
                            <td>
                                Все пространство дисков
                            </td>
                        </tr>
                        <tr>
                            <td>
                                <progress value="@(WebFileManager.Controllers.FilesController.disk.TotalSize.ToGB(0) - WebFileManager.Controllers.FilesController.disk.TotalFreeSpace.ToGB(0))" max="@WebFileManager.Controllers.FilesController.disk.TotalSize.ToGB(0)"></progress>
                            </td>
                        </tr>
                        <tr>
                            <td>
                                @WebFileManager.Controllers.FilesController.disk.TotalFreeSpace.ToGB() ГБ свободно из @WebFileManager.Controllers.FilesController.disk.TotalSize.ToGB() ГБ
                            </td>
                        </tr>
                    </table>
                </td>
            </tr>
        </table>
        <table id="allDisk" style="display:none; cursor: pointer">
            @{foreach (DriveInfo driveInfo in WebFileManager.Controllers.FilesController.disk.Disks)
                {
                    <tr class="DiskRow" onclick="getFolder(null,'@(driveInfo.Name+"\\")', null, false)">
                        <td width="45px">
                        </td>
                        <td>
                            <img src="~/img/hd_disk_harddisk_162.png" style="height:45px;width:45px" />
                        </td>
                        <td>
                            <table>
                                <tr>
                                    <td>
                                        локальный диск (@driveInfo.Name.Replace("\\", ""))
                                    </td>
                                </tr>
                                <tr>
                                    <td>
                                        <progress value="@(driveInfo.TotalSize.ToGB(0) - driveInfo.TotalFreeSpace.ToGB(0))" max="@driveInfo.TotalSize.ToGB(0)"></progress>
                                    </td>
                                </tr>
                                <tr>
                                    <td>
                                        @driveInfo.TotalFreeSpace.ToGB() ГБ свободно из @driveInfo.TotalSize.ToGB() ГБ
                                    </td>
                                </tr>
                            </table>
                        </td>
                    </tr>
                }
            }
        </table>
    </div>
</div>

Добавим скрипт украшательства ради для сворачивания/разворачивания дисков в/из общего объема, а onclick=»getFolder(null,’@(driveInfo.Name+»\\» встретится чуть позже.

function VieUnvie() {
        var table = document.getElementById("allDisk");
        if (table.style.display == "none") {
            table.style.display = "block";
        }
        else {
            table.style.display = "none";
        }
    }

Прописываем еще одну кнопку в _Layout.cshtml

<li class="nav-item">
                            <a class="nav-link text-dark" asp-area="" asp-controller="Files" asp-action="Files">Проводник</a>
                        </li>

Получим:

Теперь необходимо отобразить размеры файлов и папок. В этом есть проблема, т.к. в DirectoryInfo нет информации о размере, и, как я понял, тот же проводник windows просто каждый раз считает размеры файлов в папке, дальше идет по вложенным папкам и потом возвращает конечную сумму файлов (например, когда вы нажимаете свойство папки, какое-то время идет подсчет размера на ваших глазах и время подсчета зависит в первую очередь от количества файлов внутри). Так же поступим и мы.

Понадобится модель для хранения информации. Предлагаю такую:

public class FolderModel
    {
        public DirectoryInfo ThisDirectoryInfo { get; set; }
        public FileInfo[] Files { get;  }
        public List<FolderModel> Folders { get; }
        public decimal Size { get; } = 0;



        public FolderModel() { }
        public FolderModel(string path):this(new DirectoryInfo(path)) { }
        public FolderModel(DirectoryInfo directoryInfo)
        {
            ThisDirectoryInfo = directoryInfo;
            Files = directoryInfo.GetFiles();
            Folders = ThisDirectoryInfo.GetDirectories().Where(r => !r.Attributes.HasFlag(FileAttributes.System) & !r.Attributes.HasFlag(FileAttributes.Hidden)).ToArray().GetFolderModels();
            Size += Files.Sum(r => r.Length) + Folders.Sum(r => r.Size);
        }
        public static List<FolderModel> GetFolderModels()
        {
            List<FolderModel> folderModels = new List<FolderModel>();
            foreach (var el in WebFileManager.Controllers.FilesController.disk.Disks)
            {
                folderModels.Add(new FolderModel(el.Name));
            }
            //folderModels.Add(new FolderModel("C:\\"));
            return folderModels;
        }

    }

Мы получили массив файлов в папке с их размерами, рекурсивно ходим по вложенным папкам возвращая их размер (т.е. размер файлов в них), и конечную сумму размеров.

Дополнительные функции, одна из которых понадобится для получения нашей модели по указанному пути:

public static class FolderModelMetods
    {
        /// <summary>
        /// возвращает FolderModel по массиву DirectoryInfo
        /// </summary>
        /// <param name="directoryInfos"></param>
        /// <returns></returns>
        public static List<FolderModel> GetFolderModels(this DirectoryInfo[] directoryInfos)
        {
            List<FolderModel> folderModels = new List<FolderModel>();
            foreach(DirectoryInfo directoryInfo in directoryInfos)
            {
                try
                {
                    folderModels.Add(new FolderModel(directoryInfo));
                }
                catch 
                {
                }
            }
            return folderModels;
        }
        /// <summary>
        /// возвращает FolderModel по указанному пути
        /// </summary>
        /// <param name="folderModels"> откуда ищет</param>
        /// <param name="fullPath">путь по которому брать модель</param>
        /// <returns></returns>
        public static FolderModel GetCurentFolderModel(this List<FolderModel> folderModels, string fullPath)
        {
            string[] pathEls = fullPath.Split('\\').Where(r=>r!="").ToArray();
            FolderModel folderModel = folderModels.FirstOrDefault(r => r.ThisDirectoryInfo.Name.Replace("\\", "") == pathEls[0]);
            for (int i = 1; i < pathEls.Length; i++)
            {
                folderModel = folderModel.Folders.FirstOrDefault(r => r.ThisDirectoryInfo.Name.Trim() == pathEls[i].Trim());
            }
            return folderModel;
        }
    }

Здесь стоит трай катч. Это был быстрый способ для обхода ошибок связанных с чтением системных закрытых файлов и папок. Теперь вопрос: как это представить и что выводить на экран. Проводник windows обычно выводит имя, дату изменения, тип и размер файлов, значит и мы выведем такое. Первое, что пришло в голову — написать отдельную модель.

public class FolderViewModel
    {
        public string CurentPath { get; set; }
        public string Name { get; set; }
        public string Type { get; set; }
        public decimal Size { get; set; }
        public DateTime ChangeDate { get; set; }
    }

В этой модели мы как раз реализуем все то что хотим увидеть. Ну и заодно сразу добавим методы в статичном классе.

public static class FolderViewModelM
    {
        public static List<FolderViewModel> GetFolderViewModel(this List<FolderModel> folderModels, string path, string folderName, string SortName, string SortType)
        {
            List<FolderViewModel> folderViewModels;
            if(folderName==null) { folderViewModels = folderModels.GetFolderViewModel((path).Replace("\\\\", "\\")); }
            else { folderViewModels = folderModels.GetFolderViewModel((path + "\\" + folderName).Replace("\\\\", "\\")); }
            if (SortName != null)
            {
                switch (SortName)
                {
                    case "Name":
                        if (SortType == "Asc") { folderViewModels = folderViewModels.OrderBy(r => r.Name).ToList(); }
                        else { folderViewModels = folderViewModels.OrderByDescending(r => r.Name).ToList(); }
                        break;
                    case "Type":
                        if (SortType == "Asc") { folderViewModels = folderViewModels.OrderBy(r => r.Type).ToList(); }
                        else { folderViewModels = folderViewModels.OrderByDescending(r => r.Type).ToList(); }
                        break;
                    case "Size":
                        if (SortType == "Asc") { folderViewModels = folderViewModels.OrderBy(r => r.Size).ToList(); }
                        else { folderViewModels = folderViewModels.OrderByDescending(r => r.Size).ToList(); }
                        break;
                    case "ChangeDate":
                        if (SortType == "Asc") { folderViewModels = folderViewModels.OrderBy(r => r.ChangeDate).ToList(); }
                        else { folderViewModels = folderViewModels.OrderByDescending(r => r.ChangeDate).ToList(); }
                        break;
                }
            }
            return folderViewModels;
        }
        public static List<FolderViewModel> GetFolderViewModel(this List<FolderModel> folderModels, string fullPath)
        {
            FolderModel folderModel = folderModels.GetCurentFolderModel(fullPath);
            return folderModel.ToFolderViewModel();
        }
        public static List<FolderViewModel> ToFolderViewModel(this FolderModel folderModel)
        {
            List<FolderViewModel> folderViewModels = new List<FolderViewModel>();
            foreach (var folder in folderModel.Folders)
            {
                folderViewModels.Add(new FolderViewModel()
                {
                    Name = folder.ThisDirectoryInfo.Name,
                    ChangeDate = folder.ThisDirectoryInfo.LastWriteTime,
                    Type = "Folder",
                    Size = folder.Size.ToMB(),
                    CurentPath = folderModel.ThisDirectoryInfo.FullName
                });
            }
            foreach (var file in folderModel.Files)
            {
                folderViewModels.Add(new FolderViewModel()
                {
                    Name = file.Name,
                    ChangeDate = file.LastWriteTime,
                    Type = "File",
                    Size = file.Length.ToMB(),
                    CurentPath = folderModel.ThisDirectoryInfo.FullName
                });
            }
            return folderViewModels;
        }
        public static string ToJson(this List<FolderViewModel> folderViewModels)
        {
            return JsonSerializer.Serialize(folderViewModels);
        }
    }

Мы реализуем перевод в json для передачи на страницу и несколько методов перевода из предыдущей модели в нашу новую. Также в одном из методов реализована сортировка.

Надо придумать представление. Самым простым будет использование все той же таблицы. Сначала в наше ранее используемое представление (в то, где мы рисовали диски) записываем:

@using WebFileManager.Models.FilesModels;
@using System.IO;

Указываем, какую используем модель и дополнительно пространство. Теперь сделаем саму таблицу.

<div id="FileBrowser" style="width:100%; height:auto">
    <div id="PanelPath" style="width:100%">
        <div id="PanelPathButtons">
            <button id="left" onclick="Back()"><img src="~/img/left.png" width="20px" height="10px" /></button>
            <button id="right" onclick="Next()"><img src="~/img/right.png" width="20px" height="10px" /></button>
        </div>
        <div id="PathNow">@WebFileManager.Controllers.FilesController.folderModels[0].ThisDirectoryInfo.FullName</div>
    </div>
    <div id="FileManager">

        <div id="PathView" class="FileManager" style="width:30%;">
            fff
        </div>
        <div id="FilesView" class="FileManager" style="width: 70%; ">
            <table id="FilesAndFolders">
                <tr>
                    <th onclick="Sort(this)" style="cursor: pointer"> Name </th>
                    <th onclick="Sort(this)" style="cursor: pointer">Change date</th>
                    <th onclick="Sort(this)" style="cursor: pointer"> Type </th>
                    <th onclick="Sort(this)" style="cursor: pointer"> Size </th>
                </tr>
                @{
                    foreach (var folder in WebFileManager.Controllers.FilesController.folderModels[0].Folders)
                    {
                        <tr class="FileFolderRow">
                            <td>
                                <img src="~/img/folder.png" /> @folder.ThisDirectoryInfo.Name
                            </td>
                            <td>
                                @folder.ThisDirectoryInfo.LastWriteTime
                            </td>
                            <td>
                                Folder
                            </td>
                            <td>
                                @folder.Size.ToMB() MB
                            </td>
                        </tr>
                    }
                    foreach (var file in WebFileManager.Controllers.FilesController.folderModels[0].Files)
                    {
                        <tr class="FileFolderRow">
                            <td>
                                <img src="~/img/file.png" />@file.Name
                            </td>
                            <td>
                                File
                            </td>
                            <td>
                                @file.Length.ToMB() MB
                            </td>
                        </tr>
                    }
                }
                <tr>
                    <td></td>
                </tr>
            </table>
        </div>
    </div>
</div>

При открытии страницы сначала показываем содержимое первого диска из списка. Также делаем кнопки назад и вперед, место, где указываем путь.

Также функции:

function Back() {
        getFolder(null, $('#PathNow')[0].innerText, null, true);
        $("#right").prop("disabled", false)
    }

Соответственно возврат назад:

function getFolder(FN, PN, Sor, BK) {
         $.ajax({
                    url: '@Url.Action("NewFolder", "Files")',
                    data:{
                        folderName: FN
                        , pathNow: PN
                        , orderBy: Sor
                        , back:BK
                }
                    , type: 'POST'
                    , success: function (data) {
                        $(".FileFolderRow").remove();
                        GetFileFolderRow(data);
                        pathNow: $('#PathNow')[0].innerText = data[0]["CurentPath"];
                        setSes("maxPath", data[0]["CurentPath"]);
                    }
                })
    }

Получение представления по указанному пути — как раз та функция, которая встречалась еще в отображении дисков на странице.

function GetFileFolderRow(data) {
        for (var i = 0; i < data.length; i++) {
            let tr = document.createElement("tr"),
                td0 = document.createElement("td"),
                td1 = document.createElement("td"),
                td2 = document.createElement("td"),
                td3 = document.createElement("td"),
                img = document.createElement("img");
            if (data[i]["Type"] == "File") { img.src = "/img/file.png" }
            else { img.src = "/img/folder.png" }
            td0.appendChild(img);
            td0.appendChild(document.createTextNode(data[i]["Name"]));
            td1.innerText = data[i]["ChangeDate"];
            td2.innerText = data[i]["Type"];
            td3.innerText = data[i]["Size"] + " MB";
            tr.className = "FileFolderRow";
            tr.append(td0);
            tr.append(td1);
            tr.append(td2);
            tr.append(td3);
            $("#FilesAndFolders").append(tr);
        }
        $(dbClickRow())
    }

Записываем в таблицу полученную модель.

И функция для нажатия на строку в таблице. Если папка, то пытается пройти дальше, а на файл просто отвечает, что не может его открыть.

function dbClickRow () {
        $(".FileFolderRow").dblclick(function (e) {
            var newFolder = this.getElementsByTagName("td")[0].innerText.trim();
            if (this.getElementsByTagName("td")[2].innerText == "Folder") {
                getFolder(newFolder, $('#PathNow')[0].innerText, null, false)
            }
            else {
                alert("i can't open files ");
            }
        })

Добавляем совсем короткий скрипт для сортировки, который по сути так же делает запрос на получение страницы и заодно передает по какому полю сортировать.

function Sort(e) {
        getFolder(null, $('#PathNow')[0].innerText, e.innerText, false);
    }

Вспомним про метод, возвращающей модель для представления с сортировкой. Он определяет, по какому полю сортировать. Соответственно где-то надо хранить для пользователей их значения. Самым очевидным способом является сессия. Поэтому в startup.cs добавляем services.AddMvc() и app.UseSession(), выглядит примерно так:

Осталось реализовать в контролере как будем возвращать страницы.

В fileController добавляем:

public static List<FolderModel> folderModels = FolderModel.GetFolderModels();

Сам код:

[HttpPost]
        public ContentResult NewFolder(string folderName, string pathNow, string orderBy=null, bool back=false)
        {
            if (orderBy != null)
            {
                if (HttpContext.Session.GetString("OrderBy") == orderBy)
                {
                    if (HttpContext.Session.GetString("SortType") == "Asc") { HttpContext.Session.SetString("SortType", "Desc"); }
                    else { HttpContext.Session.SetString("SortType", "Asc"); }
                }
                else
                {
                    HttpContext.Session.SetString("OrderBy", orderBy);
                    HttpContext.Session.SetString("SortType", "Asc");
                }
            }
            return Content(folderModels.GetFolderViewModel(!back ? pathNow: pathNow.Substring(0, pathNow.LastIndexOf("\\")), folderName, HttpContext.Session.GetString("OrderBy"), HttpContext.Session.GetString("SortType")).ToJson(), "application/json");
        }

Если запрос приходит с указанием сортировки, то пишем в сессию по какому полю, а если такое поле уже указанно, то меняем порядок и отправляем модель.

Надеюсь, кому-то пригодится данный материал, также каждый желающий может аргументированно провести автора лицом по коду.

Весь проект лежит тут github.

Спасибо за внимание.