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
3 changes: 2 additions & 1 deletion src/editorPreview/pageHistoryTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@ export class PageHistory extends Disposable {
connection: Connection
): INavResponse | undefined {
address = PathUtil.ConvertToPosixPath(address);
address = PathUtil.EscapePathParts(address);
// Note: address is already URL-encoded (from window.location.pathname),
// so we should NOT escape it again to avoid double-encoding
const action = new Array<NavEditCommands>();
const lastItem = this._history[this._backstep];
if (
Expand Down
9 changes: 6 additions & 3 deletions src/editorPreview/previewManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export class PreviewManager extends Disposable {
file?: vscode.Uri
): Promise<void> {
const path = file
? PathUtil.ConvertToPosixPath(await this._fileUriToPath(file, connection))
? await this._fileUriToPath(file, connection)
: '/';

const url = `http://${connection.host}:${connection.httpPort}${path}`;
Expand Down Expand Up @@ -165,7 +165,7 @@ export class PreviewManager extends Disposable {
* Transforms Uris into a path that can be used by the server.
* @param {vscode.Uri} file the path to potentially transform.
* @param {Connection} connection the connection to connect using
* @returns {string} the transformed path if the original `file` was realtive.
* @returns {string} the transformed path (properly URI-escaped) if the original `file` was relative.
*/
private async _fileUriToPath(file: vscode.Uri, connection: Connection): Promise<string> {
let path = '/';
Expand All @@ -177,7 +177,10 @@ export class PreviewManager extends Disposable {
path = `/${path}`;
}
} else if (connection) {
path = connection.getFileRelativeToWorkspace(file.fsPath) ?? '';
path = PathUtil.EscapePathParts(connection.getFileRelativeToWorkspace(file.fsPath) ?? '');
if (path && !path.startsWith('/')) {
path = `/${path}`;
}
}
return path;
}
Expand Down
2 changes: 1 addition & 1 deletion src/infoManagers/endpointManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export class EndpointManager extends Disposable {
endpoint_prefix = PathUtil.EscapePathParts(endpoint_prefix);

// don't use path.join so that we don't remove leading slashes
const ret = `${endpoint_prefix}/${child}`;
const ret = `${endpoint_prefix}/${encodeURIComponent(child)}`;
return ret;
}

Expand Down
15 changes: 10 additions & 5 deletions src/test/runTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@ async function main(): Promise<void> {
// Passed to `--extensionDevelopmentPath`
const extensionDevelopmentPath = path.resolve(__dirname, '../../');

// The path to the extension test script
// Passed to --extensionTestsPath
const extensionTestsPath = path.resolve(__dirname, './suite/index');
// The path to the extension test script
// Passed to --extensionTestsPath
const extensionTestsPath = path.resolve(__dirname, './suite/index');

// Download VS Code, unzip it and run the integration test
await runTests({ extensionDevelopmentPath, extensionTestsPath, launchArgs: ['--disable-extensions'] });
// Download VS Code, unzip it and run the integration test
await runTests({
extensionDevelopmentPath,
extensionTestsPath,
launchArgs: ['--disable-extensions'],
platform: 'win32-x64-archive'
});
} catch (err) {
console.error('Failed to run tests');
process.exit(1);
Expand Down
27 changes: 27 additions & 0 deletions src/test/suite/connectionInfo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,33 @@ describe('ConnectionInfo', () => {
});


it('should handle file paths with spaces and special characters', async () => {
sandbox.stub(SettingUtil, 'GetConfig').returns(makeSetting({}));
sandbox.stub(vscode.env, 'asExternalUri').callsFake((uri) => Promise.resolve(uri));

const connection = await connectionManager.createAndAddNewConnection(testWorkspaces[0]);

// Test file with spaces and hash in both folder and filename
const testUri = vscode.Uri.joinPath(
testWorkspaces[0].uri,
'special #01 folder',
'test #01 file.html'
);

const relativePath = connection.getFileRelativeToWorkspace(testUri.fsPath);

// Verify the path is a plain POSIX path (not URL-encoded)
assert.strictEqual(
relativePath,
'/special #01 folder/test #01 file.html'
);

// Verify that special characters are preserved (not URL-encoded)
assert.ok(relativePath?.includes('#'), 'Hash characters should be preserved');
assert.ok(relativePath?.includes(' '), 'Spaces should be preserved');
});


it('should be able to create a Connection with an undefined workspace', async () => {
const target = sinon.spy();
sandbox.stub(SettingUtil, 'GetConfig').returns(makeSetting({}));
Expand Down
32 changes: 29 additions & 3 deletions src/test/suite/endpointManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ describe('EndpointManager', () => {
const existingPaths = ['c:/Users/TestUser/workspace1/index.html', 'c:/Users/TestUser/workspace1/pages/page1.html',
'/home/TestUser/workspace1/index.html', '/home/TestUser/workspace1/pages/page1.html',
'//other/TestUser/workspace1/index.html', '//other/TestUser/workspace1/pages/page1.html',
'c:/Users/TestUser/personal.html'
'c:/Users/TestUser/personal.html',
'c:/Users/TestUser/workspace1/test #01 file.html',
'c:/Users/TestUser/workspace1/my file & test #01.html'
];
sandbox.stub(PathUtil, 'FileExistsStat').callsFake((path: string) => {
if (existingPaths.indexOf(PathUtil.ConvertToPosixPath(path)) > -1) {
Expand All @@ -35,9 +37,9 @@ describe('EndpointManager', () => {
});

// storing paths
it('returns the identical path for windows when encoding the path', async () => {
it('returns the encoded path for windows when encoding the path', async () => {
const endpoint = await endpointManager.encodeLooseFileEndpoint('c:/Users/TestUser/workspace1/index.html');
assert.strictEqual(endpoint, 'c:/Users/TestUser/workspace1/index.html');
assert.strictEqual(endpoint, 'c%3A/Users/TestUser/workspace1/index.html');
});

it('returns the identical path for unix without the leading forward slash when encoding the path', async () => {
Expand Down Expand Up @@ -90,4 +92,28 @@ describe('EndpointManager', () => {
assert.strictEqual(file2, undefined);
assert.strictEqual(file3, undefined);
});

it('encodes filenames with hash characters correctly', async () => {
const testPath = 'c:/Users/TestUser/workspace1/test #01 file.html';
const endpoint = await endpointManager.encodeLooseFileEndpoint(testPath);

// Verify hash is encoded as %23 and spaces as %20 in filename
assert.ok(endpoint.includes('%2301'), 'Hash should be encoded as %23');
assert.ok(endpoint.includes('%20'), 'Spaces should be encoded as %20');
assert.ok(!endpoint.includes('#'), 'Literal hash should not appear in endpoint');
assert.ok(!endpoint.includes(' '), 'Literal spaces should not appear in endpoint');
});

it('round-trips encoding and decoding for files with special characters', async () => {
const testPath = 'c:/Users/TestUser/workspace1/my file & test #01.html';

// Encode the path
const encoded = await endpointManager.encodeLooseFileEndpoint(testPath);

// Decode it back
const decoded = await endpointManager.decodeLooseFileEndpoint('/' + encoded);

// Should get back the original path
assert.strictEqual(decoded, testPath);
});
});
28 changes: 28 additions & 0 deletions src/test/suite/pathUtil.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,32 @@ describe('getEndpointParent', () => {
assert.strictEqual(endpoint2, 'workspace1');
assert.strictEqual(endpoint3, '.');
});
});

describe('EscapePathParts', () => {
it('should encode spaces and special characters while preserving slashes', () => {
const input = '/special #01 folder/test #01 file.html';
const expected = 'special%20%2301%20folder/test%20%2301%20file.html';

const actual = PathUtil.EscapePathParts(input);

assert.strictEqual(actual, expected);
});

it('should handle multiple special characters', () => {
const input = '/folder with spaces/file&name.html';
const actual = PathUtil.EscapePathParts(input);

assert.ok(actual.includes('%20'), 'Spaces should be encoded');
assert.ok(actual.includes('%26'), 'Ampersands should be encoded');
assert.ok(!actual.includes(' '), 'No literal spaces should remain');
});

it('should preserve forward slashes as path delimiters', () => {
const input = 'path/to/file.html';
const actual = PathUtil.EscapePathParts(input);

assert.strictEqual(actual, 'path/to/file.html');
assert.strictEqual(input.match(/\//g)?.length, actual.match(/\//g)?.length);
});
});
46 changes: 40 additions & 6 deletions src/test/suite/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ describe('PreviewManager', () => {
sandbox.restore();
});

afterEach(() => {
sinon.restore();
});

it("previews in embedded preview", async () => {
const goToFile = sinon.spy(WebviewComm.prototype, 'goToFile');

Expand All @@ -70,10 +74,14 @@ describe('PreviewManager', () => {
assert.ok(goToFile.callCount === 4);
assert(previewManager.previewActive);

assert.ok(goToFile.getCall(0).calledWith('', false));
assert.ok(goToFile.getCall(1).calledWith('/index.html', true));
assert.ok(goToFile.getCall(2).calledWith('/index.html', true));
assert.ok(goToFile.getCall(3).calledWith('/page.html', true));
assert.strictEqual(goToFile.getCall(0).args[0], '');
assert.strictEqual(goToFile.getCall(0).args[1], false);
assert.strictEqual(goToFile.getCall(1).args[0], '/index.html');
assert.strictEqual(goToFile.getCall(1).args[1], true);
assert.strictEqual(goToFile.getCall(2).args[0], '/index.html');
assert.strictEqual(goToFile.getCall(2).args[1], true);
assert.strictEqual(goToFile.getCall(3).args[0], '/page.html');
assert.strictEqual(goToFile.getCall(3).args[1], true);
});

it("previews in external preview (non-debug)", async () => {
Expand All @@ -83,7 +91,8 @@ describe('PreviewManager', () => {
vscode.Uri.joinPath(testWorkspaces[0].uri, "/index.html"));

assert.ok(openInBrowser.calledOnce);
assert.ok(openInBrowser.getCall(0).calledWith(`http://${connection.host}:${connection.httpPort}/index.html`, CustomExternalBrowser.edge));
assert.strictEqual(openInBrowser.getCall(0).args[0], `http://${connection.host}:${connection.httpPort}/index.html`);
assert.strictEqual(openInBrowser.getCall(0).args[1], CustomExternalBrowser.edge);
});

it("previews in external preview (debug)", async () => {
Expand All @@ -93,6 +102,31 @@ describe('PreviewManager', () => {
vscode.Uri.joinPath(testWorkspaces[0].uri, "/index.html"));

assert.ok(executeCommand.calledOnce);
assert.ok(executeCommand.getCall(0).calledWith('extension.js-debug.debugLink', `http://${connection.host}:${connection.httpPort}/index.html`));
assert.strictEqual(executeCommand.getCall(0).args[0], 'extension.js-debug.debugLink');
assert.strictEqual(executeCommand.getCall(0).args[1], `http://${connection.host}:${connection.httpPort}/index.html`);
});

it("previews files with special characters in path", async () => {
const goToFile = sinon.spy(WebviewComm.prototype, 'goToFile');

// Test file with spaces and hash characters
const fileUri = vscode.Uri.joinPath(
testWorkspaces[0].uri,
'special #01 folder',
'test #01 file.html'
);

await previewManager.launchFileInEmbeddedPreview(undefined, connection, fileUri);

assert.ok(goToFile.callCount >= 1);

// Verify the path passed to goToFile is properly encoded
const pathArgument = goToFile.getCall(goToFile.callCount - 1).args[0];
assert.ok(pathArgument.includes('%20'), 'Spaces should be URL-encoded');
assert.ok(pathArgument.includes('%23'), 'Hash symbols should be URL-encoded');
assert.strictEqual(
pathArgument,
'/special%20%2301%20folder/test%20%2301%20file.html'
);
});
});
8 changes: 4 additions & 4 deletions src/utils/pathUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ import { SettingUtil } from './settingsUtil';
* A collection of functions to perform path operations
*/
export class PathUtil {
// used to idetify the path separators, `/` or `\\`.
// used to identify the path separators, `/` or `\\`.
private static _pathSepRegex = /(?:\\|\/)+/;

/**
* @description escapes a path, but keeps the `/` delimeter intact.
* @description escapes a path, but keeps the `/` delimiter intact.
* @param {string} file the file path to escape.
* @returns {string} the escaped path.
*/
Expand All @@ -26,7 +26,7 @@ export class PathUtil {

const newParts = parts
.filter((part) => part.length > 0)
.map((filterdPart) => encodeURI(filterdPart));
.map((filteredPart) => encodeURIComponent(filteredPart));
return newParts.join('/');
}

Expand All @@ -39,7 +39,7 @@ export class PathUtil {
const parts = file.split('/');
const newParts = parts
.filter((part) => part.length > 0)
.map((filterdPart) => decodeURI(filterdPart));
.map((filteredPart) => decodeURIComponent(filteredPart));
return newParts.join('/');
}

Expand Down
37 changes: 37 additions & 0 deletions test-workspace/special #01 folder/test #01 file.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html>
<head>
<title>Test File with Special Characters</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
}
h1 {
color: #2c3e50;
}
.info {
background-color: #f8f9fa;
padding: 15px;
border-left: 4px solid #007acc;
margin: 20px 0;
}
</style>
</head>
<body>
<h1>Special Characters Test</h1>
<div class="info">
<p><strong>Purpose:</strong> This file tests URL encoding of special characters in file paths.</p>
<p><strong>Folder name:</strong> <code>special #01 folder</code></p>
<p><strong>File name:</strong> <code>test #01 file.html</code></p>
</div>
<p>If you can see this page correctly, the URL encoding is working properly!</p>
<p>Both the folder name and filename contain:</p>
<ul>
<li>Spaces (should be encoded as %20)</li>
<li>Hash symbols # (should be encoded as %23)</li>
</ul>
</body>
</html>