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 { HOSTNAME, USERNAME } from "../../../constants/applications/terminal.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 [inputValue, setInputValue] = useState("");
const [history, setHistory] = useState([]);
@ -43,22 +44,46 @@ export function Terminal({ setTitle }) {
if (value === "")
return;
const args = value.split(/ +/);
// Get arguments
const args = value.match(/(?:[^\s"]+|"[^"]*")+/g);
if (args[0].toLowerCase() === "sudo" && args.length > 1) {
args.shift();
}
// Get command
const commandName = args.shift().toLowerCase();
const command = CommandsManager.find(commandName);
if (!command)
return `${commandName}: Command not found`;
// Get options
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 syntax: ${commandName} requires at least 1 argument`;
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;
try {
@ -70,7 +95,9 @@ export function Terminal({ setTitle }) {
setCurrentDirectory,
username: USERNAME,
hostname: HOSTNAME,
rawInputValue
rawInputValue,
options,
exit
});
if (response == null)

View file

@ -1,28 +1,33 @@
import { VirtualFolder } from "../../virtual-drive/virtualFolder.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 {
/** @type {string} */
name;
/**
* @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
* @returns {string|{ blank: boolean }}
*/
execute = (args, options) => {};
/** @type {executeType} */
execute = () => {};
/**
* @param {string} name
* @param {Function} execute
* @param {executeType} execute
*/
constructor(name, execute) {
this.name = name;
@ -39,7 +44,7 @@ export default class Command {
}
/**
* @param {Function} execute
* @param {executeType} execute
* @returns {Command}
*/
setExecute(execute) {
@ -57,11 +62,20 @@ export default class Command {
}
/**
* @param {{ purpose: string, usage: string, description: string}} name
* @param {boolean} value
* @returns {Command}
*/
setManual({ purpose, usage, description }) {
this.manual = { purpose, usage, description };
setRequireOptions(value) {
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;
}
}

View file

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

View file

@ -1,5 +1,6 @@
import Command from "../command.js";
export const blow = new Command("%blow", () => {
return "fg: %blow: No such job";
});
export const blow = new Command("%blow")
.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]...",
description: "Concetenate FILE(s) to standard output."
})
.setExecute((args, { currentDirectory }) => {
.setExecute(function(args, { currentDirectory, options }) {
const [name, extension] = args[0].split(".");
const file = currentDirectory.findFile(name, extension);
if (!file)
return `rm: ${args[0]}: No such file`;
return `${this.name}: ${args[0]}: No such file`;
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) {
return `Src: ${file.source}`;
} else {

View file

@ -1,13 +1,20 @@
import Command from "../command.js";
export const cd = new Command("cd", (args, { currentDirectory, setCurrentDirectory }) => {
const path = args[0] ?? "~";
const destination = currentDirectory.navigate(path);
if (!destination)
return `cd: ${args[0]}: No such file or directory`;
console.log(destination);
setCurrentDirectory(destination);
return { blank: true };
}).setRequireArgs(true);
export const cd = new Command("cd")
.setRequireArgs(true)
.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)
return `${this.name}: ${args[0]}: No such file or directory`;
console.log(destination);
setCurrentDirectory(destination);
return { blank: true };
});

View file

@ -1,10 +1,15 @@
import Command from "../command.js";
export const clear = new Command("clear", (args, { pushHistory }) => {
pushHistory({
clear: true,
isInput: false
});
return { blank: true };
});
export const clear = new Command("clear")
.setManual({
purpose: "Clear terminal screen",
usage: "clear",
})
.setExecute(function(args, { pushHistory }) {
pushHistory({
clear: true,
isInput: false
});
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 |
|| ||`;
export const cowsay = new Command("cowsay", (args, { rawInputValue }) => {
// Separate input value into lines
const segments = rawInputValue.split(" ");
const lines = [];
let currentLine = "";
let maxLineWidth = 0;
export const cowsay = new Command("cowsay")
.setRequireArgs(true)
.setManual({
purpose: "Show a cow saying something",
usage: "cowsay text",
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) => {
line = line.trimEnd();
lines.push(line);
if (line.length > maxLineWidth)
maxLineWidth = line.length;
};
const addLine = (line) => {
line = line.trimEnd();
lines.push(line);
if (line.length > maxLineWidth)
maxLineWidth = line.length;
};
const nextLine = (word) => {
addLine(currentLine);
const nextLine = (word) => {
addLine(currentLine);
if (word) {
currentLine = word + " ";
} else {
currentLine = "";
}
};
if (word) {
currentLine = word + " ";
} else {
currentLine = "";
}
};
segments.forEach((segment) => {
// Add empty spaces preceding lines
if (segment === "") {
currentLine += " ";
return;
}
segments.forEach((segment) => {
// Add empty spaces preceding lines
if (segment === "") {
currentLine += " ";
return;
}
const words = segment.split("\n");
for (let i = 0; i < words.length; i++) {
const word = words[i];
// Handle next lines
if (i > 0)
nextLine();
const words = segment.split("\n");
for (let i = 0; i < words.length; i++) {
const word = words[i];
// Handle next lines
if (i > 0)
nextLine();
// Fit word on current line
if ((currentLine + word).length <= MAX_WIDTH) {
currentLine += word + " ";
} else if (word.length > MAX_WIDTH) {
const remainingSpaces = MAX_WIDTH - currentLine.length;
// Fit word on current line
if ((currentLine + word).length <= MAX_WIDTH) {
currentLine += word + " ";
} else if (word.length > MAX_WIDTH) {
const remainingSpaces = MAX_WIDTH - currentLine.length;
if (remainingSpaces >= 2) {
addLine(currentLine + word.substring(0, remainingSpaces - 1) + "-");
currentLine = word.substring(remainingSpaces - 1) + " ";
if (remainingSpaces >= 2) {
addLine(currentLine + word.substring(0, remainingSpaces - 1) + "-");
currentLine = word.substring(remainingSpaces - 1) + " ";
} else {
nextLine(word);
}
} else {
nextLine(word);
}
} else {
nextLine(word);
}
}
});
});
if (currentLine.length > 0)
addLine(currentLine);
if (currentLine.length > 0)
addLine(currentLine);
// Turn lines into speech bubble
const speechBubble = [` ${"_".repeat(maxLineWidth + 2)} `];
// 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;
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
const missingSpaces = maxLineWidth - line.length;
if (missingSpaces > 0)
line += " ".repeat(missingSpaces);
if (missingSpaces > 0)
line += " ".repeat(missingSpaces);
if (lines.length > 1) {
if (i === 0) {
line = `/ ${line} \\`;
} else if (i === lines.length - 1) {
line = `\\ ${line} /`;
if (lines.length > 1) {
if (i === 0) {
line = `/ ${line} \\`;
} else if (i === lines.length - 1) {
line = `\\ ${line} /`;
} else {
line = `| ${line} |`;
}
} else {
line = `| ${line} |`;
line = `< ${line} >`;
}
} else {
line = `< ${line} >`;
speechBubble.push(line);
}
speechBubble.push(line);
}
speechBubble.push(` ${"-".repeat(maxLineWidth + 2)} `);
speechBubble.push(` ${"-".repeat(maxLineWidth + 2)} `);
return speechBubble.join("\n") + COW;
}).setRequireArgs(true);
return speechBubble.join("\n") + COW;
});

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"
+ "Sort entries alphabetically if none of -cftuvSUX nor --sort is specified."
})
.setExecute((args, { currentDirectory }) => {
.setExecute(function(args, { currentDirectory }) {
let directory = currentDirectory;
if (args.length > 0) {
@ -15,7 +15,7 @@ export const ls = new Command("ls")
}
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 fileNames = directory.files.map((file) => file.id);

View file

@ -1,6 +1,7 @@
import Command from "../command.js";
export const make = new Command("make", (args) => {
if (args[0] === "love")
return "make: *** No rule to make target `love'. Stop.";
});
export const make = new Command("make")
.setExecute(function(args) {
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")
.setRequireArgs(true)
.setManual({
purpose: "show system reference manuals",
usage: "man [OPTION]... page",
purpose: "Show system reference manuals",
usage: "man [OPTION]... page\n"
+ "man -k [OPTION]... regexp",
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 command = CommandsManager.find(commandName);
if (!command)
return `man: ${commandName}: Command not found`;
return `${this.name}: ${commandName}: Command not found`;
const manual = command.manual;
if (!manual)
return `man: ${commandName}: No manual found`;
return `${this.name}: ${commandName}: No manual found`;
const formatText = (text) => {
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");
});

View file

@ -1,6 +1,7 @@
import Command from "../command.js";
export const nice = new Command("nice", (args) => {
if (args[0] === "man" && args[1] === "woman")
return "nice: No manual entry for woman";
});
export const nice = new Command("nice")
.setExecute(function(args) {
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);
if (!file)
return `rm: ${args[0]}: No such file`;
return `${this.name}: ${args[0]}: No such file`;
file.delete();
return { blank: true };

View file

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

View file

@ -3,14 +3,14 @@ import Command from "../command.js";
export const touch = new Command("touch")
.setRequireArgs(true)
.setManual({
purpose: "change file timestamps",
purpose: "Change file timestamps",
usage: "touch [OPTION]... FILE...",
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."
})
.setExecute((args, { currentDirectory }) => {
.setExecute(function(args, { currentDirectory }) {
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(".");

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";
export const world = new Command("world", () => {
return "world: Not found";
});
export const world = new Command("world")
.setExecute(function() {
return `${this.name}: Not found`;
});