Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/ext improvements #129

Merged
merged 17 commits into from
Jul 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 25 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
26 changes: 23 additions & 3 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <extension-name>`

### mterm-ext-red

Expand All @@ -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

64 changes: 63 additions & 1 deletion src/main/framework/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean> = new Map<string, boolean>()
public lib: object = {}
public commandFileLocation: string = ''
public state: Map<string, object> = new Map<string, object>()
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
}
3 changes: 2 additions & 1 deletion src/main/framework/execute-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
117 changes: 99 additions & 18 deletions src/main/framework/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = (...args) => T
export type ExtensionHookResolution<T> = string | ExtensionHookCallback<T>

export interface ExtensionHookResolutionContainer<T> {
packageName: string
resolution: ExtensionHookResolution<T>
hook: ExtensionHook
}

export interface ExtensionCommandContainer {
packageName: string
command: string
}
export class Extensions {
public extensionHooks: Map<ExtensionHook, Array<ExtensionHookResolution>> = new Map<
ExtensionHook,
Array<ExtensionHookResolution>
>()
public extensionHooks: Map<ExtensionHook, Array<ExtensionHookResolutionContainer<object>>> =
new Map<ExtensionHook, Array<ExtensionHookResolutionContainer<object>>>()
public extensionList: string[] = []
public extensionCommands: ExtensionCommandContainer[] = []
constructor(private workspace: Workspace) {}

async run(hook: ExtensionHook): Promise<string> {
async run<T>(hook: ExtensionHook, ifNullThen: T, ...args): Promise<T | undefined> {
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<void> {
await this.executeFor(hook, '*', ...args)
}

async executeFor(hook: ExtensionHook, extName: string, ...args): Promise<void> {
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<void> {
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()
Expand All @@ -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<void> {
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) {
Expand All @@ -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<object>[] = []
if (ext.has(ExtensionHook[hookKey])) {
resolutions = ext.get(ExtensionHook[hookKey]) as ExtensionHookResolution[]
resolutions = ext.get(
ExtensionHook[hookKey]
) as ExtensionHookResolutionContainer<object>[]
}

resolutions.push(mtermExt[hookKey])
const extHook = mtermExt[hookKey]

resolutions.push({
packageName,
resolution: extHook,
hook: hookKey
})

ext.set(hookKey, resolutions)
}
Expand Down
6 changes: 5 additions & 1 deletion src/main/framework/runtime-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,11 @@ export function attach({ app, workspace }: BootstrapContext): void {

ipcMain.handle('runner.theme', async (_, profile): Promise<string> => {
const theme = workspace.theme.get(profile)
const extensionTheme = await workspace.extensions.run(ExtensionHook.RUNNER_THEME_CSS)
const extensionTheme = await workspace.extensions.run<string>(
ExtensionHook.RUNNER_THEME_CSS,
'',
workspace
)
return `${extensionTheme}${theme}`
})

Expand Down
Loading
Loading