diff --git a/web/src/client/components/clickable-path.ts b/web/src/client/components/clickable-path.ts index 94970ac2..9d232a5f 100644 --- a/web/src/client/components/clickable-path.ts +++ b/web/src/client/components/clickable-path.ts @@ -8,7 +8,7 @@ * @fires path-copy-failed - When path copy fails (detail: { path: string, error: string }) */ import { html, LitElement } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; import { createLogger } from '../utils/logger.js'; import { copyToClipboard, formatPathForDisplay } from '../utils/path-utils.js'; import './copy-icon.js'; @@ -26,6 +26,10 @@ export class ClickablePath extends LitElement { @property({ type: String }) class = ''; @property({ type: Number }) iconSize = 12; + // Cache formatted path to avoid re-computation on every render + @state() private _formattedPath = ''; + private _lastPath = ''; + private async handleClick(e: Event) { e.stopPropagation(); e.preventDefault(); @@ -64,7 +68,11 @@ export class ClickablePath extends LitElement { render() { if (!this.path) return html``; - const displayText = formatPathForDisplay(this.path); + // Only recompute if path has changed + if (this.path !== this._lastPath) { + this._formattedPath = formatPathForDisplay(this.path); + this._lastPath = this.path; + } return html`
- ${displayText} + ${this._formattedPath}
`; diff --git a/web/src/client/utils/path-utils.test.ts b/web/src/client/utils/path-utils.test.ts index 62401a73..855cbc4a 100644 --- a/web/src/client/utils/path-utils.test.ts +++ b/web/src/client/utils/path-utils.test.ts @@ -13,6 +13,20 @@ describe('formatPathForDisplay', () => { expect(formatPathForDisplay('/Users/john.doe/Documents')).toBe('~/Documents'); expect(formatPathForDisplay('/Users/alice-smith/Projects')).toBe('~/Projects'); expect(formatPathForDisplay('/Users/user123/Desktop')).toBe('~/Desktop'); + expect(formatPathForDisplay('/Users/user_name/Files')).toBe('~/Files'); + expect(formatPathForDisplay('/Users/user@company/Work')).toBe('~/Work'); + }); + + it('should handle usernames with regex special characters safely', () => { + // Test usernames that contain regex special characters + expect(formatPathForDisplay('/Users/user[test]/Documents')).toBe('~/Documents'); + expect(formatPathForDisplay('/Users/user(group)/Projects')).toBe('~/Projects'); + expect(formatPathForDisplay('/Users/user+plus/Desktop')).toBe('~/Desktop'); + expect(formatPathForDisplay('/Users/user$money/Files')).toBe('~/Files'); + expect(formatPathForDisplay('/Users/user.com/Work')).toBe('~/Work'); + expect(formatPathForDisplay('/Users/user*star/Downloads')).toBe('~/Downloads'); + expect(formatPathForDisplay('/Users/user?question/Apps')).toBe('~/Apps'); + expect(formatPathForDisplay('/Users/user^caret/Code')).toBe('~/Code'); }); it('should not replace if not at the beginning', () => { @@ -31,6 +45,20 @@ describe('formatPathForDisplay', () => { expect(formatPathForDisplay('/home/john.doe/Documents')).toBe('~/Documents'); expect(formatPathForDisplay('/home/alice-smith/Projects')).toBe('~/Projects'); expect(formatPathForDisplay('/home/user123/Desktop')).toBe('~/Desktop'); + expect(formatPathForDisplay('/home/user_name/Files')).toBe('~/Files'); + expect(formatPathForDisplay('/home/user@company/Work')).toBe('~/Work'); + }); + + it('should handle usernames with regex special characters safely', () => { + // Test usernames that contain regex special characters + expect(formatPathForDisplay('/home/user[test]/Documents')).toBe('~/Documents'); + expect(formatPathForDisplay('/home/user(group)/Projects')).toBe('~/Projects'); + expect(formatPathForDisplay('/home/user+plus/Desktop')).toBe('~/Desktop'); + expect(formatPathForDisplay('/home/user$money/Files')).toBe('~/Files'); + expect(formatPathForDisplay('/home/user.com/Work')).toBe('~/Work'); + expect(formatPathForDisplay('/home/user*star/Downloads')).toBe('~/Downloads'); + expect(formatPathForDisplay('/home/user?question/Apps')).toBe('~/Apps'); + expect(formatPathForDisplay('/home/user^caret/Code')).toBe('~/Code'); }); it('should not replace if not at the beginning', () => { @@ -70,6 +98,19 @@ describe('formatPathForDisplay', () => { expect(formatPathForDisplay('C:/Users/alice-smith/Projects')).toBe('~/Projects'); expect(formatPathForDisplay('c:\\Users\\user123\\Desktop')).toBe('~\\Desktop'); expect(formatPathForDisplay('c:/Users/test_user/Files')).toBe('~/Files'); + expect(formatPathForDisplay('C:\\Users\\user@company\\Work')).toBe('~\\Work'); + }); + + it('should handle usernames with regex special characters safely', () => { + // Test usernames that contain regex special characters on Windows + expect(formatPathForDisplay('C:\\Users\\user[test]\\Documents')).toBe('~\\Documents'); + expect(formatPathForDisplay('C:/Users/user(group)/Projects')).toBe('~/Projects'); + expect(formatPathForDisplay('c:\\Users\\user+plus\\Desktop')).toBe('~\\Desktop'); + expect(formatPathForDisplay('c:/Users/user$money/Files')).toBe('~/Files'); + expect(formatPathForDisplay('C:\\Users\\user.com\\Work')).toBe('~\\Work'); + expect(formatPathForDisplay('C:/Users/user*star/Downloads')).toBe('~/Downloads'); + expect(formatPathForDisplay('c:\\Users\\user?question\\Apps')).toBe('~\\Apps'); + expect(formatPathForDisplay('c:/Users/user^caret/Code')).toBe('~/Code'); }); it('should not replace if not C: drive', () => { @@ -141,9 +182,12 @@ describe('formatPathForDisplay', () => { }); describe('copyToClipboard', () => { - // Note: These tests would require mocking navigator.clipboard - // which is better handled in a browser environment or with proper mocks it('should be a function', () => { expect(typeof copyToClipboard).toBe('function'); }); + + // Note: Full testing of clipboard functionality requires a DOM environment + // These tests verify the basic structure without mocking the entire DOM/browser APIs + // which is complex in the current test setup. The actual clipboard functionality + // is tested through integration tests and manual testing. }); diff --git a/web/src/client/utils/path-utils.ts b/web/src/client/utils/path-utils.ts index 3dc7c48e..75b4bdd1 100644 --- a/web/src/client/utils/path-utils.ts +++ b/web/src/client/utils/path-utils.ts @@ -19,21 +19,15 @@ * formatPathForDisplay('C:\\Users\\jane\\Desktop') // returns '~/Desktop' * formatPathForDisplay('/home/bob/projects') // returns '~/projects' */ +// Compile regex once for better performance +const HOME_PATTERN = /^(?:\/Users\/[^/]+|\/home\/[^/]+|[Cc]:[\/\\]Users[\/\\][^\/\\]+|\/root)/; + export function formatPathForDisplay(path: string): string { if (!path) return ''; - // Use a single regex to match all patterns at once to avoid multiple replacements - // This prevents issues like /home/user1/projects/home/user2 becoming ~/projects/~ - const homePattern = new RegExp( - '^(' + - '/Users/[^/]+|' + // macOS - '/home/[^/]+|' + // Linux - '[Cc]:[/\\\\]Users[/\\\\][^/\\\\]+|' + // Windows (both slashes, case-insensitive) - '/root' + // Root user - ')' - ); - - return path.replace(homePattern, '~'); + // Use pre-compiled regex for better performance + // The regex safely matches home directories without being affected by special characters in usernames + return path.replace(HOME_PATTERN, '~'); } /**