diff --git a/packages/cli/src/ui/commands/cdCommand.test.ts b/packages/cli/src/ui/commands/cdCommand.test.ts index 340c6c0a3da..a26e4a39a88 100644 --- a/packages/cli/src/ui/commands/cdCommand.test.ts +++ b/packages/cli/src/ui/commands/cdCommand.test.ts @@ -205,6 +205,54 @@ describe('cdCommand', () => { }); }); + it('resolves a Windows-style home-relative path from the home directory', async () => { + const missingName = `qwen-cd-missing-${process.pid}-${Date.now()}`; + const expectedPath = path.normalize(path.join(os.homedir(), missingName)); + + const result = (await cdCommand.action?.( + context, + `~\\${missingName}`, + )) as MessageActionReturn; + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: `Couldn't find a directory at ${expectedPath}.`, + }); + expect(relocateWorkingDirectory).not.toHaveBeenCalled(); + }); + + it('moves to a Windows-style home-relative directory', async () => { + const homeSubdir = fs.mkdtempSync( + path.join(os.homedir(), `qwen-cd-ok-${process.pid}-`), + ); + + try { + const result = (await cdCommand.action?.( + context, + `~\\${path.basename(homeSubdir)}`, + )) as MessageActionReturn; + const realCurrentDir = await realpath(currentDir); + const realHomeSubdir = await realpath(homeSubdir); + + expect(relocateWorkingDirectory).toHaveBeenCalledWith( + realHomeSubdir, + realHomeSubdir, + ); + expect(addWorkingDirectoryChangedContext).toHaveBeenCalledWith( + realCurrentDir, + realHomeSubdir, + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: `Moved to ${realHomeSubdir}.`, + }); + } finally { + fs.rmSync(homeSubdir, { recursive: true, force: true }); + } + }); + it('moves to a path with escaped spaces', async () => { const spacedDir = path.join(tmpDir, 'space dir'); fs.mkdirSync(spacedDir); diff --git a/packages/cli/src/ui/commands/cdCommand.ts b/packages/cli/src/ui/commands/cdCommand.ts index c59032858fd..6da15e68bf6 100644 --- a/packages/cli/src/ui/commands/cdCommand.ts +++ b/packages/cli/src/ui/commands/cdCommand.ts @@ -5,10 +5,10 @@ */ import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; import * as path from 'node:path'; import { CommandKind, type SlashCommand } from './types.js'; import { getSingleDirPathCompletions } from './directoryCommand.js'; +import { resolvePath } from '@qwen-code/qwen-code-core'; import { isFolderTrustEnabled, loadTrustedFolders, @@ -36,19 +36,7 @@ function resolveCdPath(input: string, baseDir: string): string { throw new Error('Path contains null bytes.'); } - if (input === '~') { - return path.normalize(os.homedir()); - } - - if (input.startsWith('~/')) { - return path.normalize(path.join(os.homedir(), input.slice(2))); - } - - if (path.isAbsolute(input)) { - return path.normalize(input); - } - - return path.resolve(baseDir, input); + return path.normalize(resolvePath(baseDir, input)); } export const cdCommand: SlashCommand = { diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index 8197e032c52..54158aea115 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -5,12 +5,12 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { directoryCommand, getDirPathCompletions } from './directoryCommand.js'; import { - directoryCommand, expandHomeDir, - getDirPathCompletions, -} from './directoryCommand.js'; -import type { Config, WorkspaceContext } from '@qwen-code/qwen-code-core'; + type Config, + type WorkspaceContext, +} from '@qwen-code/qwen-code-core'; import type { CommandContext, SlashCommandActionReturn } from './types.js'; import { SettingScope } from '../../config/settings.js'; import * as os from 'node:os'; @@ -189,6 +189,17 @@ describe('directoryCommand', () => { }); }); + it('should expand Windows-style home-relative paths before adding directories', async () => { + const homeProject = path.join(os.homedir(), 'new-project'); + + if (!addCommand?.action) throw new Error('No action'); + await addCommand.action(mockContext, '~\\new-project'); + + expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith( + homeProject, + ); + }); + it('should persist added directories to workspace settings', async () => { const existingPath = path.normalize('/home/user/existing-project'); const newPath = path.normalize('/home/user/new-project'); @@ -495,6 +506,33 @@ describe('getDirPathCompletions', () => { }); }); + it('should complete Windows-style home-relative paths', () => { + const homeSubdir = fs.mkdtempSync( + path.join(os.homedir(), `qwen-dir-complete-${process.pid}-`), + ); + const partialName = path.basename(homeSubdir).slice(0, -2); + + try { + const results = getDirPathCompletions(`~\\${partialName}`); + + expect(results.length).toBeGreaterThan(0); + expect( + results.some( + (suggestion) => suggestion.value === homeSubdir + path.sep, + ), + ).toBe(true); + results.forEach((suggestion) => { + expect(suggestion.isDirectory).toBe(true); + expect(path.basename(suggestion.value.slice(0, -1))).toContain( + partialName, + ); + expect(suggestion.value.endsWith(path.sep)).toBe(true); + }); + } finally { + fs.rmSync(homeSubdir, { recursive: true, force: true }); + } + }); + it('should support comma-separated paths with isDirectory flag on last segment', () => { const multiPath = `${tempTestDir}, ${tempTestDir}/`; const results = getDirPathCompletions(multiPath); diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index c3c1bb5daa3..ad4771e13d3 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -11,28 +11,15 @@ import type { } from './types.js'; import { CommandKind } from './types.js'; import * as fs from 'node:fs'; -import * as os from 'node:os'; import * as path from 'node:path'; import { loadServerHierarchicalMemory, ConditionalRulesRegistry, + expandHomeDir, } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; import { SettingScope } from '../../config/settings.js'; -export function expandHomeDir(p: string): string { - if (!p) { - return ''; - } - let expandedPath = p; - if (p.toLowerCase().startsWith('%userprofile%')) { - expandedPath = os.homedir() + p.substring('%userprofile%'.length); - } else if (p === '~' || p.startsWith('~/')) { - expandedPath = os.homedir() + p.substring(1); - } - return path.normalize(expandedPath); -} - function findExistingWorkspaceDirectory( directory: string, existingDirectories: Set, @@ -87,10 +74,10 @@ function getPathCompletions( const trimmed = partial.trim(); if (!trimmed) return []; - const expanded = trimmed.startsWith('~') - ? trimmed.replace(/^~/, os.homedir()) - : trimmed; - const endsWithSep = expanded.endsWith('/') || expanded.endsWith(path.sep); + const inputEndsWithSep = trimmed.endsWith('/') || trimmed.endsWith('\\'); + const expanded = expandHomeDir(trimmed); + const endsWithSep = + inputEndsWithSep || expanded.endsWith('/') || expanded.endsWith(path.sep); const searchDir = endsWithSep ? expanded : path.dirname(expanded); const namePrefix = endsWithSep ? '' : path.basename(expanded); diff --git a/packages/cli/src/utils/resolvePath.test.ts b/packages/cli/src/utils/resolvePath.test.ts index 2c373515c34..dfeab118985 100644 --- a/packages/cli/src/utils/resolvePath.test.ts +++ b/packages/cli/src/utils/resolvePath.test.ts @@ -37,7 +37,7 @@ describe('resolvePath', () => { it('expands USERPROFILE references case-insensitively', () => { expect(resolvePath('%USERPROFILE%\\schemas\\input.json')).toBe( - path.normalize(`${os.homedir()}\\schemas\\input.json`), + path.join(os.homedir(), 'schemas', 'input.json'), ); }); diff --git a/packages/cli/src/utils/resolvePath.ts b/packages/cli/src/utils/resolvePath.ts index a5290a47adf..3a738f56eb9 100644 --- a/packages/cli/src/utils/resolvePath.ts +++ b/packages/cli/src/utils/resolvePath.ts @@ -4,26 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as os from 'node:os'; -import * as path from 'node:path'; +import { expandHomeDir } from '@qwen-code/qwen-code-core'; export function resolvePath(p: string): string { - if (!p) { - return ''; - } - let expandedPath = p; - if (p.toLowerCase().startsWith('%userprofile%')) { - expandedPath = os.homedir() + p.substring('%userprofile%'.length); - } else if (p === '~' || p.startsWith('~/')) { - expandedPath = os.homedir() + p.substring(1); - } else if (p.startsWith('~\\')) { - expandedPath = path.join( - os.homedir(), - ...p - .substring(2) - .split(/[/\\]+/) - .filter(Boolean), - ); - } - return path.normalize(expandedPath); + return expandHomeDir(p); } diff --git a/packages/core/src/utils/paths.test.ts b/packages/core/src/utils/paths.test.ts index 25182341f08..ead16826b90 100644 --- a/packages/core/src/utils/paths.test.ts +++ b/packages/core/src/utils/paths.test.ts @@ -401,6 +401,11 @@ describe('resolvePath', () => { expect(result).toBe(path.resolve(cwd, 'src/main.ts')); }); + it('resolves empty paths against the provided base directory', () => { + const result = resolvePath('/base/dir', ''); + expect(result).toBe(path.resolve('/base/dir', '')); + }); + it('returns absolute paths unchanged', () => { const absolutePath = '/absolute/path/to/file.ts'; const result = resolvePath('/some/base', absolutePath); @@ -419,6 +424,12 @@ describe('resolvePath', () => { expect(result).toBe(path.join(homeDir, 'documents/file.txt')); }); + it('expands Windows-style tilde-prefixed paths to home directory', () => { + const homeDir = os.homedir(); + const result = resolvePath('/some/base', '~\\documents\\file.txt'); + expect(result).toBe(path.join(homeDir, 'documents', 'file.txt')); + }); + it('uses baseDir when provided for relative paths', () => { const baseDir = '/custom/base'; const result = resolvePath(baseDir, './relative/path'); @@ -619,6 +630,9 @@ describe('resolveAndValidatePath', () => { expect(resolveAndValidatePath(configWithHome, '~/project')).toBe( homeSubdir, ); + expect(resolveAndValidatePath(configWithHome, '~\\project')).toBe( + homeSubdir, + ); expect(resolveAndValidatePath(configWithHome, '~')).toBe(fakeHome); } finally { homedirSpy.mockRestore(); @@ -931,10 +945,37 @@ describe('expandHomeDir', () => { expect(expandHomeDir('~')).toBe(path.normalize(homeDir)); }); + it('should preserve trailing separators for home directory paths', () => { + expect(expandHomeDir('~/')).toBe(path.normalize(homeDir + path.sep)); + expect(expandHomeDir('~\\')).toBe(path.normalize(homeDir + path.sep)); + }); + it('should expand ~/path to home directory path', () => { expect(expandHomeDir('~/documents')).toBe(path.join(homeDir, 'documents')); }); + it('should expand Windows-style ~\\path to home directory path', () => { + expect(expandHomeDir('~\\documents')).toBe(path.join(homeDir, 'documents')); + }); + + it('should preserve trailing separators in Windows-style tilde paths', () => { + expect(expandHomeDir('~\\documents\\')).toBe( + path.normalize(path.join(homeDir, 'documents') + path.sep), + ); + }); + + it('should handle mixed separators in Windows-style tilde paths', () => { + expect(expandHomeDir('~\\foo/bar\\baz')).toBe( + path.join(homeDir, 'foo', 'bar', 'baz'), + ); + }); + + it('should preserve legacy POSIX tilde path semantics', () => { + expect(expandHomeDir('~/foo\\bar')).toBe( + path.normalize(path.join(homeDir, 'foo\\bar')), + ); + }); + it('should not expand ~path (no slash)', () => { expect(expandHomeDir('~documents')).toBe('~documents'); }); @@ -946,7 +987,28 @@ describe('expandHomeDir', () => { it('should expand %userprofile%\\path to home directory path', () => { const result = expandHomeDir('%userprofile%\\documents'); - expect(result).toBe(path.normalize(homeDir + '\\documents')); + expect(result).toBe(path.join(homeDir, 'documents')); + }); + + it('should expand %USERPROFILE%/path with forward-slash separator', () => { + expect(expandHomeDir('%USERPROFILE%/documents')).toBe( + path.join(homeDir, 'documents'), + ); + }); + + it('should preserve trailing separators for %USERPROFILE% paths', () => { + expect(expandHomeDir('%USERPROFILE%/')).toBe( + path.normalize(homeDir + path.sep), + ); + expect(expandHomeDir('%USERPROFILE%\\documents\\')).toBe( + path.normalize(path.join(homeDir, 'documents') + path.sep), + ); + }); + + it('should preserve legacy %USERPROFILE% prefix semantics without a separator', () => { + expect(expandHomeDir('%USERPROFILE%foo')).toBe( + path.normalize(`${homeDir}foo`), + ); }); it('should return regular absolute path unchanged (but normalized)', () => { diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index 6453eb331fe..b1a22edccef 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -78,6 +78,37 @@ export function tildeifyPath(filePath: string): string { return filePath; } +/** + * Expands tilde (~) to the full home directory path. + * Supports both POSIX-style ~/ and Windows-style ~\ home-relative paths. + * @param p - The path to expand. + * @returns The expanded path. + */ +function expandTilde(p: string): string { + if (!p) { + return ''; + } + if (p === '~') { + return os.homedir(); + } + if (p === '~/' || p === '~\\') { + return os.homedir() + path.sep; + } + if (p.startsWith('~/')) { + return path.join(os.homedir(), p.substring(2)); + } + if (p.startsWith('~\\')) { + const rest = p.substring(2); + const hasTrailingSep = rest.endsWith('/') || rest.endsWith('\\'); + const expandedPath = path.join( + os.homedir(), + ...rest.split(/[/\\]+/).filter(Boolean), + ); + return hasTrailingSep ? expandedPath + path.sep : expandedPath; + } + return p; +} + /** * Expands tilde (~) and Windows-style %userprofile% to the full home directory path. * @param p - The path to expand. @@ -87,13 +118,35 @@ export function expandHomeDir(p: string): string { if (!p) { return ''; } - let expandedPath = p; - if (p.toLowerCase().startsWith('%userprofile%')) { - expandedPath = os.homedir() + p.substring('%userprofile%'.length); - } else if (p === '~' || p.startsWith('~/')) { - expandedPath = os.homedir() + p.substring(1); + const userProfilePrefix = '%userprofile%'; + const lowerPath = p.toLowerCase(); + if (lowerPath === userProfilePrefix) { + return path.normalize(os.homedir()); } - return path.normalize(expandedPath); + if ( + lowerPath === `${userProfilePrefix}/` || + lowerPath === `${userProfilePrefix}\\` + ) { + return path.normalize(os.homedir() + path.sep); + } + if ( + lowerPath.startsWith(`${userProfilePrefix}/`) || + lowerPath.startsWith(`${userProfilePrefix}\\`) + ) { + const rest = p.substring(userProfilePrefix.length + 1); + const hasTrailingSep = rest.endsWith('/') || rest.endsWith('\\'); + const expandedPath = path.join( + os.homedir(), + ...rest.split(/[/\\]+/).filter(Boolean), + ); + return path.normalize( + hasTrailingSep ? expandedPath + path.sep : expandedPath, + ); + } + if (lowerPath.startsWith(userProfilePrefix)) { + return path.normalize(os.homedir() + p.substring(userProfilePrefix.length)); + } + return path.normalize(expandTilde(p)); } /** @@ -325,16 +378,12 @@ export function resolvePath( baseDir: string | undefined = process.cwd(), relativePath: string, ): string { - const homeDir = os.homedir(); + const expandedPath = expandTilde(relativePath); - if (relativePath === '~') { - return homeDir; - } else if (relativePath.startsWith('~/')) { - return path.join(homeDir, relativePath.slice(2)); - } else if (path.isAbsolute(relativePath)) { - return relativePath; + if (path.isAbsolute(expandedPath)) { + return expandedPath; } else { - return path.resolve(baseDir, relativePath); + return path.resolve(baseDir, expandedPath); } }