Skip to content
Merged
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
48 changes: 48 additions & 0 deletions packages/cli/src/ui/commands/cdCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,54 @@ describe('cdCommand', () => {
});
});

it('resolves a Windows-style home-relative path from the home directory', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This test only covers the error path (directory not found). No success test verifies that cd ~\existingDir actually calls relocateWorkingDirectory. Consider adding a success-path test mirroring the existing ../next test pattern:

it('moves to a Windows-style home-relative directory', async () => {
  const homeSub = path.join(os.homedir(), `qwen-cd-ok-${process.pid}`);
  fs.mkdirSync(homeSub);
  try {
    const result = await cdCommand.action?.(context, `~\\qwen-cd-ok-${process.pid}`);
    expect(relocateWorkingDirectory).toHaveBeenCalledWith(homeSub, context);
  } finally {
    fs.rmSync(homeSub, { recursive: true });
  }
});

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion. This is covered now by the success-path Windows-style home-relative /cd test, and the targeted cdCommand.test.ts -t "Windows-style home-relative" run passed: 2 passed, 18 skipped.

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);
Expand Down
16 changes: 2 additions & 14 deletions packages/cli/src/ui/commands/cdCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {
Expand Down
46 changes: 42 additions & 4 deletions packages/cli/src/ui/commands/directoryCommand.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand Down
23 changes: 5 additions & 18 deletions packages/cli/src/ui/commands/directoryCommand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] getDirPathCompletions (line ~78) still uses inline trimmed.replace(/^~/, os.homedir()) for tilde expansion. This regex doesn't recognize ~\ as home-relative, so tab-completing ~\project produces no suggestions on any platform — even though /cd ~\project and /directory add ~\project now work correctly after this PR.

Since this file was already modified to import expandHomeDir from core, the completion function could use the same shared utility:

Suggested change
expandHomeDir,
import {
expandHomeDir,
expandTilde,
} from '@qwen-code/qwen-code-core';

Then in getDirPathCompletions, replace the inline replace(/^~/, ...) with expandTilde(trimmed).

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the catch. The completion path now uses the public expandHomeDir(trimmed) helper rather than the inline trimmed.replace(/^~/, os.homedir()), so ~\\... completion follows the same path expansion as /cd and /directory add. Targeted validation after the latest update: directoryCommand.test.tsx passed: 27 passed.

} 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<string>,
Expand Down Expand Up @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/utils/resolvePath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
);
});

Expand Down
22 changes: 2 additions & 20 deletions packages/cli/src/utils/resolvePath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
64 changes: 63 additions & 1 deletion packages/core/src/utils/paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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');
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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');
});
Expand All @@ -946,7 +987,28 @@ describe('expandHomeDir', () => {

it('should expand %userprofile%\\path to home directory path', () => {
const result = expandHomeDir('%userprofile%\\documents');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The expandHomeDir code has an explicit branch for both %userprofile%/ (forward-slash) and %userprofile%\\ (backslash) — see paths.ts lines 129-130 — but only the backslash variant is tested here. Consider adding a forward-slash test to cover the new branch:

it('should expand %USERPROFILE%/path with forward-slash separator', () => {
  expect(expandHomeDir('%USERPROFILE%/documents')).toBe(
    path.join(homeDir, 'documents'),
  );
});

— qwen3.7-max via Qwen Code /review

@VectorPeak VectorPeak Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this coverage in 591c8e5f5:

expect(expandHomeDir('%USERPROFILE%/documents')).toBe(
  path.join(homeDir, 'documents'),
);

I also added coverage for %USERPROFILE%/ and %USERPROFILE%\\documents\\ preserving trailing separators, since that was the related behavior gap in the implementation branch.

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)', () => {
Expand Down
Loading
Loading