From c2b8b32b442e029d394cc1b3d0f75298168d273c Mon Sep 17 00:00:00 2001 From: Sylvie Crowe <107814465+oneirocosm@users.noreply.github.com> Date: Sat, 22 Jun 2024 00:41:49 -0700 Subject: [PATCH] New Directory View Columns (#71) This adds several new columns to the directory view. It adds a last modified timestamp, a logo for the type, human-readable file sizes, and permissions. Several of these are configurable via the config/settings.json file. --- frontend/app/view/directorypreview.less | 3 +- frontend/app/view/directorypreview.tsx | 146 +++++++++++++++++++++--- frontend/types/gotypes.d.ts | 18 +++ pkg/service/fileservice/fileservice.go | 17 ++- pkg/util/utilfn/utilfn.go | 10 ++ pkg/wavebase/wavebase.go | 15 +++ pkg/wconfig/datetimestyle.go | 59 ++++++++++ pkg/wconfig/filewatcher.go | 3 + pkg/wconfig/settingsconfig.go | 54 ++++++++- 9 files changed, 302 insertions(+), 23 deletions(-) create mode 100644 pkg/wconfig/datetimestyle.go diff --git a/frontend/app/view/directorypreview.less b/frontend/app/view/directorypreview.less index 7e98dddf..f792af63 100644 --- a/frontend/app/view/directorypreview.less +++ b/frontend/app/view/directorypreview.less @@ -1,4 +1,5 @@ .dir-table { + --col-size-size: 0.2rem; overflow: auto; width: 100%; border-radius: 3px; @@ -9,7 +10,7 @@ padding: 4px 0; background-color: var(--panel-bg-color); - .dir-table-head-cell { + .dir-table-head-cell:not(:first-child) { position: relative; padding: 2px 4px; font-weight: bold; diff --git a/frontend/app/view/directorypreview.tsx b/frontend/app/view/directorypreview.tsx index ea2cdb69..55f8263b 100644 --- a/frontend/app/view/directorypreview.tsx +++ b/frontend/app/view/directorypreview.tsx @@ -1,10 +1,12 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import * as util from "@/util/util"; import { Table, createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import clsx from "clsx"; import * as jotai from "jotai"; import React from "react"; +import { atoms } from "../store/global"; import "./directorypreview.less"; @@ -16,23 +18,135 @@ interface DirectoryTableProps { const columnHelper = createColumnHelper(); -const defaultColumns = [ - columnHelper.accessor("path", { - cell: (info) => info.getValue(), - header: () => Name, - }), - columnHelper.accessor("size", { - cell: (info) => info.getValue(), - header: () => Size, - }), - columnHelper.accessor("mimetype", { - cell: (info) => info.getValue(), - header: () => Type, - }), -]; +const displaySuffixes = { + B: "b", + kB: "k", + MB: "m", + GB: "g", + TB: "t", + KiB: "k", + MiB: "m", + GiB: "g", + TiB: "t", +}; + +function getBestUnit(bytes: number, si: boolean = false, sigfig: number = 3): string { + if (bytes < 0) { + return ""; + } + const units = si ? ["kB", "MB", "GB", "TB"] : ["KiB", "MiB", "GiB", "TiB"]; + const divisor = si ? 1000 : 1024; + + let currentUnit = "B"; + let currentValue = bytes; + let idx = 0; + while (currentValue > divisor && idx < units.length - 1) { + currentUnit = units[idx]; + currentValue /= divisor; + } + + return `${parseFloat(currentValue.toPrecision(sigfig))}${displaySuffixes[currentUnit]}`; +} + +function getSpecificUnit(bytes: number, suffix: string): string { + if (bytes < 0) { + return ""; + } + + const divisors = new Map([ + ["B", 1], + ["kB", 1e3], + ["MB", 1e6], + ["GB", 1e9], + ["TB", 1e12], + ["KiB", 0x400], + ["MiB", 0x400 ** 2], + ["GiB", 0x400 ** 3], + ["TiB", 0x400 ** 4], + ]); + const divisor: number = divisors[suffix] ?? 1; + + return `${bytes / divisor} ${displaySuffixes[suffix]}`; +} + +function getLastModifiedTime( + unixMillis: number, + locale: Intl.LocalesArgument, + options: DateTimeFormatConfigType +): string { + if (locale === "C") { + locale = "lookup"; + } + return new Date(unixMillis).toLocaleString(locale, options); //todo use config +} + +const iconRegex = /^[a-z0-9- ]+$/; + +function isIconValid(icon: string): boolean { + if (util.isBlank(icon)) { + return false; + } + return icon.match(iconRegex) != null; +} + +function getIconClass(icon: string): string { + if (!isIconValid(icon)) { + return "fa fa-solid fa-question fa-fw"; + } + return `fa fa-solid fa-${icon} fa-fw`; +} function DirectoryTable({ data, cwd, setFileName }: DirectoryTableProps) { - const [columns] = React.useState(() => [...defaultColumns]); + let settings = jotai.useAtomValue(atoms.settingsConfigAtom); + const getIconFromMimeType = React.useCallback( + (mimeType: string): string => { + while (mimeType.length > 0) { + let icon = settings.mimetypes[mimeType]?.icon ?? null; + if (isIconValid(icon)) { + return `fa fa-solid fa-${icon} fa-fw`; + } + mimeType = mimeType.slice(0, -1); + } + return "fa fa-solid fa-question fa-fw"; + }, + [settings.mimetypes] + ); + const columns = React.useMemo( + () => [ + columnHelper.accessor("mimetype", { + cell: (info) => , + header: () => , + id: "logo", + size: 25, + }), + columnHelper.accessor("path", { + cell: (info) => info.getValue(), + header: () => Name, + }), + columnHelper.accessor("modestr", { + cell: (info) => info.getValue(), + header: () => Permissions, + size: 91, + }), + columnHelper.accessor("modtime", { + cell: (info) => + getLastModifiedTime(info.getValue(), settings.datetime.locale, settings.datetime.format), + header: () => Last Modified, + size: 185, + }), + columnHelper.accessor("size", { + cell: (info) => getBestUnit(info.getValue()), + header: () => Size, + size: 55, + }), + columnHelper.accessor("mimetype", { + cell: (info) => info.getValue(), + header: () => Type, + }), + ], + [settings] + ); + const table = useReactTable({ data, columns, @@ -111,7 +225,7 @@ function TableBody({ table, cwd, setFileName }: TableBodyProps) { key={cell.id} style={{ width: `calc(var(--col-${cell.column.id}-size) * 1px)` }} > - {cell.renderValue()} + {flexRender(cell.column.columnDef.cell, cell.getContext())} ); })} diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 0e559ea1..eb1e6214 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -97,6 +97,16 @@ declare global { rtopts?: RuntimeOpts; }; + type DateTimeConfigType = { + locale: string; + format: DateTimeFormatConfigType; + } + + type DateTimeFormatConfigType = { + dateStyle: "full" | "long" | "medium" | "short"; + timeStyle: "full" | "long" | "medium" | "short"; + } + // wstore.FileDef type FileDef = { filetype?: string; @@ -112,6 +122,7 @@ declare global { notfound?: boolean; size: number; mode: number; + modestr: string; modtime: number; isdir?: boolean; mimetype?: string; @@ -146,6 +157,11 @@ declare global { ReturnDesc: string; }; + //wconfig.MimeTypeConfigType + type MimeTypeConfigType = { + icon: string; + } + // waveobj.ORef type ORef = { otype: string; @@ -179,6 +195,8 @@ declare global { // wconfig.SettingsConfigType type SettingsConfigType = { + datetime: DateTimeConfigType; + mimetypes: {[key:string]: MimeTypeConfigType} widgets: WidgetsConfigType[]; term: TerminalConfigType; }; diff --git a/pkg/service/fileservice/fileservice.go b/pkg/service/fileservice/fileservice.go index ca352487..89a40c0a 100644 --- a/pkg/service/fileservice/fileservice.go +++ b/pkg/service/fileservice/fileservice.go @@ -28,6 +28,7 @@ type FileInfo struct { NotFound bool `json:"notfound,omitempty"` Size int64 `json:"size"` Mode os.FileMode `json:"mode"` + ModeStr string `json:"modestr"` ModTime int64 `json:"modtime"` IsDir bool `json:"isdir,omitempty"` MimeType string `json:"mimetype,omitempty"` @@ -52,6 +53,7 @@ func (fs *FileService) StatFile(path string) (*FileInfo, error) { Path: cleanedPath, Size: finfo.Size(), Mode: finfo.Mode(), + ModeStr: finfo.Mode().String(), ModTime: finfo.ModTime().UnixMilli(), IsDir: finfo.IsDir(), MimeType: mimeType, @@ -75,16 +77,27 @@ func (fs *FileService) ReadFile(path string) (*FullFile, error) { return nil, fmt.Errorf("unable to parse directory %s", finfo.Path) } + if len(innerFilesEntries) > 1000 { + innerFilesEntries = innerFilesEntries[:1001] + } var innerFilesInfo []FileInfo for _, innerFileEntry := range innerFilesEntries { innerFileInfoInt, _ := innerFileEntry.Info() + mimeType := utilfn.DetectMimeType(filepath.Join(finfo.Path, innerFileInfoInt.Name())) + var fileSize int64 + if mimeType == "directory" { + fileSize = -1 + } else { + fileSize = innerFileInfoInt.Size() + } innerFileInfo := FileInfo{ Path: innerFileInfoInt.Name(), - Size: innerFileInfoInt.Size(), + Size: fileSize, Mode: innerFileInfoInt.Mode(), + ModeStr: innerFileInfoInt.Mode().String(), ModTime: innerFileInfoInt.ModTime().UnixMilli(), IsDir: innerFileInfoInt.IsDir(), - MimeType: "", + MimeType: mimeType, } innerFilesInfo = append(innerFilesInfo, innerFileInfo) } diff --git a/pkg/util/utilfn/utilfn.go b/pkg/util/utilfn/utilfn.go index e95f6a02..1c066d4c 100644 --- a/pkg/util/utilfn/utilfn.go +++ b/pkg/util/utilfn/utilfn.go @@ -637,6 +637,16 @@ func DetectMimeType(path string) string { if stats.IsDir() { return "directory" } + if stats.Mode()&os.ModeNamedPipe == os.ModeNamedPipe { + return "pipe" + } + charDevice := os.ModeDevice | os.ModeCharDevice + if stats.Mode()&charDevice == charDevice { + return "character-special" + } + if stats.Mode()&os.ModeDevice == os.ModeDevice { + return "block-special" + } fd, err := os.Open(path) if err != nil { return "" diff --git a/pkg/wavebase/wavebase.go b/pkg/wavebase/wavebase.go index 423ead07..692bdf2f 100644 --- a/pkg/wavebase/wavebase.go +++ b/pkg/wavebase/wavebase.go @@ -126,6 +126,13 @@ func determineLang() string { strOut := string(out) truncOut := strings.Split(strOut, "@")[0] return strings.TrimSpace(truncOut) + ".UTF-8" + } else if runtime.GOOS == "win32" { + out, err := exec.CommandContext(ctx, "Get-Culture", "|", "select", "-exp", "Name").CombinedOutput() + if err != nil { + log.Printf("error executing 'Get-Culture | select -exp Name': %v\n", err) + return "" + } + return strings.TrimSpace(string(out)) + ".UTF-8" } else { // this is specifically to get the wavesrv LANG so waveshell // on a remote uses the same LANG @@ -140,6 +147,14 @@ func DetermineLang() string { return osLang } +func DetermineLocale() string { + truncated := strings.Split(DetermineLang(), ".")[0] + if truncated == "" { + return "C" + } + return strings.Replace(truncated, "_", "-", -1) +} + func AcquireWaveLock() (*filemutex.FileMutex, error) { homeDir := GetWaveHomeDir() lockFileName := filepath.Join(homeDir, WaveLockFile) diff --git a/pkg/wconfig/datetimestyle.go b/pkg/wconfig/datetimestyle.go new file mode 100644 index 00000000..357e38d2 --- /dev/null +++ b/pkg/wconfig/datetimestyle.go @@ -0,0 +1,59 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wconfig + +import ( + "encoding/json" + "fmt" +) + +type DateTimeStyle uint8 + +const ( + DateTimeStyleFull = iota + 1 + DateTimeStyleLong + DateTimeStyleMedium + DateTimeStyleShort +) + +var dateTimeStyleToString = map[uint8]string{ + 1: "full", + 2: "long", + 3: "medium", + 4: "short", +} + +var stringToDateTimeStyle = map[string]uint8{ + "full": 1, + "long": 2, + "medium": 3, + "short": 4, +} + +func (dts DateTimeStyle) String() string { + return dateTimeStyleToString[uint8(dts)] +} + +func parseDateTimeStyle(input string) (DateTimeStyle, error) { + value, ok := stringToDateTimeStyle[input] + if !ok { + return DateTimeStyle(0), fmt.Errorf("%q is not a valid date-time style", input) + } + return DateTimeStyle(value), nil +} + +func (dts DateTimeStyle) MarshalJSON() ([]byte, error) { + return json.Marshal(dts.String()) +} + +func (dts *DateTimeStyle) UnmarshalJSON(data []byte) (err error) { + var buffer string + if err := json.Unmarshal(data, &buffer); err != nil { + return err + } + if *dts, err = parseDateTimeStyle(buffer); err != nil { + return err + } + return nil +} diff --git a/pkg/wconfig/filewatcher.go b/pkg/wconfig/filewatcher.go index ed96a7cd..c0ad7d6e 100644 --- a/pkg/wconfig/filewatcher.go +++ b/pkg/wconfig/filewatcher.go @@ -1,3 +1,6 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + package wconfig import ( diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index df9e9869..51468240 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -1,3 +1,6 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + package wconfig import ( @@ -24,23 +27,66 @@ type TerminalConfigType struct { FontFamily string `json:"fontfamily,omitempty"` } +type DateTimeConfigType struct { + Locale string `json:"locale"` + Format DateTimeFormatConfigType `json:"format"` +} + +type DateTimeFormatConfigType struct { + DateStyle DateTimeStyle `json:"dateStyle"` + TimeStyle DateTimeStyle `json:"timeStyle"` + //TimeZone string `json:"timeZone"` TODO: need a universal way to obtain this before adding it +} + +type MimeTypeConfigType struct { + Icon string `json:"icon"` +} + type SettingsConfigType struct { - Widgets []WidgetsConfigType `json:"widgets"` - Term TerminalConfigType `json:"term"` + MimeTypes map[string]MimeTypeConfigType `json:"mimetypes"` + DateTime DateTimeConfigType `json:"datetime"` + Term TerminalConfigType `json:"term"` + Widgets []WidgetsConfigType `json:"widgets"` } func getSettingsConfigDefaults() SettingsConfigType { return SettingsConfigType{ + DateTime: DateTimeConfigType{ + Locale: wavebase.DetermineLocale(), + Format: DateTimeFormatConfigType{ + DateStyle: DateTimeStyleMedium, + TimeStyle: DateTimeStyleMedium, + }, + }, + MimeTypes: map[string]MimeTypeConfigType{ + "audio": {Icon: "file-audio"}, + "application/pdf": {Icon: "file-pdf"}, + "application/json": {Icon: "file-lines"}, + "directory": {Icon: "folder"}, + "font": {Icon: "book-font"}, + "image": {Icon: "file-image"}, + "text": {Icon: "file-lines"}, + "text/css": {Icon: "css3-alt fa-brands"}, + "text/javascript": {Icon: "js fa-brands"}, + "text/typescript": {Icon: "js fa-brands"}, + "text/golang": {Icon: "go fa-brands"}, + "text/html": {Icon: "html5 fa-brands"}, + "text/less": {Icon: "less fa-brands"}, + "text/markdown": {Icon: "markdown fa-brands"}, + "text/rust": {Icon: "rust fa-brands"}, + "text/scss": {Icon: "sass fa-brands"}, + "video": {Icon: "file-video"}, + }, Widgets: []WidgetsConfigType{ { - Icon: "fa fa-solid fa-files fa-fw", + Icon: "files", BlockDef: wstore.BlockDef{ View: "preview", Meta: map[string]any{"file": wavebase.GetHomeDir()}, }, }, { - Icon: "fa fa-solid fa-chart-simple fa-fw", + Icon: "chart-simple", BlockDef: wstore.BlockDef{ View: "plot", },