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
5 changes: 5 additions & 0 deletions .changeset/path-windows-separator.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 6 additions & 4 deletions packages/prompts/src/path.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down
72 changes: 72 additions & 0 deletions packages/prompts/test/path-win32.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('node:path')>();
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');
});
});
Loading