From 67017c578216e8e0f6caa0ff22d22e535a044617 Mon Sep 17 00:00:00 2001 From: HansLuft778 <42222525+HansLuft778@users.noreply.github.com> Date: Mon, 2 Jun 2025 11:23:22 +0200 Subject: [PATCH] TERMINAL: Add RAM usage and detailed file size to `ls -l` output (#2135) --- src/Terminal/HelpText.ts | 8 +- src/Terminal/commands/ls.tsx | 187 +++++++++++++++++++++++------- src/ui/formatNumber.ts | 51 ++++---- test/jest/ui/formatNumber.test.ts | 47 ++++++++ 4 files changed, 229 insertions(+), 64 deletions(-) diff --git a/src/Terminal/HelpText.ts b/src/Terminal/HelpText.ts index d8dfada40..0811bbe01 100644 --- a/src/Terminal/HelpText.ts +++ b/src/Terminal/HelpText.ts @@ -27,7 +27,7 @@ export const TerminalHelpText: string[] = [ " ipaddr Displays the IP address of the machine", " kill [script/pid] [args...] Stops the specified script on the current server ", " killall Stops all running scripts on the current machine", - " ls [dir] [--grep pattern] Displays all files on the machine", + " ls [dir] [-l] [-h] [-g pattern] Displays all files on the machine", " lscpu Displays the number of CPU cores on the machine", " mem [script] [-t n] Displays the amount of RAM required to run the script", " mv [src] [dest] Move/rename a text or script file", @@ -336,14 +336,16 @@ export const HelpTexts: Record = { ], killall: ["Usage: killall", " ", "Kills all scripts on the current server."], ls: [ - "Usage: ls [dir] [-l] [--grep pattern]", + "Usage: ls [dir] [-l] [-h] [-g, --grep pattern]", " ", "The ls command, with no arguments, prints all files and directories on the current server's directory to the Terminal screen. ", "The files will be displayed in alphabetical order. ", " ", "The 'dir' optional parameter can be used to display files/directories in another directory.", " ", - "The '-l' optional parameter allows you to force each item onto a single line.", + "The '-l' optional parameter allows you to force each item onto a single line, displaying two columns for the script's RAM usage and filesize.", + " ", + "The '-h' optional parameter allows you to display the filesize from '-l' in human readable format (e.g. KB, MB, GB) instead of bytes.", " ", "The '--grep pattern' optional parameter can be used to only display files whose filenames match the specified pattern.", " ", diff --git a/src/Terminal/commands/ls.tsx b/src/Terminal/commands/ls.tsx index 3159491ca..e4f3dd43c 100644 --- a/src/Terminal/commands/ls.tsx +++ b/src/Terminal/commands/ls.tsx @@ -25,10 +25,31 @@ import { } from "../../Paths/Directory"; import { isMember } from "../../utils/EnumHelper"; import { Settings } from "../../Settings/Settings"; +import { formatBytes, formatRam } from "../../ui/formatNumber"; export function ls(args: (string | number | boolean)[], server: BaseServer): void { + enum FileType { + Folder, + Message, + TextFile, + Program, + Contract, + Script, + } + + type FileGroup = + | { + // Types that are not clickable only need to be string[] + type: FileType.Folder | FileType.Program | FileType.Contract; + segments: string[]; + } + | { type: FileType.Message; segments: FilePath[] } + | { type: FileType.Script; segments: ScriptFilePath[] } + | { type: FileType.TextFile; segments: TextFilePath[] }; + interface LSFlags { ["-l"]: boolean; + ["-h"]: boolean; ["--grep"]: string; } let flags: LSFlags; @@ -37,6 +58,7 @@ export function ls(args: (string | number | boolean)[], server: BaseServer): voi flags = libarg( { "-l": Boolean, + "-h": Boolean, "--grep": String, "-g": "--grep", }, @@ -51,10 +73,10 @@ export function ls(args: (string | number | boolean)[], server: BaseServer): voi const numArgs = args.length; function incorrectUsage(): void { - Terminal.error("Incorrect usage of ls command. Usage: ls [dir] [-l] [-g, --grep pattern]"); + Terminal.error("Incorrect usage of ls command. Usage: ls [dir] [-l] [-h] [-g, --grep pattern]"); } - if (numArgs > 4) { + if (numArgs > 5) { return incorrectUsage(); } @@ -109,6 +131,74 @@ export function ls(args: (string | number | boolean)[], server: BaseServer): voi allMessages.sort(); folders.sort(); + let maxSizeStrLength = 0; + let maxRamStrLength = 0; + if (flags["-l"]) { + // Collect all items to calculate max string lengths + const allDisplayableItems: { path: FilePath | Directory; type: FileType }[] = []; + folders.forEach((p) => allDisplayableItems.push({ path: p, type: FileType.Folder })); + allMessages.forEach((p) => allDisplayableItems.push({ path: p, type: FileType.Message })); + allTextFiles.forEach((p) => allDisplayableItems.push({ path: p, type: FileType.TextFile })); + allScripts.forEach((p) => allDisplayableItems.push({ path: p, type: FileType.Script })); + allPrograms.forEach((p) => allDisplayableItems.push({ path: p, type: FileType.Program })); + allContracts.forEach((p) => allDisplayableItems.push({ path: p, type: FileType.Contract })); + + for (const item of allDisplayableItems) { + const { ramDisplay, sizeDisplay } = getItemNumericData(item.path, item.type); + if (sizeDisplay.length > maxSizeStrLength) maxSizeStrLength = sizeDisplay.length; + if (ramDisplay.length > maxRamStrLength) maxRamStrLength = ramDisplay.length; + } + } + + function getItemNameElement(relativePath: string, fileType: FileType): React.ReactElement { + switch (fileType) { + case FileType.Folder: + return {relativePath}; + case FileType.Message: + return ; + case FileType.TextFile: + case FileType.Script: + return ; + case FileType.Program: + case FileType.Contract: + default: + return {relativePath}; + } + } + + function getItemNumericData(relativePath: string, fileType: FileType): { ramDisplay: string; sizeDisplay: string } { + let sizeDisplay = "-"; + const fullPath = + fileType === FileType.Message || relativePath.startsWith("/") + ? (relativePath as FilePath) + : combinePath(baseDirectory, relativePath as FilePath); + + // Determine file size + let contentBytes = 0; + if (fileType === FileType.TextFile) { + const file = server.textFiles.get(fullPath as TextFilePath); + contentBytes = file?.content ? new TextEncoder().encode(file.content).length : 0; + } else { + // Script + const file = server.scripts.get(fullPath as ScriptFilePath); + contentBytes = file?.content ? new TextEncoder().encode(file.content).length : 0; + } + if (flags["-l"] && flags["-h"]) { + sizeDisplay = formatBytes(contentBytes); + } else { + sizeDisplay = `${contentBytes}`; + } + + // Determine RAM usage + let ramDisplay = "-"; + if (fileType === FileType.Script) { + const file = server.scripts.get(fullPath as ScriptFilePath); + const ramUsage = file?.getRamUsage(server.scripts); + ramDisplay = ramUsage ? formatRam(ramUsage) : "NaN"; + } + return { ramDisplay, sizeDisplay }; + } + function SegmentGrid(props: { colSize: string; children: React.ReactChild[] }): React.ReactElement { const { classes } = makeStyles()({ segmentGrid: { @@ -123,6 +213,7 @@ export function ls(args: (string | number | boolean)[], server: BaseServer): voi ); } + function ClickableContentFileLink(props: { path: ScriptFilePath | TextFilePath }): React.ReactElement { const { classes } = makeStyles()((theme: Theme) => ({ link: { @@ -180,49 +271,63 @@ export function ls(args: (string | number | boolean)[], server: BaseServer): voi ); } - enum FileType { - Folder, - Message, - TextFile, - Program, - Contract, - Script, + function LongListItem(props: { + children: React.ReactNode; + sizeInfo: string; + ramInfo: string; + maxSizeStrLengthCalculated: number; + maxRamStrLengthCalculated: number; + }): React.ReactElement { + const sizeColumnWidth = props.maxSizeStrLengthCalculated > 0 ? `${props.maxSizeStrLengthCalculated}ch` : "auto"; + const ramColumnWidth = props.maxRamStrLengthCalculated > 0 ? `${props.maxRamStrLengthCalculated}ch` : "auto"; + return ( +
+ + {props.ramInfo} + + + {props.sizeInfo} + + {props.children} +
+ ); } - type FileGroup = - | { - // Types that are not clickable only need to be string[] - type: FileType.Folder | FileType.Program | FileType.Contract; - segments: string[]; - } - | { type: FileType.Message; segments: FilePath[] } - | { type: FileType.Script; segments: ScriptFilePath[] } - | { type: FileType.TextFile; segments: TextFilePath[] }; - function postSegments({ type, segments }: FileGroup, flags: LSFlags): void { - let segmentElements: React.ReactElement[]; - const colSize = flags["-l"] - ? "100%" - : Math.ceil(Math.max(...segments.map((segment) => segment.length)) * 0.7) + "em"; - switch (type) { - case FileType.Folder: - segmentElements = segments.map((segment) => ( - - {segment} - - )); - break; - case FileType.Message: - segmentElements = segments.map((segment) => ); - break; - case FileType.Script: - case FileType.TextFile: - segmentElements = segments.map((segment) => ); - break; - default: - segmentElements = segments.map((segment) => {segment}); + if (segments.length === 0) return; + + // print file based on mode + if (flags["-l"]) { + for (const segmentPath of segments) { + const { ramDisplay, sizeDisplay } = getItemNumericData(segmentPath, type); + const nameElement = getItemNameElement(segmentPath, type); + Terminal.printRaw( + + {nameElement} + , + ); + } + } else { + const segmentElements = segments.map((segmentPath) => { + const nameElement = getItemNameElement(segmentPath, type); + return React.cloneElement(nameElement, { key: segmentPath.toString() }); + }); + const colSize = Math.ceil(Math.max(...segments.map((segment) => segment.length)) * 0.7) + "em"; + Terminal.printRaw({segmentElements}); } - Terminal.printRaw({segmentElements}); } const groups: FileGroup[] = [ diff --git a/src/ui/formatNumber.ts b/src/ui/formatNumber.ts index bf6603177..ffc9e6a48 100644 --- a/src/ui/formatNumber.ts +++ b/src/ui/formatNumber.ts @@ -7,18 +7,18 @@ const numberSuffixList = ["", "k", "m", "b", "t", "q", "Q", "s", "S", "o", "n"]; const numberExpList = numberSuffixList.map((_, i) => parseFloat(`1e${i * 3}`)); // Ram suffixes -const ramLog1000Suffixes = ["GB", "TB", "PB", "EB"]; -const ramLog1024Suffixes = ["GiB", "TiB", "PiB", "EiB"]; +const decByteSuffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]; +const binByteSuffixes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"]; // Items that get initialized in the initializer function. let digitFormats = {} as Record, percentFormats = {} as Record, basicFormatter: Intl.NumberFormat, exponentialFormatter: Intl.NumberFormat, - ramSuffixList: string[], - ramExpList: number[], - ramLogFn: (n: number) => number, - ramLogDivisor: number; + unitSuffixes: string[], + unitExpList: number[], + unitLogFn: (n: number) => number, + unitLogDivisor: number; /** Event to be emitted when changing number display settings. */ export const FormatsNeedToChange = new EventEmitter(); @@ -33,12 +33,12 @@ FormatsNeedToChange.subscribe(() => { percentFormats = {}; exponentialFormatter = makeFormatter(3, { notation: Settings.useEngineeringNotation ? "engineering" : "scientific" }); basicFormatter = new Intl.NumberFormat([Settings.Locale, "en"], { useGrouping: !Settings.hideThousandsSeparator }); - [ramSuffixList, ramLogFn, ramLogDivisor] = Settings.UseIEC60027_2 + [unitSuffixes, unitLogFn, unitLogDivisor] = Settings.UseIEC60027_2 ? // log2 of 1024 is 10 as divisor for log base 1024 - [ramLog1024Suffixes, Math.log2, 10] + [binByteSuffixes, Math.log2, 10] : // log10 of 1000 is 3 as divisor for log base 1000 - [ramLog1000Suffixes, Math.log10, 3]; - ramExpList = ramSuffixList.map((_, i) => (Settings.UseIEC60027_2 ? 1024 : 1000) ** i); + [decByteSuffixes, Math.log10, 3]; + unitExpList = unitSuffixes.map((_, i) => (Settings.UseIEC60027_2 ? 1024 : 1000) ** i); // Emit a FormatsHaveChanged event so any static content that uses formats can be regenerated. FormatsHaveChanged.emit(); @@ -67,26 +67,37 @@ function getFormatter( return (formatList[fractionalDigits] = makeFormatter(fractionalDigits, options)); } +/** Display standard byte formatting. */ +export function formatBytes(n: number, fractionalDigits = 1): string { + return formatSize(n, fractionalDigits, 0); +} + /** Display standard ram formatting. */ -export function formatRam(n: number, fractionalDigits = 2) { - // NaN does not get formatted - if (Number.isNaN(n)) return `NaN${ramSuffixList[0]}`; +export function formatRam(n: number, fractionalDigits = 2): string { + return formatSize(n, fractionalDigits, 3); +} + +function formatSize(n: number, fractionalDigits = 2, unitOffset = 3) { + const base = Settings.UseIEC60027_2 ? 1024 : 1000; const nAbs = Math.abs(n); - // Special handling for Infinities - if (nAbs === Infinity) return `${n < 0 ? "-∞" : ""}∞${ramSuffixList.at(-1)}`; + // Special handling for NaN, Infinities and zero + if (Number.isNaN(n)) return `NaN${unitSuffixes[0 + unitOffset]}`; + if (nAbs === Infinity) return `${n < 0 ? "-∞" : "∞"}${unitSuffixes.at(-1)}`; // Early return if using first suffix. - if (nAbs < 1000) return getFormatter(fractionalDigits).format(n) + ramSuffixList[0]; + if (nAbs < base) return getFormatter(fractionalDigits).format(n) + unitSuffixes[unitOffset]; - // Ram always uses a suffix and never goes to exponential - const suffixIndex = Math.min(Math.floor(ramLogFn(nAbs) / ramLogDivisor), ramSuffixList.length - 1); - n /= ramExpList[suffixIndex]; + // convert input units to bytes + let nBytes = n * base ** unitOffset; + + const suffixIndex = Math.min(Math.floor(unitLogFn(nBytes) / unitLogDivisor), unitSuffixes.length - 1); + nBytes /= unitExpList[suffixIndex]; /* Not really concerned with 1000-rounding or 1024-rounding for ram due to the actual values ram gets displayed at. If display of e.g. 1,000.00GB instead of 1.00TB for 999.995GB, or 1,024.00GiB instead of 1.00TiB for 1,023.995GiB becomes an actual issue we can add smart rounding, but ram values like that really don't happen ingame so it's probably not worth the performance overhead to check and correct these. */ - return getFormatter(fractionalDigits).format(n) + ramSuffixList[suffixIndex]; + return getFormatter(fractionalDigits).format(nBytes) + unitSuffixes[suffixIndex]; } function formatExponential(n: number) { diff --git a/test/jest/ui/formatNumber.test.ts b/test/jest/ui/formatNumber.test.ts index e801e34a4..4ef0b4904 100644 --- a/test/jest/ui/formatNumber.test.ts +++ b/test/jest/ui/formatNumber.test.ts @@ -6,6 +6,7 @@ import { FormatsNeedToChange, formatNumber, formatRam, + formatBytes, } from "../../../src/ui/formatNumber"; describe("Suffix rounding test", () => { @@ -187,6 +188,7 @@ describe("Ram formatting", () => { Settings.hideTrailingDecimalZeros = false; FormatsNeedToChange.emit(); // Base unit for ram is GB. + expect(formatRam(0)).toEqual("0.00GB"); expect(formatRam(1)).toEqual("1.00GB"); expect(formatRam(1e3)).toEqual("1.00TB"); expect(formatRam(1024)).toEqual("1.02TB"); @@ -194,6 +196,9 @@ describe("Ram formatting", () => { expect(formatRam(1048576)).toEqual("1.05PB"); expect(formatRam(1e9)).toEqual("1.00EB"); expect(formatRam(1073741824)).toEqual("1.07EB"); + expect(formatRam(NaN)).toEqual("NaNGB"); + expect(formatRam(Infinity)).toEqual("∞EB"); + expect(formatRam(-Infinity)).toEqual("-∞EB"); }); test("With GiB mode", () => { // Initial settings @@ -201,6 +206,7 @@ describe("Ram formatting", () => { Settings.hideTrailingDecimalZeros = false; FormatsNeedToChange.emit(); // Base unit for ram is now GiB. + expect(formatRam(0)).toEqual("0.00GiB"); expect(formatRam(1)).toEqual("1.00GiB"); expect(formatRam(1e3)).toEqual("1,000.00GiB"); expect(formatRam(1024)).toEqual("1.00TiB"); @@ -208,5 +214,46 @@ describe("Ram formatting", () => { expect(formatRam(1048576)).toEqual("1.00PiB"); expect(formatRam(1e9)).toEqual("953.67PiB"); expect(formatRam(1073741824)).toEqual("1.00EiB"); + expect(formatRam(NaN)).toEqual("NaNGiB"); + expect(formatRam(Infinity)).toEqual("∞EiB"); + expect(formatRam(-Infinity)).toEqual("-∞EiB"); + }); +}); +describe("Filesize formatting", () => { + test("With default GB mode", () => { + // Initial settings + Settings.UseIEC60027_2 = false; + Settings.hideTrailingDecimalZeros = false; + FormatsNeedToChange.emit(); + // Base unit for ram is GB. + expect(formatBytes(0)).toEqual("0.0B"); + expect(formatBytes(1)).toEqual("1.0B"); + expect(formatBytes(1e3)).toEqual("1.0KB"); + expect(formatBytes(1024)).toEqual("1.0KB"); + expect(formatBytes(1e6)).toEqual("1.0MB"); + expect(formatBytes(1048576)).toEqual("1.0MB"); + expect(formatBytes(1e9)).toEqual("1.0GB"); + expect(formatBytes(1073741824)).toEqual("1.1GB"); + expect(formatBytes(NaN)).toEqual("NaNB"); + expect(formatBytes(Infinity)).toEqual("∞EB"); + expect(formatBytes(-Infinity)).toEqual("-∞EB"); + }); + test("With GiB mode", () => { + // Initial settings + Settings.UseIEC60027_2 = true; + Settings.hideTrailingDecimalZeros = false; + FormatsNeedToChange.emit(); + // Base unit for ram is now GiB. + expect(formatBytes(0)).toEqual("0.0B"); + expect(formatBytes(1)).toEqual("1.0B"); + expect(formatBytes(1e3)).toEqual("1,000.0B"); + expect(formatBytes(1024)).toEqual("1.0KiB"); + expect(formatBytes(1e6)).toEqual("976.6KiB"); + expect(formatBytes(1048576)).toEqual("1.0MiB"); + expect(formatBytes(1e9)).toEqual("953.7MiB"); + expect(formatBytes(1073741824)).toEqual("1.0GiB"); + expect(formatBytes(NaN)).toEqual("NaNB"); + expect(formatBytes(Infinity)).toEqual("∞EiB"); + expect(formatBytes(-Infinity)).toEqual("-∞EiB"); }); });