diff --git a/package-lock.json b/package-lock.json index 0813e75..90db0ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,9 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "anser": "^2.1.1", "core-js": "^3.31.1", + "escape-carriage": "^1.3.1", "markdown-to-jsx": "^7.2.1", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -5108,6 +5110,11 @@ "ajv": "^6.9.1" } }, + "node_modules/anser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/anser/-/anser-2.1.1.tgz", + "integrity": "sha512-nqLm4HxOTpeLOxcmB3QWmV5TcDFhW9y/fyQ+hivtDFcK4OQ+pQ5fzPnXHM1Mfcm0VkLtvVi1TCPr++Qy0Q/3EQ==" + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -7328,6 +7335,11 @@ "node": ">=6" } }, + "node_modules/escape-carriage": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/escape-carriage/-/escape-carriage-1.3.1.tgz", + "integrity": "sha512-GwBr6yViW3ttx1kb7/Oh+gKQ1/TrhYwxKqVmg5gS+BK+Qe2KrOa/Vh7w3HPBvgGf0LfcDGoY9I6NHKoA5Hozhw==" + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", diff --git a/package.json b/package.json index 6cfa4d5..7c58653 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "anser": "^2.1.1", "core-js": "^3.31.1", + "escape-carriage": "^1.3.1", "markdown-to-jsx": "^7.2.1", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -60,11 +62,19 @@ "error", "double" ], - "object-curly-spacing": ["warn", "always"], + "object-curly-spacing": [ + "warn", + "always" + ], "default-case": "off", "arrow-parens": "error", "space-infix-ops": "warn", - "react/no-multi-comp": ["error", { "ignoreStateless": true }], + "react/no-multi-comp": [ + "error", + { + "ignoreStateless": true + } + ], "jsdoc/no-undefined-types": "warn", "jsdoc/require-param": "warn", "jsdoc/check-tag-names": "warn", diff --git a/src/components/actions/Actions.module.css b/src/components/actions/Actions.module.css index 234d5a2..0c1ffd8 100644 --- a/src/components/actions/Actions.module.css +++ b/src/components/actions/Actions.module.css @@ -80,6 +80,8 @@ .Context-menu .Icon { display: flex; + justify-content: center; + align-items: center; width: 0.875rem; height: 0.875rem; } @@ -90,6 +92,25 @@ object-fit: contain; } +.Context-menu .Image-icon { + position: absolute; + width: 1rem; + height: auto; + aspect-ratio: 1; +} + +.Context-menu .Image-icon div { + display: flex; + width: 100%; + height: 100%; +} + +.Context-menu .Image-icon div > svg { + width: 100%; + height: 100%; + object-fit: contain; +} + .Context-menu .Shortcut { color: var(--foreground-color-b); } diff --git a/src/components/applications/terminal/Ansi.jsx b/src/components/applications/terminal/Ansi.jsx new file mode 100644 index 0000000..4899a5f --- /dev/null +++ b/src/components/applications/terminal/Ansi.jsx @@ -0,0 +1,184 @@ +import Anser, { AnserJsonEntry } from "anser"; +import { escapeCarriageReturn } from "escape-carriage"; +import * as React from "react"; +import styles from "./Terminal.module.css"; + +/** + * This was copied from + * https://github.com/nteract/ansi-to-react/blob/master/src/index.ts + */ + +/** + * Converts ANSI strings into JSON output. + * @name ansiToJSON + * @function + * @param {string} input - The input string. + * @param {boolean=} use_classes - If `true`, HTML classes will be appended + * to the HTML output. + * @returns {AnserJsonEntry[]} The parsed input. + */ +function ansiToJSON(input, use_classes) { + input = escapeCarriageReturn(fixBackspace(input)); + return Anser.ansiToJson(input, { + json: true, + remove_empty: true, + use_classes, + }); +} + +/** + * Create a class string. + * @param {AnserJsonEntry} bundle + * @returns {string} class name(s) + */ +function createClass(bundle) { + const classNames = []; + + if (bundle.bg) { + classNames.push(styles[`${bundle.bg}-bg`]); + } + if (bundle.fg) { + classNames.push(styles[`${bundle.fg}-fg`]); + } + if (bundle.decoration) { + classNames.push(styles[`ansi-${bundle.decoration}`]); + } + + if (classNames.length === 0) { + return null; + } + + return classNames.join(" "); +} + +/** + * Create the style attribute. + * @param {AnserJsonEntry} bundle + * @returns {object} returns the style object + */ +function createStyle(bundle) { + const style = {}; + if (bundle.bg) { + style.backgroundColor = `rgb(${bundle.bg})`; + } + if (bundle.fg) { + style.color = `rgb(${bundle.fg})`; + } + switch (bundle.decoration) { + case "bold": + style.fontWeight = "bold"; + break; + case "dim": + style.opacity = "0.5"; + break; + case "italic": + style.fontStyle = "italic"; + break; + case "hidden": + style.visibility = "hidden"; + break; + case "strikethrough": + style.textDecoration = "line-through"; + break; + case "underline": + style.textDecoration = "underline"; + break; + case "blink": + style.textDecoration = "blink"; + break; + default: + break; + } + return style; +} + +/** + * Converts an Anser bundle into a React Node. + * @param {boolean} linkify - whether links should be converting into clickable anchor tags. + * @param {boolean} useClasses - should render the span with a class instead of style. + * @param {AnserJsonEntry} bundle - Anser output. + * @param {number} key + */ +function convertBundleIntoReact(linkify, useClasses, bundle, key) { + const style = useClasses ? null : createStyle(bundle); + const className = useClasses ? createClass(bundle) : null; + + if (!linkify) { + return React.createElement( + "pre", + { style, key, className }, + bundle.content + ); + } + + const content = []; + const linkRegex = /(\s|^)(https?:\/\/(?:www\.|(?!www))[^\s.]+\.[^\s]{2,}|www\.[^\s]+\.[^\s]{2,})/g; + + let index = 0; + let match; + while ((match = linkRegex.exec(bundle.content)) !== null) { + const [, pre, url] = match; + + const startIndex = match.index + pre.length; + if (startIndex > index) { + content.push(bundle.content.substring(index, startIndex)); + } + + // Make sure the href we generate from the link is fully qualified. We assume http + // if it starts with a www because many sites don't support https + const href = url.startsWith("www.") ? `http://${url}` : url; + content.push( + React.createElement( + "a", + { + key: index, + href, + target: "_blank", + }, + `${url}` + ) + ); + + index = linkRegex.lastIndex; + } + + if (index < bundle.content.length) { + content.push(bundle.content.substring(index)); + } + + return React.createElement("span", { style, key, className }, content); +} + +/** + * @param {object} props + * @param {string=} props.children + * @param {boolean=} props.linkify + * @param {string=} props.className + * @param {boolean=} props.useClasses + */ +export default function Ansi(props) { + const { className, useClasses, children, linkify } = props; + return React.createElement( + "code", + { className }, + ansiToJSON(children ?? "", useClasses ?? false).map( + convertBundleIntoReact.bind(null, linkify ?? false, useClasses ?? false) + ) + ); +} + +// This is copied from the Jupyter Classic source code +// notebook/static/base/js/utils.js to handle \b in a way +// that is **compatible with Jupyter classic**. One can +// argue that this behavior is questionable: +// https://stackoverflow.com/questions/55440152/multiple-b-doesnt-work-as-expected-in-jupyter# +function fixBackspace(txt) { + let tmp = txt; + do { + txt = tmp; + // Cancel out anything-but-newline followed by backspace + // eslint-disable-next-line no-control-regex + tmp = txt.replace(/[^\n]\x08/gm, ""); + } while (tmp.length < txt.length); + return txt; +} diff --git a/src/components/applications/terminal/InputLine.jsx b/src/components/applications/terminal/InputLine.jsx index e3f300f..ec8cced 100644 --- a/src/components/applications/terminal/InputLine.jsx +++ b/src/components/applications/terminal/InputLine.jsx @@ -1,5 +1,6 @@ import { useState } from "react"; import styles from "./Terminal.module.css"; +import Ansi from "./Ansi.jsx"; /** * @param {object} props @@ -19,7 +20,7 @@ export function InputLine({ value, prefix, onChange, onKeyUp, onKeyDown, inputRe return ( - {prefix &&

{prefix}

} + {prefix && {prefix}} {lines.map((line, index) => -
{line === "" ? " " : line}
+ {line === "" ? " " : line} )} ); } \ No newline at end of file diff --git a/src/components/applications/terminal/Terminal.jsx b/src/components/applications/terminal/Terminal.jsx index 9329067..02257cc 100644 --- a/src/components/applications/terminal/Terminal.jsx +++ b/src/components/applications/terminal/Terminal.jsx @@ -4,19 +4,19 @@ import { useVirtualRoot } from "../../../hooks/virtual-drive/virtualRootContext. import { clamp } from "../../../features/math/clamp.js"; import { OutputLine } from "./OutputLine.jsx"; import { InputLine } from "./InputLine.jsx"; -import { HOSTNAME, USERNAME } from "../../../constants/applications/terminal.js"; +import { ANSI, HOSTNAME, USERNAME } from "../../../constants/applications/terminal.js"; import CommandsManager from "../../../features/applications/terminal/commands.js"; import { removeFromArray } from "../../../features/utils/array.js"; /** * @param {import("../../windows/WindowView.jsx").windowProps} props */ -export function Terminal({ setTitle, close: exit }) { +export function Terminal({ startPath, setTitle, close: exit }) { const [inputKey, setInputKey] = useState(0); const [inputValue, setInputValue] = useState(""); const [history, setHistory] = useState([]); const virtualRoot = useVirtualRoot(); - const [currentDirectory, setCurrentDirectory] = useState(virtualRoot.navigate("~")); + const [currentDirectory, setCurrentDirectory] = useState(virtualRoot.navigate(startPath ?? "~")); const inputRef = useRef(null); const [historyIndex, setHistoryIndex] = useState(0); @@ -24,7 +24,8 @@ export function Terminal({ setTitle, close: exit }) { setTitle(`${USERNAME}@${HOSTNAME}: ${currentDirectory.root ? "/" : currentDirectory.path}`); }, [currentDirectory.path, currentDirectory.root, setTitle]); - const prefix = `${USERNAME}@${HOSTNAME}:${currentDirectory.root ? "/" : currentDirectory.path}$ `; + const prefix = `${ANSI.fg.cyan + USERNAME}@${HOSTNAME + ANSI.reset}:` + + `${ANSI.fg.blue + (currentDirectory.root ? "/" : currentDirectory.path) + ANSI.reset}$ `; const updatedHistory = history; const pushHistory = (entry) => { diff --git a/src/components/applications/terminal/Terminal.module.css b/src/components/applications/terminal/Terminal.module.css index 7ce74dd..da93a36 100644 --- a/src/components/applications/terminal/Terminal.module.css +++ b/src/components/applications/terminal/Terminal.module.css @@ -1,5 +1,5 @@ .Terminal { - --char-width: 0.626rem; + --char-width: 0.585rem; display: flex; flex-direction: column; @@ -12,7 +12,7 @@ .Terminal * { font-family: var(--mono-font-family); - letter-spacing: 0.01rem; + letter-spacing: -0.03em; } .Terminal p, .Terminal pre { @@ -21,6 +21,7 @@ } .Prefix { + display: flex; width: max-content; white-space: nowrap; } @@ -47,7 +48,7 @@ position: relative; height: 100%; width: fit-content; - margin-left: var(--char-width); + /* margin-left: var(--char-width); */ } .Input-container::after { @@ -90,4 +91,31 @@ top: 0; left: 0; cursor: text; -} \ No newline at end of file +} + +.ansi-black-fg { color: var(--dark-grey-e); } +.ansi-red-fg { color: var(--red-b); } +.ansi-green-fg { color: var(--green-b); } +.ansi-yellow-fg { color: var(--yellow-b); } +.ansi-blue-fg { color: var(--blue-b); } +.ansi-magenta-fg { color: var(--purple-b); } +.ansi-cyan-fg { color: var(--cyan-b); } +.ansi-white-fg { color: var(--grey-a); } + +.ansi-bright-black-fg { color: var(--dark-grey-d); } +.ansi-bright-red-fg { color: var(--red-a); } +.ansi-bright-green-fg { color: var(--green-a); } +.ansi-bright-yellow-fg { color: var(--yellow-a); } +.ansi-bright-blue-fg { color: var(--blue-a); } +.ansi-bright-magenta-fg { color: var(--purple-a); } +.ansi-bright-cyan-fg { color: var(--cyan-a); } +.ansi-bright-white-fg { color: var(--white-a); } + +.ansi-black-bg { background-color: var(--dark-grey-d); } +.ansi-red-bg { background-color: var(--red-a); } +.ansi-green-bg { background-color: var(--green-a); } +.ansi-yellow-bg { background-color: var(--yellow-a); } +.ansi-blue-bg { background-color: var(--blue-a); } +.ansi-magenta-bg { background-color: var(--purple-a); } +.ansi-cyan-bg { background-color: var(--cyan-a); } +.ansi-white-bg { background-color: var(--white-a); } diff --git a/src/constants/applications/terminal.js b/src/constants/applications/terminal.js index cf98413..f5680df 100644 --- a/src/constants/applications/terminal.js +++ b/src/constants/applications/terminal.js @@ -1,3 +1,20 @@ export const USERNAME = "user"; export const HOSTNAME = "prozilla-os"; -export const MAX_WIDTH = 50; \ No newline at end of file +export const MAX_WIDTH = 50; + +export const ANSI = { + fg: { + black: "\u001b[30m", + red: "\u001b[31m", + green: "\u001b[32m", + yellow: "\u001b[33m", + blue: "\u001b[34m", + magenta: "\u001b[35m", + cyan: "\u001b[36m", + white: "\u001b[37m", + }, + bg: { + + }, + reset: "\u001b[0m", +}; \ No newline at end of file diff --git a/src/constants/branding.js b/src/constants/branding.js index 5bc35cc..e2e622c 100644 --- a/src/constants/branding.js +++ b/src/constants/branding.js @@ -1,3 +1,5 @@ +import { ANSI } from "./applications/terminal.js"; + export const NAME = "ProzillaOS"; export const ASCII_LOGO = ` @@ -13,6 +15,24 @@ export const ASCII_LOGO = ` .=----#%+-+%#-*+-%#+---:. ==----*###*--*###*----. ==+-------------------:. + ...::---------------:. + .::---------::.. + ....::... `; + +export const ANSI_LOGO_COLOR = ANSI.fg.green; +export const ANSI_ASCII_LOGO = ` + :. + -==. + .=====: + ---::..:=======-. + :===+=----------::.. + =+=---------------:.. + --------------------:. +.:-+=----${ANSI.fg.white}*###*${ANSI_LOGO_COLOR}--${ANSI.fg.white}*####=${ANSI_LOGO_COLOR}---. +:==+----${ANSI.fg.white}#%+${ANSI_LOGO_COLOR}-${ANSI.fg.white}+%#${ANSI_LOGO_COLOR}-${ANSI.fg.white}##%*+${ANSI_LOGO_COLOR}----:. + .=----${ANSI.fg.white}#%+${ANSI_LOGO_COLOR}-${ANSI.fg.white}+%#${ANSI_LOGO_COLOR}-${ANSI.fg.white}*+${ANSI_LOGO_COLOR}-${ANSI.fg.white}%#+${ANSI_LOGO_COLOR}---:. + ==----${ANSI.fg.white}*###*${ANSI_LOGO_COLOR}--${ANSI.fg.white}*###*${ANSI_LOGO_COLOR}----. + ==+-------------------:. ...::---------------:. .::---------::.. ....::... `; \ No newline at end of file diff --git a/src/features/applications/terminal/commands/cd.js b/src/features/applications/terminal/commands/cd.js index f14713d..6429bee 100644 --- a/src/features/applications/terminal/commands/cd.js +++ b/src/features/applications/terminal/commands/cd.js @@ -1,7 +1,6 @@ import Command from "../command.js"; export const cd = new Command("cd") - .setRequireArgs(true) .setManual({ purpose: "Change the current directory", usage: "cd path", diff --git a/src/features/applications/terminal/commands/ls.js b/src/features/applications/terminal/commands/ls.js index d7d9cf8..f8603dc 100644 --- a/src/features/applications/terminal/commands/ls.js +++ b/src/features/applications/terminal/commands/ls.js @@ -1,3 +1,4 @@ +import { ANSI } from "../../../../constants/applications/terminal.js"; import Command from "../command.js"; export const ls = new Command("ls") @@ -16,7 +17,7 @@ export const ls = new Command("ls") if (!directory) return `${this.name}: Cannot access '${args[0]}': No such file or directory`; - const folderNames = directory.subFolders.map((folder) => folder.id); + const folderNames = directory.subFolders.map((folder) => `${ANSI.fg.blue}${folder.id}${ANSI.reset}`); const fileNames = directory.files.map((file) => file.id); const contents = folderNames.concat(fileNames); @@ -24,5 +25,5 @@ export const ls = new Command("ls") if (contents.length === 0) return { blank: true }; - return contents.sort((nameA, nameB) => nameA.localeCompare(nameB)).join(" "); + return contents.sort().join(" "); }); \ No newline at end of file diff --git a/src/features/applications/terminal/commands/neofetch.js b/src/features/applications/terminal/commands/neofetch.js index e09c060..2f1a17b 100644 --- a/src/features/applications/terminal/commands/neofetch.js +++ b/src/features/applications/terminal/commands/neofetch.js @@ -1,5 +1,6 @@ import { APPS } from "../../../../constants/applications.js"; -import { ASCII_LOGO, NAME } from "../../../../constants/branding.js"; +import { ANSI } from "../../../../constants/applications/terminal.js"; +import { ANSI_ASCII_LOGO, ANSI_LOGO_COLOR, NAME } from "../../../../constants/branding.js"; import { START_DATE } from "../../../../index.js"; import { formatRelativeTime } from "../../../utils/date.js"; import AppsManager from "../../applications.js"; @@ -10,7 +11,7 @@ export const neofetch = new Command("neofetch") purpose: "Fetch system information" }) .setExecute((args, { username, hostname }) => { - const leftColumn = ASCII_LOGO.split("\n"); + const leftColumn = ANSI_ASCII_LOGO.split("\n"); const rightColumnWidth = username.length + hostname.length + 1; const userAgent = navigator.userAgent; @@ -29,23 +30,27 @@ export const neofetch = new Command("neofetch") browserName = "Unknown"; } + const formatLine = (label, text) => ANSI.fg.green + label.toUpperCase() + ANSI.reset + ": " + text; + const rightColumn = [ - `${username}@${hostname}`, + `${ANSI.fg.green + username + ANSI.reset}@${ANSI.fg.green + hostname + ANSI.reset}`, "-".repeat(rightColumnWidth), - `OS: ${NAME}`, - `UPTIME: ${formatRelativeTime(START_DATE, 2, false)}`, - `RESOLUTION: ${window.innerWidth}x${window.innerHeight}`, - "THEME: default", - "ICONS: Font Awesome", - `TERMINAL: ${AppsManager.getApp(APPS.TERMINAL)?.name ?? "Unknown"}`, - `BROWSER: ${browserName}`, - `PLATFORM: ${navigator.platform}`, - `LANGUAGE: ${navigator.language}`, + formatLine("os", NAME), + formatLine("uptime", formatRelativeTime(START_DATE, 2, false)), + formatLine("resolution", window.innerWidth + "x" + window.innerHeight), + formatLine("theme", "default"), + formatLine("icons", "Font Awesome"), + formatLine("terminal", AppsManager.getApp(APPS.TERMINAL)?.name ?? "Unknown"), + formatLine("browser", browserName), + formatLine("platform", navigator.platform), + formatLine("language", navigator.language), + "", + Object.values(ANSI.fg).map((colorCode) => colorCode + "███").join("") + ANSI.reset, ]; const combined = []; for (let i = 1; i < leftColumn.length; i++) { - let line = `${leftColumn[i]} `; + let line = `${ANSI_LOGO_COLOR + leftColumn[i] + ANSI.reset} `; if (i <= rightColumn.length) { line += rightColumn[i - 1]; diff --git a/src/styles/global/variables.css b/src/styles/global/variables.css index 3159fb0..44969f4 100644 --- a/src/styles/global/variables.css +++ b/src/styles/global/variables.css @@ -1,4 +1,5 @@ :root { + --white-a: #fff; --pink-a: #ff9ff3; --pink-b: #f368e0; --yellow-a: #feca57; @@ -24,7 +25,7 @@ --dark-grey-d: hsl(212, 14%, 10%); --dark-grey-e: hsl(212, 12%, 8%); - --foreground-color-a: #fff; + --foreground-color-a: var(--white-a); --foreground-color-b: var(--grey-a); --foreground-color-c: var(--grey-b);