diff --git a/README.md b/README.md index 369a2c2..4c8b1ef 100644 --- a/README.md +++ b/README.md @@ -32,13 +32,14 @@ mterm is customizable in a few ways - - quick add a command with `:cmd command_name` and edit this - add extensions to the terminal - quick add an extension with `ext add mterm-red`, see [extensions](./docs/extensions.md) for more info + - make your own extensions, see [mterm-ext-template](https://github.com/mterm-io/mterm-ext-template) for a starting point - change the [theme](#theme) of the terminal - quick change the theme with `:theme` or `:css` - change the [settings](#settings) of the terminal - quick change the settings with `:settings edit` - change the [profile](#configure) of the terminal to a desired interpreter - quick change the profile with `:settings set defaultProfile wsl` (change wsl with the desired profile) - + https://github.com/mterm-io/mterm/assets/7341502/c920853f-1f27-4ef9-ae72-945f1663e36d @@ -160,26 +161,29 @@ here is an example `~/mterm/settings.json` - mterm provided a few system commands to help control the terminal and settings. mterm settings will always start with `:` (a colon) unless the intention is to override a system command. for example, because `clear` needs to be handled in a special way for mterm windows + tabs, it is overriden in mterm. -| Command | Alias | Purpose | -|------------------------------|-------------|----------------------------------------------------------------------------------------| -| `clear` | `cls` | Clear the current terminal output | -| `cd` | | Navigate the file tree on the host machine | -| `:exit` | `exit` | Exit the current tab, or mterm if only 1 tab is open | -| `:edit` | `edit` | Open the in-terminal editor with the file provided. Hit `Ctrl+S` to save in the editor | -| `:history` | | Print out terminal history for debugging in a JSON format | -| `:reload` | | Reload settings, the ui, and commands without restarting | -| `:tab` | | Open a new tab | -| `:test` | | Sample command that executes after 10 seconds. Helpful for debugging | -| `:vault` | | Open the secret management tool, the mterm vault | -| `:version` | `:v` | Print the current mterm version | -| `:workspace` | | Open the mterm workspace folder on disk: `~/mterm` | -| `:theme` | `:css` | Edit the terminal theme real time | -| `:cmd` | `:commands` | Edit the command file | -| `:cmd {cmd_name}` | | Edit the command file for the cmd_name, creates if this doesn't exist | -| `:settings` | | Open the mterm settings gui to manage `~/mterm/settings.json` | -| `:settings edit` | | Open the `~/mterm/settings.json` in the terminal editor with hot reloading | -| `:settings reload` | | Reload `~/mterm/settings.json` and all ui etc associated with the settings | -| `:settings {get\|set} {key}` | | Set the setting key matching the path in `~/mterm/settings.json` and reload | +| Command | Alias | Purpose | +|------------------------------|--------------|----------------------------------------------------------------------------------------| +| `clear` | `cls` | Clear the current terminal output | +| `cd` | | Navigate the file tree on the host machine | +| `:exit` | `exit` | Exit the current tab, or mterm if only 1 tab is open | +| `:edit` | `edit` | Open the in-terminal editor with the file provided. Hit `Ctrl+S` to save in the editor | +| `:history` | | Print out terminal history for debugging in a JSON format | +| `:reload` | | Reload settings, the ui, and commands without restarting | +| `:tab` | | Open a new tab | +| `:test` | | Sample command that executes after 10 seconds. Helpful for debugging | +| `:vault` | | Open the secret management tool, the mterm vault | +| `:version` | `:v` | Print the current mterm version | +| `:workspace` | | Open the mterm workspace folder on disk: `~/mterm` | +| `:theme` | `:css` | Edit the terminal theme real time | +| `:cmd` | `:commands ` | Edit the command file | +| `:ext` | `ext` | List terminal extensions | Edit the command file | +| `:ext add {extenstion}` | | Add the extension, see [extension](./docs/extensions.md) | Edit the command file | +| `:ext rm {extenstion}` | | Remove the extension, see [extension](./docs/extensions.md) | Edit the command file | +| `:cmd {cmd_name}` | | Edit the command file for the cmd_name, creates if this doesn't exist | +| `:settings` | | Open the mterm settings gui to manage `~/mterm/settings.json` | +| `:settings edit` | | Open the `~/mterm/settings.json` in the terminal editor with hot reloading | +| `:settings reload` | | Reload `~/mterm/settings.json` and all ui etc associated with the settings | +| `:settings {get\|set} {key}` | | Set the setting key matching the path in `~/mterm/settings.json` and reload | ### Commands diff --git a/docs/extensions.md b/docs/extensions.md index 4689f8b..c6724c8 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -3,8 +3,11 @@ > Please make a pull request to add your extension to this list - [mterm-ext-red](#mterm-ext-red) -- more... - + - poof, red terminal +- [mterm-ext-google](#mterm-ext-google) + - open the google search from the terminal +- want to make your own? check out the [mterm-ext-template](https://github.com/mterm-io/mterm-ext-template) and make a pull request to add it to this list + - note ANY extenstion that is published to npmjs.org is already available to be installed with `ext add ` ### mterm-ext-red @@ -14,5 +17,22 @@ ext add mterm-red ``` Make your terminal (mterm) red 10 seconds - -![red terminal](https://github.com/mterm-io/mterm-ext-red/blob/HEAD/info.png?raw=true) +https://github.com/mterm-io/mterm/assets/7341502/c920853f-1f27-4ef9-ae72-945f1663e36d + +### mterm-ext-google + +> source: https://github.com/mterm-io/mterm-ext-google +```bash +ext add mterm-google +``` + +```bash +google "search term" +google "search \" term" +google word1 +``` + +Search away - + +https://github.com/mterm-io/mterm/assets/7341502/f49c4133-e54a-4235-9cbf-e157490e9d97 diff --git a/src/main/framework/commands.ts b/src/main/framework/commands.ts index ddec87d..a47e9f9 100644 --- a/src/main/framework/commands.ts +++ b/src/main/framework/commands.ts @@ -10,7 +10,9 @@ import { ExecuteContext } from './execute-context' import { CommandUtils } from './command-utils' import { shell } from 'electron' import { snakeCase } from 'lodash' + export class Commands { + public libManual: Map = new Map() public lib: object = {} public commandFileLocation: string = '' public state: Map = new Map() @@ -58,6 +60,7 @@ export class Commands { if (!context.workspace.store.unlocked) { throw 'Vault is locked, unlock before using secrets. Open with :vault' } + return context.workspace.store.get(key, orElse) } } @@ -117,7 +120,11 @@ export class Commands { const jsFile: Buffer = await readFile(join(temp, 'commands.js')) - this.lib = {} + Object.keys(this.lib).forEach((key) => { + if (!this.libManual.get(key)) { + delete this.lib[key] + } + }) runInNewContext(`${jsFile}`, this) @@ -174,4 +181,59 @@ export class Commands { await writeFile(this.commandFileLocation, script) } + + add(command: string, exec: () => void): void { + const nameNormalized = Commands.toCommandName(command) + + this.lib[nameNormalized] = exec + + this.libManual.set(nameNormalized, true) + } + + delete(command: string): void { + const nameNormalized = Commands.toCommandName(command) + + delete this.lib[nameNormalized] + + this.libManual.delete(nameNormalized) + this.state.delete(nameNormalized) + } +} + +/** + * Splits a string into an array of substrings based on spaces, preserving spaces within quoted strings. + * Supports both single and double quotes, and handles escaped quotes within quoted text. + * The outer quotes, single or double, are removed from the returned substrings. + * + * @param {string} input - The input string to be split into arguments. + * @returns {string[]} An array of substrings representing the split arguments. + * + * @example + * const input = 'echo "Hello, world!" | grep "Hello \\"Hello"" \'Single Quoted\' \'Escaped\\\'Single\' "Double \\"Quoted\\""'; + * const result = splitArgs(input); + * console.log(result); + * // Output: ["echo", "Hello, world!", "|", "grep", "Hello "Hello"", "Single Quoted", "Escaped'Single", "Double "Quoted""] + * + * @example + * const input = 'google "hello \\" world"'; + * const result = splitArgs(input); + * console.log(result); + * // Output: ["google", "hello " world"] + */ +export function splitArgs(input: string): string[] { + const regex = /[^\s'"]+|'([^'\\]*(?:\\.[^'\\]*)*)'|"([^"\\]*(?:\\.[^"\\]*)*)"/gi + const matches: string[] = [] + let match: RegExpExecArray | null + + while ((match = regex.exec(input)) !== null) { + if (match[1] !== undefined) { + matches.push(match[1].replace(/\\(.)/g, '$1')) + } else if (match[2] !== undefined) { + matches.push(match[2].replace(/\\(.)/g, '$1')) + } else { + matches.push(match[0]) + } + } + + return matches } diff --git a/src/main/framework/execute-context.ts b/src/main/framework/execute-context.ts index ff2ba31..904dd3d 100644 --- a/src/main/framework/execute-context.ts +++ b/src/main/framework/execute-context.ts @@ -133,6 +133,7 @@ export class ExecuteContext { const R = this.out(container(html), false) const sender = this.sender const eventHandlers = this.eventHandlers + return { id, update(newHTML: string): void { @@ -195,7 +196,7 @@ export class ExecuteContext { this.sender.send('runtime.commandEvent') } - finish(code: number): void { + finish(code: number = 0): void { if (this.command.aborted || this.command.complete) { return } diff --git a/src/main/framework/extensions.ts b/src/main/framework/extensions.ts index 22c7a6e..0891c60 100644 --- a/src/main/framework/extensions.ts +++ b/src/main/framework/extensions.ts @@ -4,36 +4,88 @@ import { pathExists, readJson } from 'fs-extra' import { log } from '../logger' export enum ExtensionHook { - RUNNER_THEME_CSS = 'RUNNER_THEME_CSS' + RUNNER_THEME_CSS = 'RUNNER_THEME_CSS', + EXT_POST_INSTALL = 'EXT_POST_INSTALL', + COMMANDS = 'COMMANDS' } -export type ExtensionHookCallback = (workspace?: Workspace) => string -export type ExtensionHookResolution = string | ExtensionHookCallback +export type ExtensionHookCallback = (...args) => T +export type ExtensionHookResolution = string | ExtensionHookCallback + +export interface ExtensionHookResolutionContainer { + packageName: string + resolution: ExtensionHookResolution + hook: ExtensionHook +} + +export interface ExtensionCommandContainer { + packageName: string + command: string +} export class Extensions { - public extensionHooks: Map> = new Map< - ExtensionHook, - Array - >() + public extensionHooks: Map>> = + new Map>>() public extensionList: string[] = [] + public extensionCommands: ExtensionCommandContainer[] = [] constructor(private workspace: Workspace) {} - async run(hook: ExtensionHook): Promise { + async run(hook: ExtensionHook, ifNullThen: T, ...args): Promise { const resolutions = this.extensionHooks.get(hook) || [] - let result = '' - for (const resolution of resolutions) { + let stringResult = '' + let result: T | undefined = undefined + for (const container of resolutions) { + const resolution = container.resolution + + if (resolution === undefined) { + continue + } + if (typeof resolution === 'string') { - result += resolution + stringResult = stringResult || '' + stringResult += resolution + + result = stringResult as unknown as T } else { - result += resolution(this.workspace) + const maybeResult = resolution(...args) as T + + if (typeof maybeResult === 'string') { + stringResult = stringResult || '' + stringResult += resolution + + result = stringResult as unknown as T + } } } - return result + return result || ifNullThen + } + + async execute(hook: ExtensionHook, ...args): Promise { + await this.executeFor(hook, '*', ...args) + } + + async executeFor(hook: ExtensionHook, extName: string, ...args): Promise { + const resolutions = (this.extensionHooks.get(hook) || []).filter( + (r) => extName === '*' || r['packageName'] === extName + ) + + for (const container of resolutions) { + const resolution = container.resolution + if (typeof resolution === 'function') { + await resolution(...args) + } + } } async load(): Promise { + this.extensionCommands.forEach((cmd) => { + // clean up prior command registrations + this.workspace.commands.delete(cmd.command) + }) + this.extensionList = [] + this.extensionCommands = [] this.extensionHooks.clear() const start = Date.now() @@ -53,13 +105,14 @@ export class Extensions { packages.push(...Object.keys(packageJsonData.devDependencies)) } - const folder = this.workspace.folder + const workspace = this.workspace const ext = this.extensionHooks const list = this.extensionList + const cmdList = this.extensionCommands async function scan(packageName: string): Promise { log(`Scanning package: ${packageName}..`) - const mtermExtPath = join(folder, 'node_modules', packageName, 'mterm.js') + const mtermExtPath = join(workspace.folder, 'node_modules', packageName, 'mterm.js') const isMtermExtensionExists = await pathExists(mtermExtPath) if (!isMtermExtensionExists) { @@ -77,12 +130,40 @@ export class Extensions { for (const hook of hooks) { const hookKey = hook as ExtensionHook - let resolutions: ExtensionHookResolution[] = [] + if (hookKey === ExtensionHook.COMMANDS) { + const commands = mtermExt[hookKey] + if (typeof commands === 'function') { + const commandsResult = commands() + + Object.keys(commandsResult).forEach((command) => { + const { description, exec } = commandsResult[command] + + log(`Registering command: ${command} for ${packageName} (${description})`) + + workspace.commands.add(command, exec) + + cmdList.push({ + packageName, + command + }) + }) + } + continue + } + let resolutions: ExtensionHookResolutionContainer[] = [] if (ext.has(ExtensionHook[hookKey])) { - resolutions = ext.get(ExtensionHook[hookKey]) as ExtensionHookResolution[] + resolutions = ext.get( + ExtensionHook[hookKey] + ) as ExtensionHookResolutionContainer[] } - resolutions.push(mtermExt[hookKey]) + const extHook = mtermExt[hookKey] + + resolutions.push({ + packageName, + resolution: extHook, + hook: hookKey + }) ext.set(hookKey, resolutions) } diff --git a/src/main/framework/runtime-events.ts b/src/main/framework/runtime-events.ts index 4364cd3..ebbbe23 100644 --- a/src/main/framework/runtime-events.ts +++ b/src/main/framework/runtime-events.ts @@ -212,7 +212,11 @@ export function attach({ app, workspace }: BootstrapContext): void { ipcMain.handle('runner.theme', async (_, profile): Promise => { const theme = workspace.theme.get(profile) - const extensionTheme = await workspace.extensions.run(ExtensionHook.RUNNER_THEME_CSS) + const extensionTheme = await workspace.extensions.run( + ExtensionHook.RUNNER_THEME_CSS, + '', + workspace + ) return `${extensionTheme}${theme}` }) diff --git a/src/main/framework/runtime.ts b/src/main/framework/runtime.ts index f241426..7aed4ad 100644 --- a/src/main/framework/runtime.ts +++ b/src/main/framework/runtime.ts @@ -4,6 +4,7 @@ import { ChildProcessWithoutNullStreams } from 'node:child_process' import { resolve } from 'path' import { ResultStream } from './result-stream' import { ExecuteContext } from './execute-context' +import { splitArgs } from './commands' export interface Result { code: number @@ -49,14 +50,17 @@ export class Prompt { set value(value: string) { this._value = value - this.parts = value - .split('\n') - .map((line) => line.trim()) - .join(' ') - .split(' ') + this.parts = splitArgs( + value + .split('\n') + .map((line) => line.trim()) + .join(' ') + ) const [cmd, ...args] = this.parts + console.log(args) + this.cmd = cmd this.args = args } diff --git a/src/main/framework/system-commands/ext.ts b/src/main/framework/system-commands/ext.ts index ec6ed09..8dc4186 100644 --- a/src/main/framework/system-commands/ext.ts +++ b/src/main/framework/system-commands/ext.ts @@ -1,4 +1,5 @@ import { ExecuteContext } from '../execute-context' +import { ExtensionHook } from '../extensions' async function ext(context: ExecuteContext, task?: string): Promise { if (!task) { @@ -10,7 +11,7 @@ ${context.workspace.extensions.extensionList.length > 0 ? '-' : ''}` + context.finish(0) return } - if (task === 'load') { + if (task === 'load' || task === 'reload') { await context.workspace.extensions.load() context.out('Extensions loaded\n') context.finish(0) @@ -31,7 +32,9 @@ ${context.workspace.extensions.extensionList.length > 0 ? '-' : ''}` + await context.workspace.extensions.load() - context.out('Done\n') + await context.workspace.extensions.executeFor(ExtensionHook.EXT_POST_INSTALL, extName, context) + + // context.out('Done\n') } else if (task === 'remove' || task === 'rm' || task === 'delete') { const extName = (context.prompt.args[1] || '').trim() if (!extName) { diff --git a/src/renderer/src/assets/runner.css b/src/renderer/src/assets/runner.css index 2a6ce9f..ac947d7 100644 --- a/src/renderer/src/assets/runner.css +++ b/src/renderer/src/assets/runner.css @@ -31,6 +31,7 @@ body { grid-template-rows: auto; justify-items: center; align-items: center; + padding: 50px; } .runner-input { @@ -39,8 +40,9 @@ body { display: flex; flex-wrap: wrap; max-height: 100%; + width: 100%; align-content: stretch; - justify-content: space-around; + justify-content: center; flex-direction: column; } diff --git a/src/renderer/src/runner/runner-ac.tsx b/src/renderer/src/runner/runner-ac.tsx index 6ad0e95..f409d42 100644 --- a/src/renderer/src/runner/runner-ac.tsx +++ b/src/renderer/src/runner/runner-ac.tsx @@ -4,9 +4,14 @@ import { Suggestion, SuggestionEntryType } from './autocomplete' type RunnerACProps = { suggestion: Suggestion selection: number + isMultiLine: boolean } -export default function RunnerAC({ suggestion, selection }: RunnerACProps): ReactElement { - if (!suggestion || !suggestion.list.length) { +export default function RunnerAC({ + suggestion, + selection, + isMultiLine +}: RunnerACProps): ReactElement { + if (!suggestion || !suggestion.list.length || isMultiLine) { return <> } @@ -15,13 +20,13 @@ export default function RunnerAC({ suggestion, selection }: RunnerACProps): Reac const width = 600 const maxItemsVisible = 5 const entryHeight = 30 - const inputOffset = 50 + const inputOffset = isMultiLine ? 60 : 53 const topOffset = Math.min(maxItemsVisible, itemLength) * entryHeight const style = { height: `${topOffset}px`, width: `${width}px`, - top: `${inputOffset}px` + top: `${inputOffset}%` } function getLabel(type: SuggestionEntryType): string { diff --git a/src/renderer/src/runner/runner.tsx b/src/renderer/src/runner/runner.tsx index c5f4f45..1676301 100644 --- a/src/renderer/src/runner/runner.tsx +++ b/src/renderer/src/runner/runner.tsx @@ -102,11 +102,19 @@ export default function Runner(): ReactElement { window.electron.ipcRenderer.send('runtime.prompt', prompt) const cursor = inputRef?.current?.selectionEnd + window.electron.ipcRenderer.invoke('runtime.complete', prompt, cursor ?? -1).then((r) => { setSuggestion(r) setSuggestionSelection(0) }) } + + const onMultiLineChange = (_: string, e: ChangeEvent): void => { + setMultiLineArgs(e.target.value) + setSuggestion({ + list: [] + }) + } const handlePromptChange = ( event: ChangeEvent | ChangeEvent ): void => { @@ -175,6 +183,9 @@ export default function Runner(): ReactElement { ) if (runtime) setPrompt(runtime.prompt.slice(0, cursorPosition)) textAreaRef.current?.focus() + setSuggestion({ + list: [] + }) } } @@ -594,14 +605,18 @@ export default function Runner(): ReactElement { ref={textAreaRef} placeholder=">>" className={`runner-textarea-field ${isMultiLine ? 'multi-line' : ''}`} - onChange={(e) => setMultiLineArgs(e.target.value)} + onChange={(e) => onMultiLineChange(runtime?.prompt || '', e)} onKeyDown={handleKeyDown} value={multiLineArgs} /> ) : ( '' )} - +
{output}