From e5acc2e8b17122fc9eaffa757ae93eb053445c91 Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Sun, 22 Feb 2026 16:43:47 -0600 Subject: [PATCH] Implement edit and exit --- src/index.ts | 3 ++- src/util/fs.ts | 3 +++ src/vm/commands/edit.ts | 16 ++++++++++-- src/vm/input.ts | 15 +++++++++++ src/vm/output.ts | 2 +- src/vm/vm.ts | 57 ++++++++++++++++++++++++++++++++++++----- 6 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 src/util/fs.ts diff --git a/src/index.ts b/src/index.ts index a6d2749..4a2efc9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,7 +23,8 @@ const output: OutputManager = { clipboard: new WlClipboard, } -const exec = new Executor(output, parser) +const exec = new Executor(output, parser, input) +exec.adoptLifecycle(lifecycle) exec.subscribe(state => state.outputSubject()) input.setupPrompt() diff --git a/src/util/fs.ts b/src/util/fs.ts new file mode 100644 index 0000000..1a5e306 --- /dev/null +++ b/src/util/fs.ts @@ -0,0 +1,3 @@ +import crypto from "node:crypto"; + +export const tempFile = () => `/tmp/str-${crypto.randomBytes(4).readUInt32LE(0)}.txt` \ No newline at end of file diff --git a/src/vm/commands/edit.ts b/src/vm/commands/edit.ts index d2f6671..446fb81 100644 --- a/src/vm/commands/edit.ts +++ b/src/vm/commands/edit.ts @@ -1,7 +1,10 @@ -import {Command, ParseContext} from "./command.js"; +import {Command, ParseContext, wrapString} from "./command.js"; import {LexInput} from "../lexer.js"; import {StrVM} from "../vm.js"; import {Awaitable} from "../../util/types.js"; +import fs from "node:fs/promises"; +import childProcess from "node:child_process"; +import {tempFile} from "../../util/fs.js"; export class Edit extends Command<{}> { isParseCandidate(token: LexInput): boolean { @@ -19,8 +22,17 @@ export class Edit extends Command<{}> { execute(vm: StrVM, data: {}): Awaitable { return vm.replaceContextMatchingTerm({ string: async sub => { + await vm.control$.next({ cmd: 'close-prompt' }) - return sub + const tmp = tempFile() + await fs.writeFile(tmp, sub, 'utf8') + const proc = childProcess.spawn(process.env.EDITOR || 'vim', [tmp], { stdio: 'inherit' }) + await new Promise(res => { + proc.on('close', () => res()) + }) + + await vm.control$.next({ cmd: 'restore-prompt' }) + return (await fs.readFile(tmp, 'utf8')).toString() } }) } diff --git a/src/vm/input.ts b/src/vm/input.ts index b082cc2..02827a8 100644 --- a/src/vm/input.ts +++ b/src/vm/input.ts @@ -1,11 +1,22 @@ import * as readline from 'node:readline' import {BehaviorSubject} from "../util/subject.js"; import {Lifecycle, LifecycleAware} from "../util/lifecycle.js"; +import {StreamLogger} from "../util/log.js"; +import {log} from "../log.js"; export class Input extends BehaviorSubject implements LifecycleAware { private rl?: readline.Interface + private log: StreamLogger = log.getStreamLogger('input') + + public hasPrompt(): boolean { + return !!this.rl + } public setupPrompt(): void { + this.log.verbose({ + setupPrompt: { hasExistingPrompt: !!this.rl }, + }) + if ( this.rl ) { this.closePrompt() } @@ -25,6 +36,10 @@ export class Input extends BehaviorSubject implements LifecycleAware { } public closePrompt(): void { + this.log.verbose({ + closePrompt: { hasExistingPrompt: !!this.rl }, + }) + this.rl?.close() this.rl = undefined } diff --git a/src/vm/output.ts b/src/vm/output.ts index 978d9ac..fa2ec42 100644 --- a/src/vm/output.ts +++ b/src/vm/output.ts @@ -3,6 +3,7 @@ import {Awaitable} from "../util/types.js"; import childProcess from "node:child_process"; import fs from "node:fs"; import crypto from "node:crypto"; +import {tempFile} from "../util/fs.js"; export const getSubjectDisplay = (sub: StrRVal): string => { if ( sub.term === 'string' ) { @@ -53,7 +54,6 @@ export class FakeClipboard { } } -const tempFile = () => `/tmp/str-${crypto.randomBytes(4).readUInt32LE(0)}.txt` export class WlClipboard { async read(): Promise { const tmp = tempFile() diff --git a/src/vm/vm.ts b/src/vm/vm.ts index 4dc2449..594bf0f 100644 --- a/src/vm/vm.ts +++ b/src/vm/vm.ts @@ -15,6 +15,8 @@ import {Parser} from "./parser.js"; import {log} from "../log.js"; import {Executable} from "./parse.js"; import {getSubjectDisplay, OutputManager} from "./output.js"; +import {Input} from "./input.js"; +import {Lifecycle, LifecycleAware} from "../util/lifecycle.js"; export class Scope { private entries: Record = {} @@ -250,29 +252,41 @@ export class ExecutionContext { } export type Control = - { cmd: 'undo' | 'redo' | 'exit' | 'no-show' | 'preserve-history' } + { cmd: 'undo' | 'redo' | 'exit' | 'no-show' | 'preserve-history' | 'close-prompt' | 'restore-prompt' } -export class StrVM { - public static make(output: OutputManager): StrVM { +export class StrVM implements LifecycleAware { + public static make(output: OutputManager, input?: Input): StrVM { return new StrVM( new ExecutionContext(wrapString(''), new Scope()), output, + input, ) } private noShowNext: boolean = false private preserveHistoryNext: boolean = false + private wasPromptWhenClosed: boolean = false public readonly control$: BehaviorSubject = new BehaviorSubject() + private log: StreamLogger + private lifecycle?: Lifecycle constructor( private context: ExecutionContext, private output: OutputManager, + private input?: Input, // if provided, commands will properly handle prompt control ) { + this.log = log.getStreamLogger('vm') this.control$.subscribe((control: Control) => this.handleControl(control)) } + adoptLifecycle(lifecycle: Lifecycle): void { + this.lifecycle = lifecycle + } + private async handleControl(control: Control) { + this.log.debug({ handlingControl: control }) + if ( control.cmd === 'no-show' ) { this.noShowNext = true } else if ( control.cmd === 'preserve-history' ) { @@ -281,6 +295,18 @@ export class StrVM { this.context.restoreHistory() } else if ( control.cmd === 'redo' ) { this.context.restoreForwardHistory() + } else if ( control.cmd === 'close-prompt' ) { + this.wasPromptWhenClosed = !!this.input?.hasPrompt() + if ( this.wasPromptWhenClosed ) { + this.input?.closePrompt() + } + } else if ( control.cmd === 'restore-prompt' ) { + if ( this.wasPromptWhenClosed && !this.input?.hasPrompt() ) { + this.input?.setupPrompt() + } + this.wasPromptWhenClosed = false + } else if ( control.cmd === 'exit' ) { + this.lifecycle?.close() } } @@ -332,7 +358,11 @@ export class StrVM { } makeChild(): StrVM { - return new StrVM(this.context.makeChild(), this.output) + const child = new StrVM(this.context.makeChild(), this.output, this.input) + if ( this.lifecycle ) { + child.adoptLifecycle(this.lifecycle) + } + return child } async outputSubject(): Promise { @@ -345,17 +375,30 @@ export class StrVM { } } -export class Executor extends BehaviorSubject { +export class Executor extends BehaviorSubject implements LifecycleAware{ private logger: StreamLogger + private lifecycle?: Lifecycle - constructor(private output: OutputManager, parser?: Parser) { + constructor(private output: OutputManager, parser?: Parser, private input?: Input) { super() this.logger = log.getStreamLogger('executor') parser?.subscribe(exec => this.handleExecutable(exec)) } + adoptLifecycle(lifecycle: Lifecycle): void { + this.lifecycle = lifecycle + } + async handleExecutable(exec: Executable) { - const vm = this.currentValue || StrVM.make(this.output) + const vm = this.currentValue || this.makeVM() await this.next(await exec.command.execute(vm, exec.data)) } + + private makeVM(): StrVM { + const vm = StrVM.make(this.output, this.input) + if ( this.lifecycle ) { + vm.adoptLifecycle(this.lifecycle) + } + return vm + } }