From 2f5cf54a8f3a273cf533565e6470153721c5456d Mon Sep 17 00:00:00 2001 From: Hyper-Z11 Date: Sun, 15 Feb 2026 15:03:10 +0700 Subject: [PATCH 1/6] feat: add editor core prompt --- packages/core/src/index.ts | 2 ++ packages/core/src/prompts/editor.ts | 41 +++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 packages/core/src/prompts/editor.ts 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..ddb3c0a8 --- /dev/null +++ b/packages/core/src/prompts/editor.ts @@ -0,0 +1,41 @@ +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 { + exec?: string; + postfix?: string; +} + +export default class EditorPrompt extends Prompt { + exec: string | undefined; + path: string | undefined; + + private create() { + spawnSync(this.exec as string, [this.path as string]); + this.value = readFileSync(this.path as string, 'utf8'); + } + + constructor(opts: EditorOptions) { + super(opts, false); + this.value = opts.initialValue ?? ''; + + this.exec = + opts.exec ?? process.env.EDITOR ?? (process.platform === 'win32' ? 'notepad' : 'nano'); + this.path = join(tmpdir(), `ce-${randomUUID()}${opts.postfix ?? ''}`); + writeFileSync(this.path, this.value as string); + + this.on('key', (_char, key) => { + if (key.name === 'return') { + this.create(); + } + }); + + this.on('finalize', () => { + rmSync(this.path as string); + }); + } +} From 09747c1c15e40ce0170183e180652c97afed3f5b Mon Sep 17 00:00:00 2001 From: Hyper-Z11 Date: Sun, 15 Feb 2026 15:34:12 +0700 Subject: [PATCH 2/6] feat: add tmpdir option --- packages/core/src/prompts/editor.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/prompts/editor.ts b/packages/core/src/prompts/editor.ts index ddb3c0a8..71212829 100644 --- a/packages/core/src/prompts/editor.ts +++ b/packages/core/src/prompts/editor.ts @@ -8,6 +8,7 @@ import Prompt, { type PromptOptions } from './prompt.js'; export interface EditorOptions extends PromptOptions { exec?: string; postfix?: string; + tmpdir?: string; } export default class EditorPrompt extends Prompt { @@ -25,7 +26,7 @@ export default class EditorPrompt extends Prompt { this.exec = opts.exec ?? process.env.EDITOR ?? (process.platform === 'win32' ? 'notepad' : 'nano'); - this.path = join(tmpdir(), `ce-${randomUUID()}${opts.postfix ?? ''}`); + this.path = join(opts.tmpdir ?? tmpdir(), `ce-${randomUUID()}${opts.postfix ?? ''}`); writeFileSync(this.path, this.value as string); this.on('key', (_char, key) => { From e398a065a4406f6e4296c39c2908063ffd2c39e1 Mon Sep 17 00:00:00 2001 From: Hyper-Z11 Date: Mon, 16 Feb 2026 09:29:34 +0700 Subject: [PATCH 3/6] test: add editor core test --- packages/core/__mocks__/child_process.cjs | 8 ++++ packages/core/__mocks__/fs.cjs | 2 + packages/core/__mocks__/os.cjs | 8 ++++ packages/core/package.json | 5 ++- packages/core/test/prompts/editor.test.ts | 51 +++++++++++++++++++++++ pnpm-lock.yaml | 3 ++ 6 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 packages/core/__mocks__/child_process.cjs create mode 100644 packages/core/__mocks__/fs.cjs create mode 100644 packages/core/__mocks__/os.cjs create mode 100644 packages/core/test/prompts/editor.test.ts 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/test/prompts/editor.test.ts b/packages/core/test/prompts/editor.test.ts new file mode 100644 index 00000000..89aea5f7 --- /dev/null +++ b/packages/core/test/prompts/editor.test.ts @@ -0,0 +1,51 @@ +import { 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({ './cache-abc': 'foo' }, '/tmp'); + }); + + 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'); + }); +}); 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) From c443fa44b4d0ba871577111b425a49977bc83aa3 Mon Sep 17 00:00:00 2001 From: Hyper-Z11 Date: Mon, 16 Feb 2026 10:30:39 +0700 Subject: [PATCH 4/6] feat: add & change some option --- packages/core/src/prompts/editor.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/core/src/prompts/editor.ts b/packages/core/src/prompts/editor.ts index 71212829..35efc292 100644 --- a/packages/core/src/prompts/editor.ts +++ b/packages/core/src/prompts/editor.ts @@ -6,27 +6,30 @@ import { join } from 'node:path'; import Prompt, { type PromptOptions } from './prompt.js'; export interface EditorOptions extends PromptOptions { - exec?: string; + bin?: string; + args?: (path: string) => Array; postfix?: string; tmpdir?: string; } export default class EditorPrompt extends Prompt { - exec: string | undefined; - path: string | undefined; + bin!: string; + args!: Array; + path!: string; private create() { - spawnSync(this.exec as string, [this.path as string]); - this.value = readFileSync(this.path as string, 'utf8'); + spawnSync(this.bin, this.args); + this.value = readFileSync(this.path, 'utf8'); } constructor(opts: EditorOptions) { super(opts, false); this.value = opts.initialValue ?? ''; - this.exec = - opts.exec ?? process.env.EDITOR ?? (process.platform === 'win32' ? 'notepad' : 'nano'); + 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) => { @@ -36,7 +39,7 @@ export default class EditorPrompt extends Prompt { }); this.on('finalize', () => { - rmSync(this.path as string); + rmSync(this.path); }); } } From 6a4ce5d43cf260366a2f4a6ac930d9037878f722 Mon Sep 17 00:00:00 2001 From: Hyper-Z11 Date: Mon, 16 Feb 2026 11:25:04 +0700 Subject: [PATCH 5/6] test: add more core editor tests --- packages/core/test/prompts/editor.test.ts | 112 +++++++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/packages/core/test/prompts/editor.test.ts b/packages/core/test/prompts/editor.test.ts index 89aea5f7..13839df4 100644 --- a/packages/core/test/prompts/editor.test.ts +++ b/packages/core/test/prompts/editor.test.ts @@ -17,7 +17,7 @@ describe('EditorPrompt', () => { input = new MockReadable(); output = new MockWritable(); vol.reset(); - vol.fromJSON({ './cache-abc': 'foo' }, '/tmp'); + vol.fromJSON({ './tmp/cache-abc': 'foo', './newtmp/cache-123': 'bar' }, '/'); }); afterEach(() => { @@ -48,4 +48,114 @@ describe('EditorPrompt', () => { 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('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('--'); + }); + }); }); From d490f11265eb863bc94ab0d0492537c27beeaf29 Mon Sep 17 00:00:00 2001 From: Hyper-Z11 Date: Wed, 18 Feb 2026 11:47:08 +0700 Subject: [PATCH 6/6] test: add file test for editor --- packages/core/test/prompts/editor.test.ts | 29 ++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/core/test/prompts/editor.test.ts b/packages/core/test/prompts/editor.test.ts index 13839df4..f7f7a6b8 100644 --- a/packages/core/test/prompts/editor.test.ts +++ b/packages/core/test/prompts/editor.test.ts @@ -1,4 +1,4 @@ -import { vol } from 'memfs'; +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'; @@ -93,6 +93,33 @@ describe('EditorPrompt', () => { }); }); + 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;