Implement edit and exit

This commit is contained in:
2026-02-22 16:43:47 -06:00
parent 8d16fb41ab
commit e5acc2e8b1
6 changed files with 85 additions and 11 deletions

View File

@@ -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()

3
src/util/fs.ts Normal file
View File

@@ -0,0 +1,3 @@
import crypto from "node:crypto";
export const tempFile = () => `/tmp/str-${crypto.randomBytes(4).readUInt32LE(0)}.txt`

View File

@@ -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<StrVM> {
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<void>(res => {
proc.on('close', () => res())
})
await vm.control$.next({ cmd: 'restore-prompt' })
return (await fs.readFile(tmp, 'utf8')).toString()
}
})
}

View File

@@ -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<string> 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<string> implements LifecycleAware {
}
public closePrompt(): void {
this.log.verbose({
closePrompt: { hasExistingPrompt: !!this.rl },
})
this.rl?.close()
this.rl = undefined
}

View File

@@ -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<string> {
const tmp = tempFile()

View File

@@ -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<string, StrRVal> = {}
@@ -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<Control> = 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<void> {
@@ -345,17 +375,30 @@ export class StrVM {
}
}
export class Executor extends BehaviorSubject<StrVM> {
export class Executor extends BehaviorSubject<StrVM> 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<CommandData>) {
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
}
}