diff --git a/packages/core/__mocks__/child_process.cjs b/packages/core/__mocks__/child_process.cjs new file mode 100644 index 00000000..e998937e --- /dev/null +++ b/packages/core/__mocks__/child_process.cjs @@ -0,0 +1,8 @@ +const child_process = require('node:child_process'); + +module.exports = { + ...child_process, + spawnSync() { + return undefined; + }, +}; diff --git a/packages/core/__mocks__/fs.cjs b/packages/core/__mocks__/fs.cjs new file mode 100644 index 00000000..3c1dd612 --- /dev/null +++ b/packages/core/__mocks__/fs.cjs @@ -0,0 +1,2 @@ +const { fs } = require('memfs'); +module.exports = fs; diff --git a/packages/core/__mocks__/os.cjs b/packages/core/__mocks__/os.cjs new file mode 100644 index 00000000..b14f908d --- /dev/null +++ b/packages/core/__mocks__/os.cjs @@ -0,0 +1,8 @@ +const os = require('node:os'); + +module.exports = { + ...os, + tmpdir() { + return '/tmp'; + }, +}; diff --git a/packages/core/package.json b/packages/core/package.json index b0271b22..17b62a3f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -57,7 +57,8 @@ "sisteransi": "^1.0.5" }, "devDependencies": { - "vitest": "^3.2.4", - "fast-wrap-ansi": "^0.1.3" + "fast-wrap-ansi": "^0.1.3", + "memfs": "^4.17.2", + "vitest": "^3.2.4" } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7299d075..3aa54a6e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,8 @@ export type { AutocompleteOptions } from './prompts/autocomplete.js'; export { default as AutocompletePrompt } from './prompts/autocomplete.js'; export type { ConfirmOptions } from './prompts/confirm.js'; export { default as ConfirmPrompt } from './prompts/confirm.js'; +export type { EditorOptions } from './prompts/editor.js'; +export { default as EditorPrompt } from './prompts/editor.js'; export type { GroupMultiSelectOptions } from './prompts/group-multiselect.js'; export { default as GroupMultiSelectPrompt } from './prompts/group-multiselect.js'; export type { MultiSelectOptions } from './prompts/multi-select.js'; diff --git a/packages/core/src/prompts/editor.ts b/packages/core/src/prompts/editor.ts new file mode 100644 index 00000000..35efc292 --- /dev/null +++ b/packages/core/src/prompts/editor.ts @@ -0,0 +1,45 @@ +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import Prompt, { type PromptOptions } from './prompt.js'; + +export interface EditorOptions extends PromptOptions { + bin?: string; + args?: (path: string) => Array; + postfix?: string; + tmpdir?: string; +} + +export default class EditorPrompt extends Prompt { + bin!: string; + args!: Array; + path!: string; + + private create() { + spawnSync(this.bin, this.args); + this.value = readFileSync(this.path, 'utf8'); + } + + constructor(opts: EditorOptions) { + super(opts, false); + this.value = opts.initialValue ?? ''; + + this.bin = + opts.bin ?? process.env.EDITOR ?? (process.platform === 'win32' ? 'notepad' : 'nano'); + this.path = join(opts.tmpdir ?? tmpdir(), `ce-${randomUUID()}${opts.postfix ?? ''}`); + this.args = opts?.args?.(this.path) ?? [this.path]; + writeFileSync(this.path, this.value as string); + + this.on('key', (_char, key) => { + if (key.name === 'return') { + this.create(); + } + }); + + this.on('finalize', () => { + rmSync(this.path); + }); + } +} diff --git a/packages/core/test/prompts/editor.test.ts b/packages/core/test/prompts/editor.test.ts new file mode 100644 index 00000000..f7f7a6b8 --- /dev/null +++ b/packages/core/test/prompts/editor.test.ts @@ -0,0 +1,188 @@ +import { fs, vol } from 'memfs'; +import { cursor } from 'sisteransi'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { default as EditorPrompt } from '../../src/prompts/editor.js'; +import { MockReadable } from '../mock-readable.js'; +import { MockWritable } from '../mock-writable.js'; + +vi.mock('node:fs'); +vi.mock('node:os'); +vi.mock('node:child_process'); + +describe('EditorPrompt', () => { + let input: MockReadable; + let output: MockWritable; + + beforeEach(() => { + input = new MockReadable(); + output = new MockWritable(); + vol.reset(); + vol.fromJSON({ './tmp/cache-abc': 'foo', './newtmp/cache-123': 'bar' }, '/'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('renders render() result', () => { + const instance = new EditorPrompt({ + input, + output, + render: () => 'foo', + }); + instance.prompt(); + expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); + }); + + test('sets value and submits on confirm', () => { + const instance = new EditorPrompt({ + input, + output, + render: () => 'foo', + initialValue: 'hello', + }); + + instance.prompt(); + input.emit('keypress', '\r', { name: 'return' }); + + expect(instance.value).to.equal('hello'); + expect(instance.state).to.equal('submit'); + }); + + describe('path', () => { + const UUID_RE = [8, 4, 4, 4, 12].map((len) => `[a-z0-9]{${len}}`).join('-'); + const createRegexp = (dir: string, postfix = '') => + new RegExp(['', dir, `ce-${UUID_RE}${postfix}`].join('[\\\\/]')); + + test('default', () => { + const instance = new EditorPrompt({ + input, + output, + render: () => 'foo', + }); + + instance.prompt(); + + expect(instance.path).to.match(createRegexp('tmp')); + }); + + test('custom temp dir', () => { + const instance = new EditorPrompt({ + input, + output, + render: () => 'foo', + tmpdir: '/newtmp', + }); + + instance.prompt(); + + expect(instance.path).to.match(createRegexp('newtmp')); + }); + + test('custom temp file postfix/extension', () => { + const instance = new EditorPrompt({ + input, + output, + render: () => 'foo', + postfix: '.txt', + }); + + instance.prompt(); + + expect(instance.path).to.match(createRegexp('tmp', '.txt')); + }); + }); + + describe("file", () => { + test("created on start", () => { + const instance = new EditorPrompt({ + input, + output, + render: () => 'foo', + }); + + instance.prompt(); + + expect(fs.existsSync(instance.path)).to.equal(true); + }) + + test("removed on end", () => { + const instance = new EditorPrompt({ + input, + output, + render: () => 'foo', + }); + + instance.prompt(); + input.emit('keypress', '\r', { name: 'return' }); + + expect(fs.existsSync(instance.path)).to.equal(false); + }) + }) + + describe('executable', () => { + const originalEnv = structuredClone(process.env); + const originalPlatform = process.platform; + + afterEach(() => { + process.env = originalEnv; + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + test.each(['linux', 'win32'])('%s default', (platform) => { + Object.defineProperty(process, 'platform', { value: platform }); + + const instance = new EditorPrompt({ + input, + output, + render: () => 'foo', + }); + + instance.prompt(); + + expect(instance.bin).to.equal(platform === 'win32' ? 'notepad' : 'nano'); + expect(instance.args.length).to.equal(1); + }); + + test('custom binary', () => { + const instance = new EditorPrompt({ + input, + output, + render: () => 'foo', + bin: 'vim', + }); + + instance.prompt(); + + expect(instance.bin).to.equal('vim'); + }); + + test('custom binary via env.EDITOR', () => { + process.env.EDITOR = 'nvim'; + + const instance = new EditorPrompt({ + input, + output, + render: () => 'foo', + }); + + instance.prompt(); + + expect(instance.bin).to.equal('nvim'); + }); + + test('custom args', () => { + const instance = new EditorPrompt({ + input, + output, + render: () => 'foo', + args: (p) => ['--', p], + }); + + instance.prompt(); + + expect(instance.args.length).to.equal(2); + expect(instance.args[0]).to.equal('--'); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c19acf1..bca31bb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,6 +70,9 @@ importers: fast-wrap-ansi: specifier: ^0.1.3 version: 0.1.3 + memfs: + specifier: ^4.17.2 + version: 4.17.2 vitest: specifier: ^3.2.4 version: 3.2.4(@types/node@24.1.0)(jiti@2.5.0)