From 6592e2289ab68ccb405a22a33e053a99564972d1 Mon Sep 17 00:00:00 2001 From: Mikey Date: Wed, 2 Sep 2026 14:18:29 -0700 Subject: [PATCH] Support Windows paths and directory separators in path tab completion --- .../__tests__/use-path-tab-completion.test.ts | 43 ++++++++------- cli/src/hooks/use-path-tab-completion.ts | 53 +++++++++++++++---- 2 files changed, 65 insertions(+), 31 deletions(-) diff --git a/cli/src/hooks/__tests__/use-path-tab-completion.test.ts b/cli/src/hooks/__tests__/use-path-tab-completion.test.ts index 9faa580a1e..6a04a4af2b 100644 --- a/cli/src/hooks/__tests__/use-path-tab-completion.test.ts +++ b/cli/src/hooks/__tests__/use-path-tab-completion.test.ts @@ -13,6 +13,12 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test' * - Path transformation for display */ +import { + isAbsolutePath, + isCompleteDirectory, + toRelativePath, +} from '../use-path-tab-completion' + // Helper to expand ~ to home directory (same as in the hook) const expandPath = (inputPath: string): string => { if (inputPath.startsWith('~')) { @@ -21,27 +27,6 @@ const expandPath = (inputPath: string): string => { return inputPath } -// Helper to check if a path is absolute-style (starts with / or ~) -const isAbsolutePath = (searchQuery: string): boolean => { - return searchQuery.startsWith('/') || searchQuery.startsWith('~') -} - -// Helper to check if completion result indicates a full directory -const isCompleteDirectory = (completed: string): boolean => { - return completed.endsWith('/') -} - -// Helper to convert absolute completion back to relative for display -const toRelativePath = ( - completed: string, - currentPath: string, -): string | null => { - if (completed.startsWith(currentPath + path.sep)) { - return completed.slice(currentPath.length + 1) - } - return null -} - describe('usePathTabCompletion - path type detection', () => { describe('isAbsolutePath', () => { test('returns true for paths starting with /', () => { @@ -56,6 +41,13 @@ describe('usePathTabCompletion - path type detection', () => { expect(isAbsolutePath('~/')).toBe(true) }) + test('returns true for Windows drive paths', () => { + expect(isAbsolutePath('C:\\Users')).toBe(true) + expect(isAbsolutePath('D:\\projects')).toBe(true) + expect(isAbsolutePath('C:/Users/Documents')).toBe(true) + expect(isAbsolutePath('C:\\')).toBe(true) + }) + test('returns false for relative paths', () => { expect(isAbsolutePath('Documents')).toBe(false) expect(isAbsolutePath('src/components')).toBe(false) @@ -82,9 +74,16 @@ describe('usePathTabCompletion - completion result detection', () => { expect(isCompleteDirectory('relative/path/')).toBe(true) }) - test('returns false for paths not ending with /', () => { + test('returns true for Windows paths ending with backslash', () => { + expect(isCompleteDirectory('C:\\Users\\')).toBe(true) + expect(isCompleteDirectory('relative\\path\\')).toBe(true) + expect(isCompleteDirectory('\\')).toBe(true) + }) + + test('returns false for paths not ending with / or \\', () => { expect(isCompleteDirectory('/usr/local')).toBe(false) expect(isCompleteDirectory('~/Documents')).toBe(false) + expect(isCompleteDirectory('C:\\Users')).toBe(false) expect(isCompleteDirectory('partial')).toBe(false) }) diff --git a/cli/src/hooks/use-path-tab-completion.ts b/cli/src/hooks/use-path-tab-completion.ts index de356ed132..a1a1014c84 100644 --- a/cli/src/hooks/use-path-tab-completion.ts +++ b/cli/src/hooks/use-path-tab-completion.ts @@ -23,10 +23,44 @@ export interface UsePathTabCompletionReturn { handleTabCompletion: () => boolean } +/** + * Check if a path query represents an absolute path. + * Supports POSIX paths (/...), tilde paths (~...), and Windows drive/UNC paths. + */ +export function isAbsolutePath(searchQuery: string): boolean { + return ( + searchQuery.startsWith('/') || + searchQuery.startsWith('~') || + path.isAbsolute(searchQuery) || + path.win32.isAbsolute(searchQuery) + ) +} + +/** + * Check if a completed path represents a full directory. + * Matches trailing slash (/) or Windows backslash (\). + */ +export function isCompleteDirectory(completed: string): boolean { + return completed.endsWith('/') || completed.endsWith('\\') +} + +/** + * Convert absolute completion back to relative path for display under current directory. + */ +export function toRelativePath( + completed: string, + currentPath: string, +): string | null { + if (completed.startsWith(currentPath + path.sep)) { + return completed.slice(currentPath.length + 1) + } + return null +} + /** * Hook for path tab completion. - * Handles both absolute (/, ~) and relative path completion. - * Always navigates to completed directories when completion ends with /. + * Handles both absolute (/, ~, drive letters) and relative path completion. + * Always navigates to completed directories when completion ends with a directory separator. */ export function usePathTabCompletion({ searchQuery, @@ -36,12 +70,12 @@ export function usePathTabCompletion({ expandPath, }: UsePathTabCompletionOptions): UsePathTabCompletionReturn { const handleTabCompletion = useCallback((): boolean => { - if (searchQuery.startsWith('/') || searchQuery.startsWith('~')) { + if (isAbsolutePath(searchQuery)) { // Absolute path completion const completed = getPathCompletion(searchQuery) if (completed) { - // If completion is a full directory (ends with /), navigate there and keep the path in input - if (completed.endsWith('/')) { + // If completion is a full directory (ends with / or \), navigate there and keep the path in input + if (isCompleteDirectory(completed)) { const dirPath = expandPath(completed.slice(0, -1)) try { if (existsSync(dirPath) && statSync(dirPath).isDirectory()) { @@ -60,8 +94,8 @@ export function usePathTabCompletion({ const relativePath = path.join(currentPath, searchQuery) const completed = getPathCompletion(relativePath) if (completed) { - // If completion is a full directory (ends with /), navigate there and keep the path in input - if (completed.endsWith('/')) { + // If completion is a full directory (ends with / or \), navigate there and keep the path in input + if (isCompleteDirectory(completed)) { try { const dirPath = completed.slice(0, -1) if (existsSync(dirPath) && statSync(dirPath).isDirectory()) { @@ -74,8 +108,9 @@ export function usePathTabCompletion({ } } // Convert back to relative path for display - if (completed.startsWith(currentPath + path.sep)) { - setSearchQuery(completed.slice(currentPath.length + 1)) + const rel = toRelativePath(completed, currentPath) + if (rel !== null) { + setSearchQuery(rel) } else { setSearchQuery(completed) }