diff --git a/src/editorPreview/pageHistoryTracker.ts b/src/editorPreview/pageHistoryTracker.ts index 67303bfd..ab085538 100644 --- a/src/editorPreview/pageHistoryTracker.ts +++ b/src/editorPreview/pageHistoryTracker.ts @@ -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(); const lastItem = this._history[this._backstep]; if ( diff --git a/src/editorPreview/previewManager.ts b/src/editorPreview/previewManager.ts index 8f2c714c..598490b3 100644 --- a/src/editorPreview/previewManager.ts +++ b/src/editorPreview/previewManager.ts @@ -101,7 +101,7 @@ export class PreviewManager extends Disposable { file?: vscode.Uri ): Promise { const path = file - ? PathUtil.ConvertToPosixPath(await this._fileUriToPath(file, connection)) + ? await this._fileUriToPath(file, connection) : '/'; const url = `http://${connection.host}:${connection.httpPort}${path}`; @@ -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 { let path = '/'; @@ -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; } diff --git a/src/infoManagers/endpointManager.ts b/src/infoManagers/endpointManager.ts index b7e08d0d..ff8a5f05 100644 --- a/src/infoManagers/endpointManager.ts +++ b/src/infoManagers/endpointManager.ts @@ -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; } diff --git a/src/test/runTest.ts b/src/test/runTest.ts index eeed5338..34b1c394 100644 --- a/src/test/runTest.ts +++ b/src/test/runTest.ts @@ -12,12 +12,17 @@ async function main(): Promise { // 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); diff --git a/src/test/suite/connectionInfo.test.ts b/src/test/suite/connectionInfo.test.ts index 72b5dc5b..1d8a1f27 100644 --- a/src/test/suite/connectionInfo.test.ts +++ b/src/test/suite/connectionInfo.test.ts @@ -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({})); diff --git a/src/test/suite/endpointManager.test.ts b/src/test/suite/endpointManager.test.ts index 86687947..04dd67cc 100644 --- a/src/test/suite/endpointManager.test.ts +++ b/src/test/suite/endpointManager.test.ts @@ -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) { @@ -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 () => { @@ -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); + }); }); \ No newline at end of file diff --git a/src/test/suite/pathUtil.test.ts b/src/test/suite/pathUtil.test.ts index 869808f0..e23077f4 100644 --- a/src/test/suite/pathUtil.test.ts +++ b/src/test/suite/pathUtil.test.ts @@ -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); + }); }); \ No newline at end of file diff --git a/src/test/suite/preview.test.ts b/src/test/suite/preview.test.ts index b2104619..2d0f99f9 100644 --- a/src/test/suite/preview.test.ts +++ b/src/test/suite/preview.test.ts @@ -53,6 +53,10 @@ describe('PreviewManager', () => { sandbox.restore(); }); + afterEach(() => { + sinon.restore(); + }); + it("previews in embedded preview", async () => { const goToFile = sinon.spy(WebviewComm.prototype, 'goToFile'); @@ -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 () => { @@ -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 () => { @@ -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' + ); }); }); \ No newline at end of file diff --git a/src/utils/pathUtil.ts b/src/utils/pathUtil.ts index 5f01fdd9..78aad84d 100644 --- a/src/utils/pathUtil.ts +++ b/src/utils/pathUtil.ts @@ -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. */ @@ -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('/'); } @@ -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('/'); } diff --git a/test-workspace/special #01 folder/test #01 file.html b/test-workspace/special #01 folder/test #01 file.html new file mode 100644 index 00000000..81d74e14 --- /dev/null +++ b/test-workspace/special #01 folder/test #01 file.html @@ -0,0 +1,37 @@ + + + + Test File with Special Characters + + + +

Special Characters Test

+
+

Purpose: This file tests URL encoding of special characters in file paths.

+

Folder name: special #01 folder

+

File name: test #01 file.html

+
+

If you can see this page correctly, the URL encoding is working properly!

+

Both the folder name and filename contain:

+
    +
  • Spaces (should be encoded as %20)
  • +
  • Hash symbols # (should be encoded as %23)
  • +
+ +