Nice little directory browser :D

WOAH thats a lot of changes (oops)

* fix/style: oops forgot to change scan targets
* feat/style: inline strategies needed by `_hs_tailwind.js`
* fix/style: fix (bad) powershell casing for aria-disabled
* feat/style: gray color also for .dotfile class
* feat/AppTitle: Partial attr (to plain `<title>` for use in hx responses
* feat/Breadcrumbs: own component for use in hx oob responses
* feat/GoBackButton: ditto
* fix/Header: oops kept Mizumiya's hs attr
* feat/Header(client): Enter on searchbar will click the first table row!
* fix/Header(client): replace powershell interpolation for `#file-counter`
* fix/Index: actually use _hs_tailwind (ww)
* Index: keep current path in #prop-name for hx
* Index: move around table hx attrs to reduce repetition
* feat/MainLayout: use jq
* feat/Program: file downloading!!
* feat/Utils.CheckJoinedPathIsBased: Result<DI>CheckJoinedPathIsBased -> Result<Union<DI, FI>>VerifyPath (though the Union/OneResult isn't very nice to use)
* fix/Utils.IEnumerable.RandomElement: prevent double-enumeration
* fix/Utils.FileSystemInfo.IsReadable: make sure the fsi actually exists (ww)
* fix/Utils.FileSystemInfo.ReadLink: replace libc `realpath` with just calling the `realpath` executable. frusturating but its the only thing that seems to work!!!
*feat/Utils.FileSystemInfo.UnravelLink: read link all the way until we get to a real file (just realized this has might have an infinite loop issue)
* feat/Utils.String: convenience extensions here too
* feat/Utils.OneResult: failed attempt at making the api nice to use

helpimnotdrowning.net c9a953ad cc7ac509

verified
+421 -127
+8 -1
Components/AppTitle.razor
··· 15 15 along with Utatane. If not, see <http://www.gnu.org/licenses/>. 16 16 *@ 17 17 18 - <PageTitle>@( ChildContent == null ? "<somewhere???>" : ChildContent.RenderString() ) :: helpimnotdrowning.net</PageTitle> 18 + @if (Partial) { 19 + <title>@( ChildContent == null ? "<somewhere???>" : ChildContent.RenderString() ) :: helpimnotdrowning.net</title> 20 + } else { 21 + <PageTitle>@( ChildContent == null ? "<somewhere???>" : ChildContent.RenderString() ) :: helpimnotdrowning.net</PageTitle> 22 + } 19 23 20 24 @code { 21 25 [Parameter] 22 26 public RenderFragment? ChildContent { get; set; } 27 + 28 + [Parameter] 29 + public bool Partial { get; set; } 23 30 }
+33
Components/Breadcrumbs.razor
··· 1 + <div id="breadcrumbs" @attributes="AdditionalAttributes"> 2 + <a href="/" class="clickable" hx-boost="true">/</a> 3 + @for (var i = 0; i < _pathFragments.Count; i++) { 4 + var currentCrumbLink = String.Join('/', _pathFragments[..(i+1)]); 5 + 6 + <a href="/@currentCrumbLink" class="clickable" hx-boost="true"> 7 + @_pathFragments[i] 8 + </a> 9 + 10 + if (i != _pathFragments.Count) { 11 + <span>/</span> 12 + } 13 + } 14 + </div> 15 + 16 + @code { 17 + [Parameter] 18 + public required String Path { get; set; } 19 + 20 + private List<String> _pathFragments; 21 + 22 + [Parameter(CaptureUnmatchedValues = true)] 23 + public IDictionary<String, Object>? AdditionalAttributes { get; set; } 24 + 25 + protected override Task OnInitializedAsync() { 26 + _pathFragments = Path 27 + .ReplaceRegex(@"/$", "") 28 + .Split('/', StringSplitOptions.RemoveEmptyEntries) 29 + .ToList(); 30 + 31 + return base.OnInitializedAsync(); 32 + } 33 + }
+29
Components/GoBackButton.razor
··· 1 + <a id="go-back" 2 + class="clickable" 3 + href="@(_isAtRoot ? null : "..")" 4 + aria-label="Go up 1 directory" 5 + hx-boost="true" 6 + aria-disabled="@(_isAtRoot ? "true" : "false")" 7 + tabindex="@(_isAtRoot ? -1 : 0)" 8 + onClick="@(_isAtRoot ? "event.preventDefault();" : null)" 9 + @attributes="AdditionalAttributes" 10 + > 11 + <span class="cursor-default! [vertical-align:sub]">../</span> 12 + </a> 13 + <button>hi</button> 14 + 15 + @code { 16 + [Parameter] 17 + public required String Path { get; set; } 18 + 19 + [Parameter(CaptureUnmatchedValues = true)] 20 + public IDictionary<String, Object>? AdditionalAttributes { get; set; } 21 + 22 + private bool _isAtRoot; 23 + 24 + protected override Task OnInitializedAsync() { 25 + _isAtRoot = Path == "/"; 26 + 27 + return base.OnInitializedAsync(); 28 + } 29 + }
+27 -37
Components/Header.razor
··· 16 16 *@ 17 17 18 18 <header class="n-box"> 19 - <div id="breadcrumbs"> 20 - <a href="/" class="clickable" hx-boost="true">/</a> 21 - @{ var pathFragments = Path.Split('/').Skip(1).SkipLast(1).ToList(); } 22 - @for (var i = 0; i < pathFragments.Count; i++) { 23 - var currentCrumbLink = String.Join('/', pathFragments[..i]); 24 - <a href="@currentCrumbLink" class="clickable" hx-boost="true">@pathFragments[i]</a> 25 - if (i == pathFragments.Count) { 26 - <span>/</span> 27 - } 28 - } 29 - </div> 19 + <Breadcrumbs Path="@Path" /> 30 20 <div id="toolbar"> 31 21 <button id="reload-table" 32 - class="clickable" 33 - type="button" 34 - aria-label="Reload table" 35 - _hs=" 22 + class="clickable" 23 + type="button" 24 + aria-label="Reload table" 25 + _=" 36 26 -- shortcut: only the active <th> will have aria-sort 37 27 on click send nhnd:run to <th[aria-sort]/> 38 28 " 39 29 > 40 30 <span class="[vertical-align:sub]">↻</span> 41 31 </button> 42 - @{ var isAtRoot = Path == "/"; } 43 - <a id="go-back" 44 - class="clickable" 45 - href="@(isAtRoot ? null : "..")" 46 - aria-label="Go up 1 directory" 47 - hx-boost="true" 48 - aria-disabled="@isAtRoot" 49 - tabindex="@(isAtRoot ? -1 : 0)" 50 - onClick="@(isAtRoot ? "event.preventDefault();" : null)" 51 - > 52 - <span class="cursor-default! [vertical-align:sub]">../</span> 53 - </a> 32 + 33 + <GoBackButton Path="@Path"/> 54 34 55 35 <abbr id="search-label" title="Search">🔎</abbr> 56 - <input id="search" class="border-black" type="search" aria-labelledby="search-label" _hs="@_scriptForIdSearch" /> 36 + <input id="search" class="border-black" type="search" aria-labelledby="search-label" _="@_scriptForIdSearch" /> 57 37 58 38 <abbr id="rx-label" title="Search with RegEx">.*</abbr> 59 - <input id="use-rx-for-search" type="checkbox" autocomplete="off" checked aria-labelledby="rx-label" _hs="@_scriptForIdUseRxForSearch" /> 39 + <input id="use-rx-for-search" type="checkbox" autocomplete="off" checked aria-labelledby="rx-label" _="@_scriptForIdUseRxForSearch" /> 60 40 61 - <span id="file-counter" class="my-auto" _hs="@_scriptForIdFileCounter"></span> 41 + <span id="file-counter" class="my-auto" _="@_scriptForIdFileCounter" /> 62 42 </div> 63 43 </header> 64 44 65 45 @code { 66 46 67 - [Parameter] public required String Path { get; set; } 68 - 47 + [Parameter] 48 + public required String Path { get; set; } 49 + 69 50 #region _hyperscript strings 70 51 // these are just the big ones, smaller ones are littered throughout 71 52 private readonly String _scriptForIdSearch = @" ··· 78 59 otherwise 79 60 show <tbody > tr/> in #filetable when (the first @data-order of .file-name in it) contains my value.toLowerCase() 80 61 end 62 + on keydown[key==""Enter""] 63 + if event.isComposing or event.keyCode really equals 229 then 64 + exit 65 + otherwise 66 + send click to the first <#filetable-body > tr:not(.hidden) > .file-name > a/> 67 + set my value to """" 68 + end 81 69 on keydown from body 82 70 make a RegExp from ""^(F|Soft)\\d"" called rx 83 71 if not ( ··· 98 86 ) 99 87 go to the top of the body 100 88 focus() on me 101 - 102 89 "; 103 90 104 91 private readonly String _scriptForIdUseRxForSearch = @" ··· 113 100 114 101 private readonly String _scriptForIdFileCounter = @" 115 102 on nhnd:update 116 - set files to `$('.file').length 117 - set dirs to `$('.directory').length 118 - set my innerHTML to ```${dirs} $(_icon 'Directories' '📁') `${files} $(_icon 'Files' '📄')`` 119 - "; 103 + set files to $('.file').length 104 + set dirs to $('.directory').length 105 + set my innerHTML to `${dirs} __W ${files} __X` 106 + " 107 + .Replace("__W", Utils.AbbrIconPlain("Directories", "📁")) 108 + .Replace("__X", Utils.AbbrIconPlain("Files", "📄")); 109 + 120 110 #endregion _hyperscript strings 121 111 122 112 }
+1
Components/Layout/MainLayout.razor
··· 26 26 <link rel="stylesheet" type="text/css" href="/.nhnd/style.css" /> 27 27 28 28 <script src="/.nhnd/htmx.js"></script> 29 + <script src="/.nhnd/jquery.js"></script> 29 30 <meta name="htmx-config" content='{"scrollIntoViewOnBoost":false}' /> 30 31 31 32 <meta name="darkreader-lock" />
+30 -17
Components/Pages/Index.razor
··· 20 20 @* <PageTitle>Index</PageTitle> *@ 21 21 <HeadContent> 22 22 <script src="/.nhnd/_hyperscript.js"></script> 23 + <script src="/.nhnd/_hs_tailwind.js"></script> 24 + 23 25 <script type="text/hyperscript"> 24 26 def get_dir(x) 25 27 if not x ··· 59 61 <div class="n-box flex flex-row"> 60 62 <div id="main"> 61 63 <form id="sorting-on" class="hidden"> 62 - <input autocomplete="off" type="radio" name="sort-by" value="name" id="prop-name" checked /> 63 - <input autocomplete="off" type="radio" name="sort-by" value="size" id="prop-size" /> 64 - <input autocomplete="off" type="radio" name="sort-by" value="time" id="prop-time" /> 64 + <input autocomplete="off" type="text" name="path" value="@Path" id="prop-path" /> 65 65 66 - <input autocomplete="off" type="checkbox" name="sort-asc" checked /> 66 + <input autocomplete="off" type="radio" name="sort-by" value="name" id="prop-name" checked/> 67 + <input autocomplete="off" type="radio" name="sort-by" value="size" id="prop-size"/> 68 + <input autocomplete="off" type="radio" name="sort-by" value="time" id="prop-time"/> 69 + 70 + <input autocomplete="off" type="checkbox" name="sort-asc" checked/> 67 71 </form> 68 72 69 - <table id="filetable" _="on htmx:afterSettle send nhnd:update to #file-counter"> 73 + <table 74 + id="filetable" 75 + hx-swap="innerHtml" 76 + hx-target="#filetable tbody" 77 + hx-include="#sorting-on input" 78 + hx-select="table tbody tr" 79 + _="on htmx:afterSettle send nhnd:update to #file-counter" 80 + > 70 81 <thead> 71 - <tr hx-include="#sorting-on input"> 82 + <tr> 72 83 @{ String InstallSort(String x) => $"install sort_table(property: '{x}') end"; } 73 84 <th id="filetable-head-dl" class="pointer-events-none" tabindex="0"> 74 85 <span class="visually-hidden">Download link</span> ··· 79 90 </th> 80 91 81 92 <th id="filetable-head-name" 93 + hx-get="/api/files" 82 94 hx-trigger="load from:document, nhnd:run" 83 - hx-get="/api/files?path=@Path" 84 - hx-target="#filetable tbody" 85 - hx-swap="innerHtml" 86 95 hx-indicator="#filetable-body, #name-spinner, #filetable-head-name" 87 96 aria-sort="ascending" 88 97 _="@InstallSort("name")" ··· 96 105 </th> 97 106 98 107 <th id="filetable-head-size" 108 + hx-get="/api/files" 99 109 hx-trigger="nhnd:run" 100 - hx-get="/api/files?path=@Path" 101 - hx-target="#filetable tbody" 102 - hx-swap="innerHtml" 103 110 hx-indicator="#filetable-body, #name-spinner, #filetable-head-size" 104 111 _="@InstallSort("size")" 105 112 > ··· 112 119 </th> 113 120 114 121 <th id="filetable-head-time" 122 + hx-get="/api/files" 115 123 hx-trigger="nhnd:run" 116 - hx-get="/api/files?path=@Path" 117 - hx-target="#filetable tbody" 118 - hx-swap="innerHtml" 119 124 hx-indicator="#filetable-body, #name-spinner, #filetable-head-time" 120 125 _="@InstallSort("time")" 121 126 > ··· 128 133 </th> 129 134 </tr> 130 135 </thead> 131 - <tbody id="filetable-body" class="htmx-indicator"> 136 + <tbody id="filetable-body" 137 + class="htmx-indicator" 138 + hx-swap="scroll:top" 139 + _="on htmx:beforeRequest(target) set #prop-path's value to the target" 140 + > 132 141 @* pregen rows to prevent massive layout shift *@ 133 142 @foreach (FileSystemInfo fsi in _joinedPath.EnumerateFileSystemInfos()) { 134 143 <tr> ··· 153 162 } 154 163 155 164 private DirectoryInfo _joinedPath; 165 + 166 + private readonly String _scriptForIdFiletableBody = @" 167 + on htmx:beforeRequest(target) set #prop-path's value to the target 168 + "; 156 169 157 170 protected override Task OnInitializedAsync() { 158 171 // we checked in middleware dw 159 - _joinedPath = Utils.CheckJoinedPathIsBased(Path).Value; 172 + _joinedPath = Utils.VerifyPath(Path).Value.AsT1(); 160 173 161 174 return base.OnInitializedAsync(); 162 175 }
+70 -37
Components/Pages/api/Files.razor
··· 16 16 *@ 17 17 18 18 @page "/api/files" 19 + @using System.Diagnostics.CodeAnalysis 19 20 @layout EmptyLayout 20 21 22 + <AppTitle Partial>@( Path == "/" ? "/" : Path.Split('/').Last() )</AppTitle> 23 + 24 + <input autocomplete="off" type="text" name="path" value="@Path" id="prop-path" hx-swap-oob="true" /> 25 + 26 + <Breadcrumbs Path="@Path" hx-swap-oob="true"/> 27 + 28 + <GoBackButton Path="@Path" hx-swap-oob="true" /> 29 + 30 + <table> 31 + <tbody> 21 32 @foreach (var row in _rows) { 22 - <tr class="fsobject @row.StringType"> 33 + <tr class="fsobject @row.TypeString"> 23 34 <td> 24 35 @if (row.IsFile && !row.IsBad) { 25 36 <a 26 37 download 27 38 class="file-dl clickable" 28 39 href="@row.Href" 29 - aria-label="Download file" 30 - >⭳</a> 40 + aria-label="Download file">⭳</a> 31 41 } 32 42 </td> 33 43 <td>@row.Icon</td> ··· 37 47 @(row.Name + row.Trail) 38 48 </s> 39 49 } else { 40 - <a href="@(row.Href + row.Trail)" hx-boost="@(!row.IsFile)"> 41 - @row.Name 42 - </a> 43 - @if (!row.IsFile) { 44 - <span class="dir-slash">/</span> 50 + @if (row.IsFile) { 51 + <a class="@(row.IsDotFile ? "dotfile" : null)" 52 + href="@(row.Href + row.Trail)" 53 + > 54 + @row.Name 55 + </a> 56 + } else { 57 + <a class="@(row.IsDotFile ? "dotfile" : null)" 58 + href="@(row.Href + row.Trail)" 59 + hx-get="/api/files?path=@row.Href" 60 + hx-target="#filetable tbody" 61 + hx-swap="innerHtml scroll:top" 62 + hx-push-url="@(row.Href + row.Trail)" 63 + hx-indicator="#filetable-body, #name-spinner, #filetable-head-time" 64 + > 65 + @row.Name<span class="dir-slash">/</span> 66 + </a> 45 67 } 46 68 } 47 69 </td> ··· 50 72 @if (row.IsBad) { 51 73 <s>@Utils.FormatFileSize(row.Size)</s> 52 74 } else { 53 - @Utils.FormatFileSize(row.Size); 75 + @Utils.FormatFileSize(row.Size) 76 + ; 54 77 } 55 78 } 56 79 </td> 57 80 <td class="file-date" data-order="@row.TimeUnix"> 58 81 @if (row.IsBad) { 59 - <s><time>@row.TimeFmt</time></s> 82 + <s><time>@row.TimeFmt</time></s> 60 83 } else { 61 84 <time>@row.TimeFmt</time> 62 85 } 63 86 </td> 64 87 </tr> 65 88 } 89 + @* </tbody> *@ 90 + </tbody> 91 + </table> 66 92 67 93 @code { 68 94 ··· 81 107 private IEnumerable<FileRow> _rows; 82 108 83 109 protected override Task OnInitializedAsync() { 84 - // _path = Path == "/" ? "/" : $"/{Path}"; 110 + if (String.IsNullOrEmpty(Path)) 111 + Path = "/"; 112 + else if (Path.EndsWith("/") && Path != "/") 113 + Path = Path.TrimEnd('/'); 114 + 85 115 // prechecked in middleware! 86 - _realPath = Utils.CheckJoinedPathIsBased(Path ?? "/").Value; 116 + _realPath = Utils.VerifyPath(Path).Value.AsT1(); 87 117 _sortByColumn = SortByColumn switch { 88 118 "name" => SortByColumns.Name, 89 119 "size" => SortByColumns.Size, ··· 95 125 _ => SortByDirection.Descending 96 126 97 127 }; 98 - _rows = _realPath.EnumerateFileSystemInfos() 99 - .Select(fsi => new FileRow(fsi!, Path ?? "/")) 128 + _rows = _realPath.EnumerateFileSystemInfos() 129 + .Select(fsi => new FileRow(fsi, Path ?? "/")) 100 130 .OrderByDescending(x => !x.IsFile) 101 131 .ThenBy(x => x.Name); 102 132 103 - Console.WriteLine($"Path <{Path}> | Col <{SortByColumn}> | Dir <{SortAsc}>"); 104 - Console.WriteLine($"RealPath <{_realPath}> | Col <{_sortByColumn}> | Dir <{_sortByDirection}>"); 133 + // Console.WriteLine($"Path <{Path}> | Col <{SortByColumn}> | Dir <{SortAsc}>"); 134 + // Console.WriteLine($"RealPath <{_realPath}> | Col <{_sortByColumn}> | Dir <{_sortByDirection}>"); 105 135 106 136 return base.OnInitializedAsync(); 107 137 } ··· 118 148 } 119 149 120 150 public class FileRow { 121 - public FileSystemInfo Fsi { get; } 122 - public bool IsFile { get; } 123 - public String StringType { get; } 124 - public bool IsBad { get; } 125 - public MarkupString Icon { get; } 126 - public String Name { get; } 127 - public long Size { get; } 128 - public String TimeFmt { get; } 129 - public String TimeUnix { get; } 130 - public String? Href { get; } 131 - public char? Trail { get; } 151 + public required FileSystemInfo Fsi { get; init; } 152 + public required bool IsFile { get; init; } 153 + public required bool IsBad { get; init; } 154 + public required String TypeString { get; init; } 155 + public required MarkupString Icon { get; init; } 156 + public required String Name { get; init; } 157 + public required bool IsDotFile { get; init; } 158 + public required long Size { get; init; } 159 + public required String TimeFmt { get; init; } 160 + public required String TimeUnix { get; init; } 161 + public required String? Href { get; init; } 162 + public required char? Trail { get; init; } 132 163 164 + [SetsRequiredMembers] 133 165 public FileRow(FileSystemInfo baseFsi, String currentPath) { 134 166 bool isLink = baseFsi.Attributes.HasFlag(FileAttributes.ReparsePoint); 135 167 136 - Fsi = isLink ? baseFsi.ReadLink()! : baseFsi; 137 - 168 + // Fsi = isLink ? baseFsi.ReadLink()! : baseFsi; 169 + Fsi = baseFsi.UnravelLink(); 138 170 IsFile = Fsi is FileInfo; 139 - StringType = IsFile ? "file" : "directory"; 140 171 IsBad = !Fsi.IsReadable(); 172 + TypeString = IsFile ? "file" : "directory"; 141 173 Icon = IsBad 142 - ? Utils.AbbrIcon($"Server does not have permission to read this {StringType}", "⚠️") 174 + ? Utils.AbbrIcon($"Server does not have permission to read this {TypeString}", "⚠️") 143 175 : Utils.GetIconForFileType(Fsi); 144 176 Name = baseFsi.Name; // specifically want whatever its called in the directory we're looking at 145 - Size = IsFile ? ((FileInfo)Fsi).Length : -1; 146 - TimeFmt = Fsi.LastWriteTime.ToUniversalTime().ToString("yyyy-MM-dd HH:mm"); 147 - TimeUnix = $"{Fsi.LastWriteTime.ToUniversalTime().Subtract(DateTime.UnixEpoch).TotalSeconds:N0}"; 177 + IsDotFile = Name.StartsWith('.'); 178 + Size = IsBad || !IsFile ? -1 : ((FileInfo)Fsi).Length; 179 + TimeFmt = baseFsi.LastWriteTime.ToUniversalTime().ToString("yyyy-MM-dd HH:mm"); 180 + TimeUnix = $"{baseFsi.LastWriteTime.ToUniversalTime().Subtract(DateTime.UnixEpoch).TotalSeconds:N0}"; 148 181 Href = IsBad 149 182 ? null 150 183 : System.IO.Path.Join(currentPath, Name); 151 184 Trail = IsFile ? null : '/'; 152 185 153 - if (isLink) { 154 - Console.WriteLine($"name {Name} isfile {IsFile} size {Size}"); 155 - } 186 + // if (isLink) { 187 + // Console.WriteLine($"name {Name} isfile {IsFile} size {Size}"); 188 + // } 156 189 } 157 190 158 191 }
+23 -5
Program.cs
··· 17 17 18 18 using System.IO.Compression; 19 19 using System.Text; 20 - using FluentResults; 21 - using Microsoft.AspNetCore.Http.Features; 22 20 using Utatane.Components; 23 21 using Microsoft.AspNetCore.ResponseCompression; 24 22 using Microsoft.AspNetCore.Rewrite; 25 - using Microsoft.Extensions.FileProviders; 26 23 using Utatane; 27 24 28 25 var builder = WebApplication.CreateBuilder(new WebApplicationOptions() { ··· 62 59 app.UseAntiforgery(); 63 60 app.UseResponseCompression(); 64 61 62 + // HTML compression 65 63 app.Use(async (context, next) => { 66 64 // context.Response.Body is a direct line to the client, so 67 65 // swap it out for our own in-memory stream for now ··· 103 101 return; 104 102 } 105 103 106 - var pathCheck = Utils.CheckJoinedPathIsBased(context.Request.Path); 107 - if (pathCheck.IsFailed) { 104 + var resolved = Utils.VerifyPath(context.Request.Path); 105 + 106 + if (resolved.IsFailed) { 108 107 context.Response.StatusCode = StatusCodes.Status404NotFound; 109 108 return; 110 109 } 110 + 111 + // if we're a file AND we aren't a link to a directory 112 + if (resolved.Value.IsT2 && resolved.Value.AsT2().UnravelLink() is not DirectoryInfo) { 113 + FileInfo file = new FileInfo(resolved.Value.AsT2().UnravelLink().FullName); 114 + 115 + await Results.File( 116 + file.FullName, 117 + MimeMapping.MimeUtility.GetMimeMapping(file.Name), 118 + file.Name, 119 + lastModified: file.LastWriteTimeUtc, 120 + enableRangeProcessing: true 121 + ).ExecuteAsync(context); 122 + 123 + return; 124 + } 125 + 126 + // Console.WriteLine($"dir={resolved.Value.IsT1} file={resolved.Value.IsT2}"); 127 + 128 + // we are either a directory or a link to one 111 129 112 130 await next(context); 113 131 });
+7 -3
Tailwind/style.tw.css
··· 52 52 @import "../public/font/IBMPlexSansJP-Regular/IBMPlexSansJP-Regular.css"; 53 53 @import "../public/font/IBMPlexSansJP-Bold/IBMPlexSansJP-Bold.css"; 54 54 55 - @source "../**/*.{ps1,html,css}"; 55 + @source "../**/*.{razor,cs,html,css,md}"; 56 56 @source not "../public"; 57 + 58 + @source inline("hidden"); 59 + @source inline("invisible"); 60 + @source inline("opacity-0"); 57 61 58 62 @theme { 59 63 --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, ··· 136 140 @apply bg-gray-200 dark:bg-zinc-400; 137 141 } 138 142 139 - &:disabled, &[aria-disabled="True"] { 143 + &:disabled, &[aria-disabled="true"] { 140 144 @apply 141 145 text-stone-400 142 146 border-stone-400 ··· 336 340 w-full 337 341 table-fixed; 338 342 339 - s { 343 + s, .dotfile { 340 344 @apply text-current/40; 341 345 } 342 346
+4
Utatane.csproj
··· 19 19 <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory> 20 20 <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> 21 21 </Content> 22 + <Content Include="Tailwind\style.tw.css" /> 23 + <Content Include="Tailwind\style.tw.css" /> 22 24 </ItemGroup> 23 25 24 26 <Target Name="UsePublicNotWwwroot" AfterTargets="Publish"> ··· 27 29 28 30 <ItemGroup> 29 31 <PackageReference Include="FluentResults" Version="4.0.0" /> 32 + <PackageReference Include="Functional.DiscriminatedUnion" Version="1.1.2" /> 33 + <PackageReference Include="MimeMapping" Version="4.0.0" /> 30 34 <PackageReference Include="NUglify" Version="1.21.17" /> 31 35 <PackageReference Include="Tailwind.Hosting" Version="1.2.4" /> 32 36 <PackageReference Include="Tailwind.Hosting.Build" Version="1.2.4">
+112 -24
Utils.cs
··· 17 17 18 18 #pragma warning disable BL0006 19 19 20 + using System.Diagnostics; 20 21 using System.Runtime.InteropServices; 21 22 using System.Text; 23 + using System.Text.RegularExpressions; 22 24 using FluentResults; 25 + using Functional.DiscriminatedUnion; 23 26 using Microsoft.AspNetCore.Components; 24 27 using Microsoft.AspNetCore.Components.Rendering; 25 28 using Microsoft.AspNetCore.Components.RenderTree; ··· 52 55 return Uglify.Html(html, HtmlSettings).Code ?? String.Empty; 53 56 } 54 57 55 - public static Result<DirectoryInfo> CheckJoinedPathIsBased(String path) { 56 - var resolved = new DirectoryInfo(Path.Join(Root, path)); 58 + public static Result<OneResult<DirectoryInfo, FileInfo>> VerifyPath(String path) { 59 + if (DoNotServe.Contains(path)) 60 + return Result.Fail($"Do Not Serve: {path}"); 61 + 62 + String fullPath = Path.GetFullPath(Path.Join(Root, path)); 63 + 64 + if (!fullPath.StartsWith(Root)) 65 + return Result.Fail($"Outside root: {path} -> {fullPath}"); 66 + 67 + var maybeDir = new DirectoryInfo(fullPath); 68 + if (maybeDir.Exists) 69 + return Result.Ok<OneResult<DirectoryInfo, FileInfo>>(maybeDir); 70 + 71 + var maybeFile = new FileInfo(fullPath); 72 + if (maybeFile.Exists) 73 + return Result.Ok<OneResult<DirectoryInfo, FileInfo>>(maybeFile); 57 74 58 - if ( 59 - !resolved.Exists 60 - || !resolved.FullName.StartsWith(Root) 61 - || DoNotServe.Contains(resolved.Name) 62 - ) { 63 - return Result.Fail("Bad path"); 64 - } 65 - 66 - return Result.Ok(resolved); 75 + return Result.Fail($"Does not exist: {path} -> {fullPath}"); 67 76 } 68 77 69 78 public static String FormatFileSize(Int64 size) { ··· 84 93 } 85 94 86 95 public static MarkupString AbbrIcon(String text, String symbol) { 96 + return (MarkupString)AbbrIconPlain(text, symbol); 97 + } 98 + 99 + public static String AbbrIconPlain(String text, String symbol) { 87 100 RenderTreeBuilder renderer = new RenderTreeBuilder(); 88 101 89 102 var abbr = new TagBuilder("abbr"); ··· 93 106 using var writer = new System.IO.StringWriter(); 94 107 abbr.WriteTo(writer, System.Text.Encodings.Web.HtmlEncoder.Default); 95 108 96 - return (MarkupString)writer.ToString(); 109 + return writer.ToString(); 97 110 } 98 - 111 + 99 112 private static readonly Dictionary<String, MarkupString> ExtToAbbr = new Dictionary<String, MarkupString> { 100 113 #region Extension <--> Icon mappings 101 114 { ".avc", AbbrIcon("Video file", "🎞️") }, ··· 270 283 271 284 extension<T>(IEnumerable<T> enumerable) { 272 285 public T RandomElement() { 273 - int index = (new Random()).Next(0, enumerable.Count()); 274 - return enumerable.ElementAt(index); 286 + var list = enumerable.ToList(); 287 + int index = (new Random()).Next(0, list.Count); 288 + return list.ElementAt(index); 275 289 } 276 290 } 277 291 ··· 306 320 } 307 321 308 322 public bool IsReadable() { 323 + if (!fsi.Exists) { 324 + return false; 325 + } 326 + 309 327 if (fsi is DirectoryInfo dir) { 310 328 return dir.EuidAccess_R_X(); 311 329 } ··· 322 340 } 323 341 324 342 if (OperatingSystem.IsWindows()) { 325 - return fsi.ResolveLinkTarget(true)!; 343 + return fsi.ResolveLinkTarget(true); 326 344 } 345 + 346 + Process rp = new Process { 347 + StartInfo = new ProcessStartInfo("realpath", ["-m", fsi.FullName]) { 348 + UseShellExecute = false, 349 + RedirectStandardOutput = true 350 + } 351 + }; 327 352 328 - String? real = realpath(fsi.FullName, IntPtr.Zero); 329 - if (real == null) { 330 - // realpath(3): ... otherwise, it returns NULL, the contents of the array 331 - // resolved_path are undefined, and errno is set to indicate the error. 332 - int errno = Marshal.GetLastPInvokeError(); 333 - String msg = Marshal.GetLastPInvokeErrorMessage(); 334 - throw new Exception($"realpath for {fsi.FullName} failed with errno {errno}: {msg}"); 335 - } 353 + rp.Start(); 354 + var real = rp.StandardOutput.ReadLine(); 355 + rp.WaitForExit(); 336 356 337 357 if (Directory.Exists(real)) { 338 358 return new DirectoryInfo(real); ··· 341 361 // the file at real might not actually exist, but we want to return it anyways 342 362 return new FileInfo(real); 343 363 } 364 + 365 + // TODO: uhh this might loop if the target doesnt exist 366 + public FileSystemInfo UnravelLink() { 367 + FileSystemInfo floor = fsi ?? throw new ArgumentNullException(nameof(fsi)); 368 + 369 + while (true) { 370 + if (floor!.ReadLink() != null) { 371 + floor = floor!.ReadLink()!; 372 + } else { 373 + return floor; 374 + } 375 + } 376 + 377 + } 344 378 } 345 379 346 380 extension(RenderFragment fragment) { ··· 368 402 return builder.ToString(); 369 403 } 370 404 } 405 + 406 + extension(String str) { 407 + public String ReplaceRegex(String regex, String replacement) { 408 + return Regex.Replace(str, regex, replacement); 409 + } 410 + 411 + public String ReplaceRegex(String regex, MatchEvaluator replacement) { 412 + return Regex.Replace(str, regex, replacement); 413 + } 414 + 415 + public bool Matches(String regex) { 416 + return Regex.IsMatch(str, regex); 417 + } 418 + } 419 + 420 + // extension(OneResult<T1> oneResult) { 421 + // public bool Is<Tx>() where Tx : T1 { 422 + // return typeof(Tx) is typeof(T1) ? oneResult.IsT1 : false; 423 + // } 424 + // 425 + // public Tx As<Tx>() where Tx : T1 { 426 + // return oneResult.IsT1 ? (Tx)oneResult.AsT1() : throw new InvalidOperationException($"Cannot cast value to type {nameof(Tx)}"); 427 + // } 428 + // } 429 + // 430 + // extension(OneResult<T1, T2> oneResult) { 431 + // public bool Is<Tx>() where Tx : T2 { 432 + // return typeof(Tx) is typeof(T2) ? oneResult.IsT2 : false; 433 + // } 434 + // 435 + // public Tx As<Tx>() where Tx : T2 { 436 + // return oneResult.IsT2 ? (Tx)oneResult.AsT2() : throw new InvalidOperationException($"Cannot cast value to type {nameof(Tx)}"); 437 + // } 438 + // } 439 + // 440 + // extension(OneResult<T1, T2, T3> oneResult) { 441 + // public bool Is<Tx>() where Tx : T3 { 442 + // return typeof(Tx) is typeof(T3) ? oneResult.IsT3 : false; 443 + // } 444 + // 445 + // public Tx As<Tx>() where Tx : T3 { 446 + // return oneResult.IsT3 ? (Tx)oneResult.AsT3() : throw new InvalidOperationException($"Cannot cast value to type {nameof(Tx)}"); 447 + // } 448 + // } 449 + // 450 + // extension(OneResult<T1, T2, T3, T4> oneResult) { 451 + // public bool Is(Type tx) where Tx : T4 { 452 + // return typeof(Tx) is typeof(T4) ? oneResult.IsT4 : false; 453 + // } 454 + // 455 + // public Tx As<Tx>() where Tx : T4 { 456 + // return oneResult.IsT4 ? (Tx)oneResult.AsT4() : throw new InvalidOperationException($"Cannot cast value to type {nameof(Tx)}"); 457 + // } 458 + // } 371 459 }
+77 -3
public/style.css
··· 25 25 --color-zinc-500: oklch(55.2% 0.016 285.938); 26 26 --color-zinc-600: oklch(44.2% 0.017 285.786); 27 27 --color-stone-400: oklch(70.9% 0.01 56.259); 28 + --color-stone-500: oklch(55.3% 0.013 58.071); 28 29 --color-black: #000; 29 30 --color-white: #fff; 30 31 --spacing: 0.25rem; ··· 188 189 display: none !important; 189 190 } 190 191 } 191 - @layer utilities; 192 + @layer utilities { 193 + .pointer-events-none { 194 + pointer-events: none; 195 + } 196 + .invisible { 197 + visibility: hidden; 198 + } 199 + .visible { 200 + visibility: visible; 201 + } 202 + .absolute { 203 + position: absolute; 204 + } 205 + .fixed { 206 + position: fixed; 207 + } 208 + .static { 209 + position: static; 210 + } 211 + .container { 212 + width: 100%; 213 + @media (width >= 40rem) { 214 + max-width: 40rem; 215 + } 216 + @media (width >= 48rem) { 217 + max-width: 48rem; 218 + } 219 + @media (width >= 64rem) { 220 + max-width: 64rem; 221 + } 222 + @media (width >= 80rem) { 223 + max-width: 80rem; 224 + } 225 + @media (width >= 96rem) { 226 + max-width: 96rem; 227 + } 228 + } 229 + .my-auto { 230 + margin-block: auto; 231 + } 232 + .contents { 233 + display: contents; 234 + } 235 + .flex { 236 + display: flex; 237 + } 238 + .hidden { 239 + display: none; 240 + } 241 + .table { 242 + display: table; 243 + } 244 + .cursor-default\! { 245 + cursor: default !important; 246 + } 247 + .flex-row { 248 + flex-direction: row; 249 + } 250 + .border-black { 251 + border-color: var(--color-black); 252 + } 253 + .\[vertical-align\:sub\] { 254 + vertical-align: sub; 255 + } 256 + .font-mono { 257 + font-family: var(--font-mono); 258 + } 259 + .text-stone-500 { 260 + color: var(--color-stone-500); 261 + } 262 + .opacity-0 { 263 + opacity: 0%; 264 + } 265 + } 192 266 @font-face { 193 267 font-family: IBM Plex Mono; 194 268 font-style: normal; ··· 3051 3125 background-color: var(--color-zinc-400); 3052 3126 } 3053 3127 } 3054 - &:disabled, &[aria-disabled="True"] { 3128 + &:disabled, &[aria-disabled="true"] { 3055 3129 pointer-events: none; 3056 3130 border-color: var(--color-stone-400); 3057 3131 color: var(--color-stone-400); ··· 3242 3316 #filetable { 3243 3317 width: 100%; 3244 3318 table-layout: fixed; 3245 - s { 3319 + s, .dotfile { 3246 3320 color: currentcolor; 3247 3321 @supports (color: color-mix(in lab, red, red)) { 3248 3322 color: color-mix(in oklab, currentcolor 40%, transparent);