Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/core/__mocks__/child_process.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const child_process = require('node:child_process');

module.exports = {
...child_process,
spawnSync() {
return undefined;
},
};
2 changes: 2 additions & 0 deletions packages/core/__mocks__/fs.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const { fs } = require('memfs');
module.exports = fs;
8 changes: 8 additions & 0 deletions packages/core/__mocks__/os.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const os = require('node:os');

module.exports = {
...os,
tmpdir() {
return '/tmp';
},
};
5 changes: 3 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
45 changes: 45 additions & 0 deletions packages/core/src/prompts/editor.ts
Original file line number Diff line number Diff line change
@@ -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<string, EditorPrompt> {
bin?: string;
args?: (path: string) => Array<string>;
postfix?: string;
tmpdir?: string;
}

export default class EditorPrompt extends Prompt<string> {
bin!: string;
args!: Array<string>;
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);
});
}
}
188 changes: 188 additions & 0 deletions packages/core/test/prompts/editor.test.ts
Original file line number Diff line number Diff line change
@@ -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('--');
});
});
});
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading