Updated terminal app

This commit is contained in:
Prozilla 2023-12-11 16:51:40 +01:00
parent c4868a040f
commit 9718530c9f
No known key found for this signature in database
GPG key ID: 5858DFE71CAF31EE
20 changed files with 294 additions and 144 deletions

View file

@ -6,8 +6,9 @@ import { OutputLine } from "./OutputLine.jsx";
import { InputLine } from "./InputLine.jsx"; import { InputLine } from "./InputLine.jsx";
import { HOSTNAME, USERNAME } from "../../../constants/applications/terminal.js"; import { HOSTNAME, USERNAME } from "../../../constants/applications/terminal.js";
import CommandsManager from "../../../features/applications/terminal/commands.js"; import CommandsManager from "../../../features/applications/terminal/commands.js";
import { removeFromArray } from "../../../features/utils/array.js";
export function Terminal({ setTitle }) { export function Terminal({ setTitle, close: exit }) {
const [inputKey, setInputKey] = useState(0); const [inputKey, setInputKey] = useState(0);
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const [history, setHistory] = useState([]); const [history, setHistory] = useState([]);
@ -43,22 +44,46 @@ export function Terminal({ setTitle }) {
if (value === "") if (value === "")
return; return;
const args = value.split(/ +/); // Get arguments
const args = value.match(/(?:[^\s"]+|"[^"]*")+/g);
if (args[0].toLowerCase() === "sudo" && args.length > 1) { if (args[0].toLowerCase() === "sudo" && args.length > 1) {
args.shift(); args.shift();
} }
// Get command
const commandName = args.shift().toLowerCase(); const commandName = args.shift().toLowerCase();
const command = CommandsManager.find(commandName); const command = CommandsManager.find(commandName);
if (!command) if (!command)
return `${commandName}: Command not found`; return `${commandName}: Command not found`;
if (command.requireArgs && args.length === 0) // Get options
return `${commandName}: Incorrect syntax: ${commandName} requires at least 1 argument`; const options = [];
args.filter((arg) => arg.startsWith("-")).forEach((option) => {
if (option.startsWith("--")) {
const longOption = option.substring(2).toLowerCase();
if (!options.includes(longOption))
options.push(longOption);
} else {
const shortOptions = option.substring(1).split("");
shortOptions.forEach((shortOption) => {
if (!options.includes(shortOption))
options.push(shortOption);
});
}
removeFromArray(option, args);
});
// Check usage
if (command.requireArgs && args.length === 0)
return `${commandName}: Incorrect usage: ${commandName} requires at least 1 argument`;
if (command.requireOptions && options.length === 0)
return `${commandName}: Incorrect usage: ${commandName} requires at least 1 option`;
// Execute command
let response = null; let response = null;
try { try {
@ -70,7 +95,9 @@ export function Terminal({ setTitle }) {
setCurrentDirectory, setCurrentDirectory,
username: USERNAME, username: USERNAME,
hostname: HOSTNAME, hostname: HOSTNAME,
rawInputValue rawInputValue,
options,
exit
}); });
if (response == null) if (response == null)

View file

@ -1,28 +1,33 @@
import { VirtualFolder } from "../../virtual-drive/virtualFolder.js"; import { VirtualFolder } from "../../virtual-drive/virtualFolder.js";
import { VirtualRoot } from "../../virtual-drive/virtualRoot.js"; import { VirtualRoot } from "../../virtual-drive/virtualRoot.js";
/**
* @callback executeType
* @param {string[]} args
* @param {object} options
* @param {Function} options.promptOutput
* @param {Function} options.pushHistory
* @param {VirtualRoot} options.virtualRoot
* @param {VirtualFolder} options.currentDirectory
* @param {Function} options.setCurrentDirectory
* @param {string} options.username
* @param {string} options.hostname
* @param {string} options.rawInputValue
* @param {string[]} options.options
* @param {Function} options.exit
* @returns {string|{ blank: boolean }}
*/
export default class Command { export default class Command {
/** @type {string} */ /** @type {string} */
name; name;
/** /** @type {executeType} */
* @param {string[]} args execute = () => {};
* @param {object} options
* @param {Function} options.promptOutput
* @param {Function} options.pushHistory
* @param {VirtualRoot} options.virtualRoot
* @param {VirtualFolder} options.currentDirectory
* @param {Function} options.setCurrentDirectory
* @param {string} options.username
* @param {string} options.hostname
* @param {string} options.rawInputValue
* @returns {string|{ blank: boolean }}
*/
execute = (args, options) => {};
/** /**
* @param {string} name * @param {string} name
* @param {Function} execute * @param {executeType} execute
*/ */
constructor(name, execute) { constructor(name, execute) {
this.name = name; this.name = name;
@ -39,7 +44,7 @@ export default class Command {
} }
/** /**
* @param {Function} execute * @param {executeType} execute
* @returns {Command} * @returns {Command}
*/ */
setExecute(execute) { setExecute(execute) {
@ -57,11 +62,20 @@ export default class Command {
} }
/** /**
* @param {{ purpose: string, usage: string, description: string}} name * @param {boolean} value
* @returns {Command} * @returns {Command}
*/ */
setManual({ purpose, usage, description }) { setRequireOptions(value) {
this.manual = { purpose, usage, description }; this.requireOptions = value;
return this;
}
/**
* @param {{ purpose: string, usage: string, description: string, options: object }} manual
* @returns {Command}
*/
setManual({ purpose, usage, description, options }) {
this.manual = { purpose, usage, description, options };
return this; return this;
} }
} }

View file

@ -1,10 +1,13 @@
import Command from "./command.js";
import { blow } from "./commands/blow.js"; import { blow } from "./commands/blow.js";
import { cat } from "./commands/cat.js"; import { cat } from "./commands/cat.js";
import { cd } from "./commands/cd.js"; import { cd } from "./commands/cd.js";
import { clear } from "./commands/clear.js"; import { clear } from "./commands/clear.js";
import { compgen } from "./commands/compgen.js";
import { cowsay } from "./commands/cowsay.js"; import { cowsay } from "./commands/cowsay.js";
import { dir } from "./commands/dir.js"; import { dir } from "./commands/dir.js";
import { echo } from "./commands/echo.js"; import { echo } from "./commands/echo.js";
import { exit } from "./commands/exit.js";
import { fortune } from "./commands/fortune.js"; import { fortune } from "./commands/fortune.js";
import { hostname } from "./commands/hostname.js"; import { hostname } from "./commands/hostname.js";
import { ls } from "./commands/ls.js"; import { ls } from "./commands/ls.js";
@ -18,12 +21,14 @@ import { reboot } from "./commands/reboot.js";
import { rm } from "./commands/rm.js"; import { rm } from "./commands/rm.js";
import { rmdir } from "./commands/rmdir.js"; import { rmdir } from "./commands/rmdir.js";
import { touch } from "./commands/touch.js"; import { touch } from "./commands/touch.js";
import { whatis } from "./commands/whatis.js";
import { whoami } from "./commands/whoami.js";
import { world } from "./commands/world.js"; import { world } from "./commands/world.js";
export default class CommandsManager { export default class CommandsManager {
/** /**
* @param {string} name * @param {string} name
* @returns {CommandsManager} * @returns {Command}
*/ */
static find(name) { static find(name) {
let matchCommand = null; let matchCommand = null;
@ -38,6 +43,15 @@ export default class CommandsManager {
return matchCommand; return matchCommand;
} }
/**
* @param {string} pattern
* @returns {Command[]}
*/
static search(pattern) {
const matches = this.COMMANDS.filter((command) => command.name.match(pattern));
return matches;
}
static COMMANDS = [ static COMMANDS = [
echo, echo,
clear, clear,
@ -60,5 +74,9 @@ export default class CommandsManager {
cat, cat,
man, man,
reboot, reboot,
compgen,
whoami,
whatis,
exit,
]; ];
} }

View file

@ -1,5 +1,6 @@
import Command from "../command.js"; import Command from "../command.js";
export const blow = new Command("%blow", () => { export const blow = new Command("%blow")
return "fg: %blow: No such job"; .setExecute(function() {
}); return `fg: ${this.name}: No such job`;
});

View file

@ -7,15 +7,20 @@ export const cat = new Command("cat")
usage: "cat [OPTION]... [FILE]...", usage: "cat [OPTION]... [FILE]...",
description: "Concetenate FILE(s) to standard output." description: "Concetenate FILE(s) to standard output."
}) })
.setExecute((args, { currentDirectory }) => { .setExecute(function(args, { currentDirectory, options }) {
const [name, extension] = args[0].split("."); const [name, extension] = args[0].split(".");
const file = currentDirectory.findFile(name, extension); const file = currentDirectory.findFile(name, extension);
if (!file) if (!file)
return `rm: ${args[0]}: No such file`; return `${this.name}: ${args[0]}: No such file`;
if (file.content) { if (file.content) {
return file.content; if (!options.includes("e")) {
return file.content;
} else {
// Append "$" at the end of every line
return file.content.split("\n").join("$\n") + "$";
}
} else if (file.source) { } else if (file.source) {
return `Src: ${file.source}`; return `Src: ${file.source}`;
} else { } else {

View file

@ -1,13 +1,20 @@
import Command from "../command.js"; import Command from "../command.js";
export const cd = new Command("cd", (args, { currentDirectory, setCurrentDirectory }) => { export const cd = new Command("cd")
const path = args[0] ?? "~"; .setRequireArgs(true)
const destination = currentDirectory.navigate(path); .setManual({
purpose: "Change directory",
usage: "cd path",
description: "Change working directory to given path (the home directory by default)."
})
.setExecute(function(args, { currentDirectory, setCurrentDirectory }) {
const path = args[0] ?? "~";
const destination = currentDirectory.navigate(path);
if (!destination) if (!destination)
return `cd: ${args[0]}: No such file or directory`; return `${this.name}: ${args[0]}: No such file or directory`;
console.log(destination); console.log(destination);
setCurrentDirectory(destination); setCurrentDirectory(destination);
return { blank: true }; return { blank: true };
}).setRequireArgs(true); });

View file

@ -1,10 +1,15 @@
import Command from "../command.js"; import Command from "../command.js";
export const clear = new Command("clear", (args, { pushHistory }) => { export const clear = new Command("clear")
pushHistory({ .setManual({
clear: true, purpose: "Clear terminal screen",
isInput: false usage: "clear",
}); })
.setExecute(function(args, { pushHistory }) {
pushHistory({
clear: true,
isInput: false
});
return { blank: true }; return { blank: true };
}); });

View file

@ -0,0 +1,10 @@
import Command from "../command.js";
import CommandsManager from "../commands.js";
export const compgen = new Command("compgen")
.setRequireOptions(true)
.setExecute(function(args, { options }) {
if (options.includes("c")) {
return CommandsManager.COMMANDS.map((command) => command.name).sort().join("\n");
}
});

View file

@ -8,92 +8,99 @@ const COW = `
||----w | ||----w |
|| ||`; || ||`;
export const cowsay = new Command("cowsay", (args, { rawInputValue }) => { export const cowsay = new Command("cowsay")
// Separate input value into lines .setRequireArgs(true)
const segments = rawInputValue.split(" "); .setManual({
const lines = []; purpose: "Show a cow saying something",
let currentLine = ""; usage: "cowsay text",
let maxLineWidth = 0; description: "Show ASCII art of a cow saying something."
})
.setExecute(function(args, { rawInputValue }) {
// Separate input value into lines
const segments = rawInputValue.split(" ");
const lines = [];
let currentLine = "";
let maxLineWidth = 0;
const addLine = (line) => { const addLine = (line) => {
line = line.trimEnd(); line = line.trimEnd();
lines.push(line); lines.push(line);
if (line.length > maxLineWidth) if (line.length > maxLineWidth)
maxLineWidth = line.length; maxLineWidth = line.length;
}; };
const nextLine = (word) => { const nextLine = (word) => {
addLine(currentLine); addLine(currentLine);
if (word) { if (word) {
currentLine = word + " "; currentLine = word + " ";
} else { } else {
currentLine = ""; currentLine = "";
} }
}; };
segments.forEach((segment) => { segments.forEach((segment) => {
// Add empty spaces preceding lines // Add empty spaces preceding lines
if (segment === "") { if (segment === "") {
currentLine += " "; currentLine += " ";
return; return;
} }
const words = segment.split("\n"); const words = segment.split("\n");
for (let i = 0; i < words.length; i++) { for (let i = 0; i < words.length; i++) {
const word = words[i]; const word = words[i];
// Handle next lines // Handle next lines
if (i > 0) if (i > 0)
nextLine(); nextLine();
// Fit word on current line // Fit word on current line
if ((currentLine + word).length <= MAX_WIDTH) { if ((currentLine + word).length <= MAX_WIDTH) {
currentLine += word + " "; currentLine += word + " ";
} else if (word.length > MAX_WIDTH) { } else if (word.length > MAX_WIDTH) {
const remainingSpaces = MAX_WIDTH - currentLine.length; const remainingSpaces = MAX_WIDTH - currentLine.length;
if (remainingSpaces >= 2) { if (remainingSpaces >= 2) {
addLine(currentLine + word.substring(0, remainingSpaces - 1) + "-"); addLine(currentLine + word.substring(0, remainingSpaces - 1) + "-");
currentLine = word.substring(remainingSpaces - 1) + " "; currentLine = word.substring(remainingSpaces - 1) + " ";
} else {
nextLine(word);
}
} else { } else {
nextLine(word); nextLine(word);
} }
} else {
nextLine(word);
} }
});
if (currentLine.length > 0)
addLine(currentLine);
// Turn lines into speech bubble
const speechBubble = [` ${"_".repeat(maxLineWidth + 2)} `];
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
const missingSpaces = maxLineWidth - line.length;
if (missingSpaces > 0)
line += " ".repeat(missingSpaces);
if (lines.length > 1) {
if (i === 0) {
line = `/ ${line} \\`;
} else if (i === lines.length - 1) {
line = `\\ ${line} /`;
} else {
line = `| ${line} |`;
}
} else {
line = `< ${line} >`;
}
speechBubble.push(line);
} }
speechBubble.push(` ${"-".repeat(maxLineWidth + 2)} `);
return speechBubble.join("\n") + COW;
}); });
if (currentLine.length > 0)
addLine(currentLine);
// Turn lines into speech bubble
const speechBubble = [` ${"_".repeat(maxLineWidth + 2)} `];
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
const missingSpaces = maxLineWidth - line.length;
if (missingSpaces > 0)
line += " ".repeat(missingSpaces);
if (lines.length > 1) {
if (i === 0) {
line = `/ ${line} \\`;
} else if (i === lines.length - 1) {
line = `\\ ${line} /`;
} else {
line = `| ${line} |`;
}
} else {
line = `< ${line} >`;
}
speechBubble.push(line);
}
speechBubble.push(` ${"-".repeat(maxLineWidth + 2)} `);
return speechBubble.join("\n") + COW;
}).setRequireArgs(true);

View file

@ -0,0 +1,6 @@
import Command from "../command.js";
export const exit = new Command("exit", (args, { exit }) => {
exit();
return { blank: true };
});

View file

@ -7,7 +7,7 @@ export const ls = new Command("ls")
description: "List information about the FILEs (the current directory by default).\n" description: "List information about the FILEs (the current directory by default).\n"
+ "Sort entries alphabetically if none of -cftuvSUX nor --sort is specified." + "Sort entries alphabetically if none of -cftuvSUX nor --sort is specified."
}) })
.setExecute((args, { currentDirectory }) => { .setExecute(function(args, { currentDirectory }) {
let directory = currentDirectory; let directory = currentDirectory;
if (args.length > 0) { if (args.length > 0) {
@ -15,7 +15,7 @@ export const ls = new Command("ls")
} }
if (!directory) if (!directory)
return `ls: Cannot access '${args[0]}': No such file or 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) => folder.id);
const fileNames = directory.files.map((file) => file.id); const fileNames = directory.files.map((file) => file.id);

View file

@ -1,6 +1,7 @@
import Command from "../command.js"; import Command from "../command.js";
export const make = new Command("make", (args) => { export const make = new Command("make")
if (args[0] === "love") .setExecute(function(args) {
return "make: *** No rule to make target `love'. Stop."; if (args[0] === "love")
}); return `${this.name}: *** No rule to make target 'love'. Stop.`;
});

View file

@ -6,22 +6,38 @@ const MARGIN = 5;
export const man = new Command("man") export const man = new Command("man")
.setRequireArgs(true) .setRequireArgs(true)
.setManual({ .setManual({
purpose: "show system reference manuals", purpose: "Show system reference manuals",
usage: "man [OPTION]... page", usage: "man [OPTION]... page\n"
+ "man -k [OPTION]... regexp",
description: "Each page arguments given to man is normally the name of a command.\n" description: "Each page arguments given to man is normally the name of a command.\n"
+ "The manual page associated with this command is then found and displayed." + "The manual page associated with this command is then found and displayed.",
options: {
"-k": "Search for manual page using regexp"
}
}) })
.setExecute((args) => { .setExecute(function(args, { options }) {
// Search function
if (options.includes("k")) {
const commands = CommandsManager.search(args[0]);
return commands.map((command) => {
if (command.manual?.purpose) {
return `${command.name} - ${command.manual.purpose}`;
} else {
return command.name;
}
}).join("\n");
}
const commandName = args[0].toLowerCase(); const commandName = args[0].toLowerCase();
const command = CommandsManager.find(commandName); const command = CommandsManager.find(commandName);
if (!command) if (!command)
return `man: ${commandName}: Command not found`; return `${this.name}: ${commandName}: Command not found`;
const manual = command.manual; const manual = command.manual;
if (!manual) if (!manual)
return `man: ${commandName}: No manual found`; return `${this.name}: ${commandName}: No manual found`;
const formatText = (text) => { const formatText = (text) => {
const lines = text.split("\n").map((line) => " ".repeat(MARGIN) + line); const lines = text.split("\n").map((line) => " ".repeat(MARGIN) + line);
@ -50,5 +66,14 @@ export const man = new Command("man")
]); ]);
} }
if (manual.options) {
sections.push([
"OPTIONS",
formatText(Object.entries(manual.options).map(([key, value]) => {
return `${key} ${value}`;
}).join("\n"))
]);
}
return sections.map((section) => section.join("\n")).join("\n\n"); return sections.map((section) => section.join("\n")).join("\n\n");
}); });

View file

@ -1,6 +1,7 @@
import Command from "../command.js"; import Command from "../command.js";
export const nice = new Command("nice", (args) => { export const nice = new Command("nice")
if (args[0] === "man" && args[1] === "woman") .setExecute(function(args) {
return "nice: No manual entry for woman"; if (args[0] === "man" && args[1] === "woman")
}); return `${this.name}: No manual entry for woman`;
});

View file

@ -5,7 +5,7 @@ export const rm = new Command("rm", (args, { currentDirectory }) => {
const file = currentDirectory.findFile(name, extension); const file = currentDirectory.findFile(name, extension);
if (!file) if (!file)
return `rm: ${args[0]}: No such file`; return `${this.name}: ${args[0]}: No such file`;
file.delete(); file.delete();
return { blank: true }; return { blank: true };

View file

@ -5,7 +5,7 @@ export const rmdir = new Command("rmdir", (args, { currentDirectory }) => {
const folder = currentDirectory.findSubFolder(name); const folder = currentDirectory.findSubFolder(name);
if (!folder) if (!folder)
return `rmdir: ${args[0]}: No such directory`; return `${this.name}: ${args[0]}: No such directory`;
folder.delete(); folder.delete();
return { blank: true }; return { blank: true };

View file

@ -3,14 +3,14 @@ import Command from "../command.js";
export const touch = new Command("touch") export const touch = new Command("touch")
.setRequireArgs(true) .setRequireArgs(true)
.setManual({ .setManual({
purpose: "change file timestamps", purpose: "Change file timestamps",
usage: "touch [OPTION]... FILE...", usage: "touch [OPTION]... FILE...",
description: "Update the access and modification times of each FILE to the current time.\n\n" description: "Update the access and modification times of each FILE to the current time.\n\n"
+ "A FILE argument that does not exist is created empty." + "A FILE argument that does not exist is created empty."
}) })
.setExecute((args, { currentDirectory }) => { .setExecute(function(args, { currentDirectory }) {
if (args[0] === "girls\\" && args[1] === "boo**") if (args[0] === "girls\\" && args[1] === "boo**")
return "touch: Cannot touch 'girls boo**': Permission denied"; return `${this.name}: Cannot touch 'girls boo**': Permission denied`;
const [name, extension] = args[0].split("."); const [name, extension] = args[0].split(".");

View file

@ -0,0 +1,17 @@
import Command from "../command.js";
import CommandsManager from "../commands.js";
export const whatis = new Command("whatis")
.setRequireArgs(true)
.setExecute(function(args) {
const commandName = args[0].toLowerCase();
const command = CommandsManager.find(commandName);
if (!command)
return `${this.name}: ${commandName}: Command not found`;
if (!command.manual?.purpose)
return `${this.name}: ${commandName}: No information found`;
return `${commandName} - ${command.manual.purpose}`;
});

View file

@ -0,0 +1,5 @@
import Command from "../command.js";
export const whoami = new Command("whoami", (args, { username }) => {
return username;
});

View file

@ -1,5 +1,6 @@
import Command from "../command.js"; import Command from "../command.js";
export const world = new Command("world", () => { export const world = new Command("world")
return "world: Not found"; .setExecute(function() {
}); return `${this.name}: Not found`;
});