From 926afab7880a2bfbf428c870f3771e1abe534440 Mon Sep 17 00:00:00 2001 From: "MD. MOHIBUR RAHMAN" <35300157+mrpmohiburrahman@users.noreply.github.com> Date: Tue, 24 Feb 2026 03:41:33 +0600 Subject: [PATCH] fix(cli): improve shell completion for dotfiles, spaces, and quotes --- .../src/ui/hooks/useShellCompletion.test.ts | 59 +++++++++++++++++++ .../cli/src/ui/hooks/useShellCompletion.ts | 44 ++++++++------ 2 files changed, 85 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/ui/hooks/useShellCompletion.test.ts b/packages/cli/src/ui/hooks/useShellCompletion.test.ts index ec4d7d0ff4..8cb5487683 100644 --- a/packages/cli/src/ui/hooks/useShellCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useShellCompletion.test.ts @@ -245,6 +245,65 @@ describe('useShellCompletion utilities', () => { expect(labels).toContain('.hidden'); }); + it('should show dotfiles in the current directory when query is exactly "."', async () => { + const structure: FileSystemStructure = { + '.hidden': '', + '.bashrc': '', + visible: '', + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('.', tmpDir); + const labels = results.map((s) => s.label); + expect(labels).toContain('.hidden'); + expect(labels).toContain('.bashrc'); + expect(labels).not.toContain('visible'); + }); + + it('should handle dotfile completions within a subdirectory', async () => { + const structure: FileSystemStructure = { + subdir: { + '.secret': '', + 'public.txt': '', + }, + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('subdir/.', tmpDir); + const labels = results.map((s) => s.label); + expect(labels).toContain('.secret'); + expect(labels).not.toContain('public.txt'); + }); + + it('should strip leading quotes to resolve inner directory contents', async () => { + const structure: FileSystemStructure = { + src: { + 'index.ts': '', + }, + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('"src/', tmpDir); + expect(results).toHaveLength(1); + expect(results[0].label).toBe('index.ts'); + + const resultsSingleQuote = await resolvePathCompletions("'src/", tmpDir); + expect(resultsSingleQuote).toHaveLength(1); + expect(resultsSingleQuote[0].label).toBe('index.ts'); + }); + + it('should properly escape resolutions with spaces inside stripped quote queries', async () => { + const structure: FileSystemStructure = { + 'Folder With Spaces': {}, + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('"Fo', tmpDir); + expect(results).toHaveLength(1); + expect(results[0].label).toBe('Folder With Spaces/'); + expect(results[0].value).toBe(escapeShellPath('Folder With Spaces/')); + }); + it('should return empty array for non-existent directory', async () => { const results = await resolvePathCompletions( '/nonexistent/path/foo', diff --git a/packages/cli/src/ui/hooks/useShellCompletion.ts b/packages/cli/src/ui/hooks/useShellCompletion.ts index d984de5e01..ff9796e496 100644 --- a/packages/cli/src/ui/hooks/useShellCompletion.ts +++ b/packages/cli/src/ui/hooks/useShellCompletion.ts @@ -252,22 +252,30 @@ export async function resolvePathCompletions( ): Promise { if (partial == null) return []; - const [expandedPartial, didExpandTilde] = expandTilde(partial); + // Input Sanitization + let strippedPartial = partial; + if (strippedPartial.startsWith('"') || strippedPartial.startsWith("'")) { + strippedPartial = strippedPartial.slice(1); + } + if (strippedPartial.endsWith('"') || strippedPartial.endsWith("'")) { + strippedPartial = strippedPartial.slice(0, -1); + } - // Determine the directory to list and the prefix to match - const resolvedPath = path.isAbsolute(expandedPartial) - ? expandedPartial - : path.resolve(cwd, expandedPartial); + // Normalize separators \ to / + const normalizedPartial = strippedPartial.replace(/\\/g, '/'); - // If the partial ends with a separator, list that directory directly. - // Otherwise, list the parent and filter by the basename prefix. + const [expandedPartial, didExpandTilde] = expandTilde(normalizedPartial); + + // Directory Detection const endsWithSep = - partial.endsWith('/') || partial.endsWith(path.sep) || partial === ''; - const dirToRead = endsWithSep ? resolvedPath : path.dirname(resolvedPath); - const prefix = endsWithSep ? '' : path.basename(resolvedPath); + normalizedPartial.endsWith('/') || normalizedPartial === ''; + const dirToRead = endsWithSep + ? path.resolve(cwd, expandedPartial) + : path.resolve(cwd, path.dirname(expandedPartial)); + + const prefix = endsWithSep ? '' : path.basename(expandedPartial); const prefixLower = prefix.toLowerCase(); - // Determine whether to show dotfiles const showDotfiles = prefix.startsWith('.'); let entries: Array; @@ -294,30 +302,30 @@ export async function resolvePathCompletions( if (!name.toLowerCase().startsWith(prefixLower)) continue; const isDir = entry.isDirectory(); - const displayName = isDir ? name + path.sep : name; + const displayName = isDir ? name + '/' : name; // Build the completion value relative to what the user typed let completionValue: string; if (endsWithSep) { - completionValue = partial + displayName; + completionValue = normalizedPartial + displayName; } else { - // Replace the basename portion - const parentPart = partial.slice( + const parentPart = normalizedPartial.slice( 0, - partial.length - path.basename(partial).length, + normalizedPartial.length - path.basename(normalizedPartial).length, ); completionValue = parentPart + displayName; } // Restore tilde if we expanded it if (didExpandTilde) { - const homeDir = os.homedir(); + const homeDir = os.homedir().replace(/\\/g, '/'); if (completionValue.startsWith(homeDir)) { completionValue = '~' + completionValue.slice(homeDir.length); } } - // Escape special characters in the completion value + // Output formatting: Escape special characters in the completion value + // Since normalizedPartial stripped quotes, we escape the value directly. const escapedValue = escapeShellPath(completionValue); suggestions.push({