diff --git a/README.md b/README.md index 53fd70c..66e3897 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=mterm-io_mterm&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=mterm-io_mterm) [![release](https://github.com/mterm-io/mterm/actions/workflows/release.yml/badge.svg)](https://github.com/mterm-io/mterm/actions/workflows/release.yml) +![image](https://github.com/mterm-io/mterm/assets/7341502/6eb47f43-1ab5-41c5-9c0e-5eb61ce575bf) ![image](https://github.com/mterm-io/mterm/assets/7341502/27bcad62-6891-4b49-80b5-e5a17e0562ab) **mterm** is a cross-platform command-line terminal that proxies the underlying command-line interpreters, such as [powershell](https://learn.microsoft.com/en-us/powershell/), [sh](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/sh.html) or [wsl](https://ubuntu.com/desktop/wsl). commands are executed in the background and results streamed to the foreground. @@ -128,21 +129,23 @@ 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 | -| `: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` | -| `:settings` | | Open the mterm settings gui to manage `~/mterm/settings.json` | -| `: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` | +| `: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 @@ -212,6 +215,15 @@ export function who() { ![image](https://github.com/mterm-io/mterm/assets/7341502/76b26a62-33ea-4883-b07c-677f99ab3355) +### Editor + +mterm provides an editor with `:edit ` or `edit ` commands - + +![image](https://github.com/mterm-io/mterm/assets/7341502/25db8038-7a86-419c-a5d7-777b97025ec7) + +hit `control + s` within the file editor to save this + + ### Other Notes When you change the tab name to include `$idx` - this will be replaced with the current tab index diff --git a/src/main/bootstrap/create-tray.ts b/src/main/bootstrap/create-tray.ts index bdc7b9c..ca0abb0 100644 --- a/src/main/bootstrap/create-tray.ts +++ b/src/main/bootstrap/create-tray.ts @@ -71,6 +71,4 @@ export async function createTray(context: BootstrapContext): Promise { tray.setToolTip('MTERM') tray.setContextMenu(menu) - - console.log(tray) } diff --git a/src/main/bootstrap/index.ts b/src/main/bootstrap/index.ts index a64556d..7e53d1f 100644 --- a/src/main/bootstrap/index.ts +++ b/src/main/bootstrap/index.ts @@ -37,10 +37,7 @@ export async function boostrap(context: BootstrapContext): Promise { } }) - autoUpdater - .checkForUpdatesAndNotify() - .then((r) => console.log(r)) - .catch(console.error) + autoUpdater.checkForUpdatesAndNotify().catch(console.error) attach(context) diff --git a/src/main/framework/history.ts b/src/main/framework/history.ts index 526ef7d..2d8d7fb 100644 --- a/src/main/framework/history.ts +++ b/src/main/framework/history.ts @@ -7,6 +7,7 @@ export interface HistoricalExecution { error: boolean aborted: boolean profile: string + edit?: string when: { start: number finish: number @@ -40,6 +41,7 @@ export class History { result: saveResult ? command.result.stream.map((o) => o.raw) : undefined, error: command.error, profile, + edit: command?.result?.edit?.path, when: { start, finish: Date.now() diff --git a/src/main/framework/runtime-events.ts b/src/main/framework/runtime-events.ts index daf992a..ee5d3a6 100644 --- a/src/main/framework/runtime-events.ts +++ b/src/main/framework/runtime-events.ts @@ -25,6 +25,7 @@ import { } from '../../constants' import Convert from 'ansi-to-html' import { HistoricalExecution } from './history' +import { readFile, writeFile } from 'fs-extra' const convert = new Convert() const DOMPurify = createDOMPurify(new JSDOM('').window) @@ -35,7 +36,15 @@ export function attach({ app, workspace }: BootstrapContext): void { const focus = runtime.history.find((cmd) => cmd.id === runtime.commandFocus) const result = focus - ? focus.result + ? { + ...focus.result, + edit: focus.result.edit + ? { + ...focus.result.edit, + callback: undefined + } + : undefined + } : { code: 0, stream: [] @@ -44,7 +53,17 @@ export function attach({ app, workspace }: BootstrapContext): void { const history: CommandViewModel[] = runtime.history.map((historyItem) => { return { ...historyItem, - process: undefined + process: undefined, + result: { + ...historyItem.result, + edit: historyItem.result.edit + ? { + content: historyItem.result.edit.content, + path: historyItem.result.edit.path, + modified: historyItem.result.edit.modified + } + : undefined + } } }) @@ -73,6 +92,57 @@ export function attach({ app, workspace }: BootstrapContext): void { }) } + ipcMain.handle( + 'runtime.set-edit', + async (_, runtimeId: string, commandId: string, result: string): Promise => { + const runtime = workspace.runtimes.find((r) => r.id === runtimeId) + if (!runtime) { + return false + } + + if (!commandId) { + commandId = runtime.commandFocus + } + + const command = runtime.history.find((c) => c.id === commandId) + if (!command || !command.result.edit) { + return false + } + + command.result.edit.content = result + command.result.edit.modified = true + + return true + } + ) + + ipcMain.handle( + 'runtime.save-edit', + async (_, runtimeId: string, commandId: string): Promise => { + const runtime = workspace.runtimes.find((r) => r.id === runtimeId) + if (!runtime) { + return false + } + + if (!commandId) { + commandId = runtime.commandFocus + } + + const command = runtime.history.find((c) => c.id === commandId) + if (!command || !command.result.edit) { + return false + } + + command.result.edit.modified = false + + await writeFile(command.result.edit.path, command.result.edit.content) + + await command.result.edit.callback(command.result.edit.content) + + return true + } + ) + ipcMain.handle( 'runtime.set-result', async (_, runtimeId: string, commandId: string, result: string): Promise => { @@ -101,7 +171,7 @@ export function attach({ app, workspace }: BootstrapContext): void { } } - return false + return true } ) @@ -332,7 +402,8 @@ export function attach({ app, workspace }: BootstrapContext): void { aborted: false, result: { code: 0, - stream: [] + stream: [], + edit: undefined }, runtime: runtime.id } @@ -345,7 +416,7 @@ export function attach({ app, workspace }: BootstrapContext): void { return command }) - ipcMain.handle('runtime.execute', async (_, { id, runtime }: Command): Promise => { + ipcMain.handle('runtime.execute', async (_, { id, runtime }: Command): Promise => { const runtimeTarget = workspace.runtimes.find((r) => r.id === runtime) if (!runtimeTarget) { throw `Runtime '${runtime}' does not exist` @@ -392,10 +463,12 @@ export function attach({ app, workspace }: BootstrapContext): void { try { const out = (text: string, error: boolean = false): void => { - if (command.aborted || command.complete) { - return + const isFinished = command.aborted || command.complete + if (isFinished) { + if (!command.result.edit) { + return + } } - const raw = text.toString() text = DOMPurify.sanitize(raw) @@ -430,6 +503,18 @@ export function attach({ app, workspace }: BootstrapContext): void { workspace, runtime: runtimeTarget, command, + async edit(path: string, callback: (text: string) => void) { + const file = await readFile(path) + + this.command.result.edit = { + path, + modified: false, + content: file.toString(), + callback + } + + _.sender.send('runtime.commandEvent') + }, out, finish }) @@ -452,7 +537,7 @@ export function attach({ app, workspace }: BootstrapContext): void { finish(result.code) } - return command + return true }) ipcMain.handle('runtimes', async (): Promise => { diff --git a/src/main/framework/runtime-executor.ts b/src/main/framework/runtime-executor.ts index 6c0ab8c..f4dccb3 100644 --- a/src/main/framework/runtime-executor.ts +++ b/src/main/framework/runtime-executor.ts @@ -12,12 +12,13 @@ import Version from './system-commands/version' import Vault from './system-commands/vault' import Workspace from './system-commands/workspace' import Settings from './system-commands/settings' +import Edit from './system-commands/edit' const systemCommands: Array<{ command: string alias?: string[] task: (context: ExecuteContext, ...args: string[]) => Promise | void -}> = [Reload, Exit, History, Cd, Tab, Test, Clear, Version, Vault, Workspace, Settings] +}> = [Reload, Exit, History, Cd, Tab, Test, Clear, Version, Vault, Workspace, Settings, Edit] export async function execute(context: ExecuteContext): Promise { const { platform, workspace, runtime, command, out, finish } = context const [cmd, ...args] = command.prompt.split(' ') diff --git a/src/main/framework/runtime.ts b/src/main/framework/runtime.ts index 303dfac..dece5af 100644 --- a/src/main/framework/runtime.ts +++ b/src/main/framework/runtime.ts @@ -1,6 +1,7 @@ import { resolveFolderPathForMTERM, Workspace } from './workspace' import short from 'short-uuid' import { ChildProcessWithoutNullStreams } from 'node:child_process' +import { resolve } from 'path' export interface ResultStream { error: boolean @@ -11,6 +12,13 @@ export interface ResultStream { export interface Result { code: number stream: ResultStream[] + edit?: EditFile +} + +export interface ResultViewModel { + code: number + stream: ResultStream[] + edit?: EditFileViewModel } export interface ResultStreamEvent { @@ -18,6 +26,16 @@ export interface ResultStreamEvent { command: string entry: ResultStream } + +export interface EditFileViewModel { + path: string + modified: boolean + content: string +} + +export interface EditFile extends EditFileViewModel { + callback: (text: string) => Promise | void +} export interface Command { prompt: string result: Result @@ -31,7 +49,7 @@ export interface Command { export interface CommandViewModel { prompt: string - result: Result + result: ResultViewModel runtime: string aborted: boolean complete: boolean @@ -54,7 +72,7 @@ export interface RuntimeModel { id: string prompt: string profile: string - result: Result + result: ResultViewModel target: boolean folder: string history: CommandViewModel[] @@ -81,6 +99,15 @@ export class Runtime { icon: '', title: 'mterm [$idx]' } + + resolve(path: string): string { + let location = resolve(this.folder, path) + if (path.startsWith('~')) { + location = resolveFolderPathForMTERM(path) + } + + return location + } } export interface ExecuteContext { @@ -88,6 +115,7 @@ export interface ExecuteContext { workspace: Workspace runtime: Runtime command: Command + edit: (path: string, callback: (content: string) => void) => Promise out: (text: string, error?: boolean) => void finish: (code: number) => void } diff --git a/src/main/framework/system-commands/cd.ts b/src/main/framework/system-commands/cd.ts index 29edfe1..fb3be15 100644 --- a/src/main/framework/system-commands/cd.ts +++ b/src/main/framework/system-commands/cd.ts @@ -1,27 +1,28 @@ import { ExecuteContext } from '../runtime' -import { resolve } from 'path' -import { resolveFolderPathForMTERM } from '../workspace' -import { pathExists } from 'fs-extra' +import { pathExists, stat } from 'fs-extra' + export default { command: 'cd', async task(context: ExecuteContext): Promise { const [, ...args] = context.command.prompt.split(' ') - const path: string = args[0] || '.' - - let location = resolve(context.runtime.folder, path) - if (path.startsWith('~')) { - location = resolveFolderPathForMTERM(path) - } + const path: string = context.runtime.resolve(args[0] || '.') - const isLocationActive = await pathExists(location) + const isLocationActive = await pathExists(path) if (!isLocationActive) { - context.out(`Folder not found \n\n${location}`, true) + context.out(`Folder not found \n\n${path}`, true) + context.finish(1) + return + } + + const stats = await stat(path) + if (!stats.isDirectory()) { + context.out(`This is not a folder mterm can navigate \n\n${path}`, true) context.finish(1) return } - context.runtime.folder = location + context.runtime.folder = path } } diff --git a/src/main/framework/system-commands/edit.ts b/src/main/framework/system-commands/edit.ts new file mode 100644 index 0000000..b67bd1c --- /dev/null +++ b/src/main/framework/system-commands/edit.ts @@ -0,0 +1,31 @@ +import { ExecuteContext } from '../runtime' +import { pathExists, stat } from 'fs-extra' + +async function exit(context: ExecuteContext): Promise { + const [, ...args] = context.command.prompt.split(' ') + const path: string = context.runtime.resolve(args[0] || '.') + + const isLocationActive = await pathExists(path) + if (!isLocationActive) { + context.out(`File \n\n${location}`, true) + context.finish(1) + return + } + + const stats = await stat(path) + if (!stats.isFile()) { + context.out(`This path is not an editable file\n\n${path}`, true) + context.finish(1) + return + } + + await context.edit(path, async () => { + context.out('Saved changes!\n') + }) +} + +export default { + command: ':edit', + alias: ['edit'], + task: exit +} diff --git a/src/main/framework/system-commands/settings.ts b/src/main/framework/system-commands/settings.ts index 266e280..c36a2e1 100644 --- a/src/main/framework/system-commands/settings.ts +++ b/src/main/framework/system-commands/settings.ts @@ -22,6 +22,15 @@ export default { context.out(`settings reloaded`) } else if (task === 'open') { await shell.openPath(context.workspace.settings.location) + } else if (task === 'edit') { + await context.edit(context.workspace.settings.location, async () => { + context.out('Saved settings! Reloading..\n') + + await context.workspace.settings.load() + await context.workspace.applySettings(RunnerWindow) + + context.out('Settings reloaded\n') + }) } else if (task === 'get') { if (!key) { context.out('no key provided to :settings get', true) diff --git a/src/renderer/src/assets/runner.css b/src/renderer/src/assets/runner.css index effcb2a..1e12fc9 100644 --- a/src/renderer/src/assets/runner.css +++ b/src/renderer/src/assets/runner.css @@ -21,22 +21,29 @@ body { width: 100%; height: 100%; display: grid; - grid-template-columns: 100px; + grid-template-columns: auto; grid-template-rows: auto; justify-items: center; align-items: center; - justify-content: space-evenly; +} + +.runner-input { + width: 100% } .runner-input-field { height: 50px; text-align: center; + width: 100%; font-size: 20px; background: transparent; border: 0; font-family: "Roboto Mono", monospace } +.runner-input-field { + color: #cac7c7 +} .runner-input-field:focus { border: 0; outline: none; @@ -106,6 +113,42 @@ body { overflow: hidden } +.runner-editor { + display: grid; + grid-auto-flow: row; + grid-template-rows: auto auto 10px; +} + + +.runner-editor-header { + display: grid; + grid-auto-flow: column; + grid-template-columns: auto auto; + padding-top: 15px; + padding-bottom: 15px; +} + +.runner-editor-header-result { + max-height: 15px; + overflow-y: auto +} +.runner-editor-header-result::-webkit-scrollbar { + width: 15px; +} +.runner-editor-header-result::-webkit-scrollbar-corner { + background: rgba(0,0,0,0); +} +.runner-editor-header-result::-webkit-scrollbar-thumb { + background-color: #294794; + border-radius: 6px; + border: 4px solid rgba(0,0,0,0); + background-clip: content-box; + min-width: 32px; + min-height: 32px; +} +.runner-editor-header-result pre { + margin: 0 +} .runner-context-folder { color: #59dcec; text-align: right; diff --git a/src/renderer/src/runner/runner.tsx b/src/renderer/src/runner/runner.tsx index f8f93d6..f194a87 100644 --- a/src/renderer/src/runner/runner.tsx +++ b/src/renderer/src/runner/runner.tsx @@ -48,6 +48,7 @@ export default function Runner(): ReactElement { 'default' ) + applyHistoryIndex(-1) await reloadRuntimesFromBackend() // renderer -> "backend" @@ -187,6 +188,19 @@ export default function Runner(): ReactElement { }) } + const onEditFileChange = (runtimeId: string, commandId: string, value: string): void => { + window.electron.ipcRenderer.invoke('runtime.set-edit', runtimeId, commandId, value).then(() => { + return reloadRuntimesFromBackend() + }) + } + const onEditFileKeyDown = (runtimeId: string, commandId: string, e): void => { + if (e.code === 'KeyS' && e.ctrlKey) { + window.electron.ipcRenderer.invoke('runtime.save-edit', runtimeId, commandId).then(() => { + return reloadRuntimesFromBackend() + }) + } + } + // useEffect(() => { // inputRef.current?.focus() // @@ -230,6 +244,7 @@ export default function Runner(): ReactElement { const result = historicalExecution ? historicalExecution.result : runtime.result const resultText = result.stream.map((record) => record.text).join('') + const resultTextRaw = result.stream.map((record) => record.raw).join('') let output = (
@@ -237,7 +252,6 @@ export default function Runner(): ReactElement {
) if (editMode) { - const resultTextRaw = result.stream.map((record) => record.raw).join('') output = ( +
+
+ {' '} + Editing {result.edit.path} + {result.edit.modified ? '*' : ''} +
+
+
{resultTextRaw}
+
+
+
+ + onEditFileChange(runtime.id, historicalExecution ? historicalExecution.id : '', value) + } + onKeyDown={(e) => + onEditFileKeyDown(runtime.id, historicalExecution ? historicalExecution.id : '', e) + } + basicSetup={{ foldGutter: true }} + /> +
+ + ) + } + return ( <>
onHistoryItemClicked(index)} > {command.prompt} + {command.result?.edit?.modified ? ' *' : ''}
))} diff --git a/src/renderer/src/runner/runtime.ts b/src/renderer/src/runner/runtime.ts index 32e60cb..fed1cf6 100644 --- a/src/renderer/src/runner/runtime.ts +++ b/src/renderer/src/runner/runtime.ts @@ -7,6 +7,7 @@ export interface ResultStream { export interface Result { code: number stream: ResultStream[] + edit?: EditFile } export interface ResultStreamEvent { @@ -23,6 +24,12 @@ export interface Command { error: boolean id: string } + +export interface EditFile { + path: string + content: string + modified: boolean +} export interface Runtime { id: string result: Result