Implement edit and exit
This commit is contained in:
@@ -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
3
src/util/fs.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export const tempFile = () => `/tmp/str-${crypto.randomBytes(4).readUInt32LE(0)}.txt`
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
57
src/vm/vm.ts
57
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<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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user