diff --git a/.changeset/path-windows-separator.md b/.changeset/path-windows-separator.md new file mode 100644 index 00000000..13edfed7 --- /dev/null +++ b/.changeset/path-windows-separator.md @@ -0,0 +1,5 @@ +--- +"@clack/prompts": patch +--- + +Fix the `path` prompt in `directory` mode on Windows: typing a trailing `\` after a directory now lists its children instead of showing no matches. diff --git a/packages/prompts/src/path.ts b/packages/prompts/src/path.ts index f9de592c..20d19ab3 100644 --- a/packages/prompts/src/path.ts +++ b/packages/prompts/src/path.ts @@ -1,5 +1,5 @@ import { existsSync, lstatSync, readdirSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { dirname, join, sep } from 'node:path'; import type { Validate } from '@clack/core'; import { runValidation } from '@clack/core'; import { autocomplete } from './autocomplete.js'; @@ -87,21 +87,23 @@ export const path = (opts: PathOptions) => { try { let searchPath: string; + // `sep` is `\` on Windows, where both separators are valid + const hasTrailingSeparator = userInput.endsWith('/') || userInput.endsWith(sep); if (!existsSync(userInput)) { searchPath = dirname(userInput); } else { const stat = lstatSync(userInput); - if (stat.isDirectory() && (!opts.directory || userInput.endsWith('/'))) { + if (stat.isDirectory() && (!opts.directory || hasTrailingSeparator)) { searchPath = userInput; } else { searchPath = dirname(userInput); } } - // Strip trailing slash so startsWith matches the directory itself among its siblings + // Strip the trailing separator so startsWith matches the directory itself among its siblings const prefix = - userInput.length > 1 && userInput.endsWith('/') ? userInput.slice(0, -1) : userInput; + userInput.length > 1 && hasTrailingSeparator ? userInput.slice(0, -1) : userInput; const items = readdirSync(searchPath) .map((item) => { diff --git a/packages/prompts/test/path-win32.test.ts b/packages/prompts/test/path-win32.test.ts new file mode 100644 index 00000000..38245672 --- /dev/null +++ b/packages/prompts/test/path-win32.test.ts @@ -0,0 +1,72 @@ +import { vol } from 'memfs'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import * as prompts from '../src/index.js'; +import { MockReadable, MockWritable } from './test-utils.js'; + +// Run the path prompt with Windows path semantics on any OS. memfs only knows +// POSIX paths here, so `C:\tmp\foo` is looked up as `/tmp/foo`. +vi.mock('node:path', async (importOriginal) => { + const { win32 } = await importOriginal(); + return { ...win32, default: win32 }; +}); + +vi.mock('node:fs', async () => { + const { fs } = await import('memfs'); + const toPosix = (path: string) => path.replace(/^[a-zA-Z]:/, '').replaceAll('\\', '/'); + return { + existsSync: (path: string) => fs.existsSync(toPosix(path)), + lstatSync: (path: string) => fs.lstatSync(toPosix(path)), + readdirSync: (path: string) => fs.readdirSync(toPosix(path)), + }; +}); + +describe('path (win32)', () => { + let output: MockWritable; + let input: MockReadable; + + beforeEach(() => { + output = new MockWritable(); + input = new MockReadable(); + vol.reset(); + vol.fromJSON( + { + './foo/bar.txt': '1', + './hello/world.jpg': '2', + './root.zip': '3', + }, + '/tmp' + ); + }); + + test('directory mode traverses into child when trailing backslash entered', async () => { + const result = prompts.path({ + message: 'foo', + initialValue: 'C:\\tmp', + directory: true, + input, + output, + }); + + input.emit('keypress', '\\', { name: '\\' }); + input.emit('keypress', '', { name: 'return' }); + // cancel instead of hanging if return did not submit + input.emit('keypress', '', { name: 'escape' }); + + expect(await result).toBe('C:\\tmp\\foo'); + }); + + test('directory mode lists children of an initial value ending in a backslash', async () => { + const result = prompts.path({ + message: 'foo', + initialValue: 'C:\\tmp\\', + directory: true, + input, + output, + }); + + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'escape' }); + + expect(await result).toBe('C:\\tmp\\foo'); + }); +});