From b0b4e0b2e9bc7c023d6a33922b8ac8cdf3545cd6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 20 Jul 2025 21:11:21 +0200 Subject: [PATCH] feat: Unified autocomplete for working directory in session creation (#435) --- CLAUDE.md | 1 + .../client/components/autocomplete-manager.ts | 138 +++++ .../client/components/session-create-form.ts | 502 ++++++++++-------- web/src/client/components/unified-settings.ts | 44 +- .../services/repository-service.test.ts | 186 +++++++ web/src/client/services/repository-service.ts | 66 +++ .../client/services/session-service.test.ts | 197 +++++++ web/src/client/services/session-service.ts | 72 +++ web/src/client/utils/command-utils.test.ts | 142 +++++ web/src/client/utils/command-utils.ts | 67 +++ web/src/client/utils/storage-utils.test.ts | 233 ++++++++ web/src/client/utils/storage-utils.ts | 110 ++++ web/src/client/utils/title-mode-utils.test.ts | 86 +++ web/src/client/utils/title-mode-utils.ts | 41 ++ web/src/server/routes/filesystem.ts | 129 +++++ .../components/autocomplete-manager.test.ts | 456 ++++++++++++++++ 16 files changed, 2251 insertions(+), 219 deletions(-) create mode 100644 web/src/client/components/autocomplete-manager.ts create mode 100644 web/src/client/services/repository-service.test.ts create mode 100644 web/src/client/services/repository-service.ts create mode 100644 web/src/client/services/session-service.test.ts create mode 100644 web/src/client/services/session-service.ts create mode 100644 web/src/client/utils/command-utils.test.ts create mode 100644 web/src/client/utils/command-utils.ts create mode 100644 web/src/client/utils/storage-utils.test.ts create mode 100644 web/src/client/utils/storage-utils.ts create mode 100644 web/src/client/utils/title-mode-utils.test.ts create mode 100644 web/src/client/utils/title-mode-utils.ts create mode 100644 web/src/test/client/components/autocomplete-manager.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 32aef2c5..c4f64da7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,7 @@ When the user says "release" or asks to create a release, ALWAYS read and follow - Always check current branch with `git branch` before making changes - If unsure about branching, ASK THE USER FIRST - **"Adopt" means REVIEW, not merge!** When asked to "adopt" a PR, switch to its branch and review the changes. NEVER merge without explicit permission. +- **"Rebase main" means rebase CURRENT branch with main!** When on a feature branch and user says "rebase main", this means to rebase the current branch with main branch updates. NEVER switch to main branch. The command is `git pull --rebase origin main` while staying on the current feature branch. ### Terminal Title Management with VT diff --git a/web/src/client/components/autocomplete-manager.ts b/web/src/client/components/autocomplete-manager.ts new file mode 100644 index 00000000..ba819f1e --- /dev/null +++ b/web/src/client/components/autocomplete-manager.ts @@ -0,0 +1,138 @@ +import type { AuthClient } from '../services/auth-client.js'; +import { createLogger } from '../utils/logger.js'; + +const logger = createLogger('autocomplete-manager'); + +export interface AutocompleteItem { + name: string; + path: string; + type: 'file' | 'directory'; + suggestion: string; + isRepository?: boolean; +} + +export interface Repository { + id: string; + path: string; + folderName: string; + lastModified: string; + relativePath: string; +} + +export class AutocompleteManager { + private repositories: Repository[] = []; + private authClient?: AuthClient; + + constructor(authClient?: AuthClient) { + this.authClient = authClient; + } + + setAuthClient(authClient: AuthClient | undefined) { + this.authClient = authClient; + } + + setRepositories(repositories: Repository[]) { + this.repositories = repositories; + } + + async fetchCompletions(path: string): Promise { + if (!path) return []; + + try { + // Fetch filesystem completions + const response = await fetch(`/api/fs/completions?path=${encodeURIComponent(path)}`, { + headers: this.authClient?.getAuthHeader() || {}, + }); + + if (!response.ok) { + logger.error('Failed to fetch completions'); + return []; + } + + const data = await response.json(); + const completions: AutocompleteItem[] = data.completions || []; + + // Also search through discovered repositories if user is typing a partial name + const isSearchingByName = + !path.includes('/') || + ((path.match(/\//g) || []).length === 1 && path.endsWith('/') === false); + + if (isSearchingByName && this.repositories.length > 0) { + const searchTerm = path.toLowerCase().replace('~/', ''); + + // Filter repositories that match the search term + const matchingRepos = this.repositories + .filter((repo) => repo.folderName.toLowerCase().includes(searchTerm)) + .map((repo) => ({ + name: repo.folderName, + path: repo.relativePath, + type: 'directory' as const, + suggestion: repo.path, + isRepository: true, + })); + + // Merge with filesystem completions, avoiding duplicates + const existingPaths = new Set(completions.map((c) => c.suggestion)); + const uniqueRepos = matchingRepos.filter((repo) => !existingPaths.has(repo.suggestion)); + + completions.push(...uniqueRepos); + } + + // Sort completions with custom logic + const sortedCompletions = this.sortCompletions(completions, path); + + // Limit to 20 results for performance + return sortedCompletions.slice(0, 20); + } catch (error) { + logger.error('Error fetching completions:', error); + return []; + } + } + + private sortCompletions( + completions: AutocompleteItem[], + originalPath: string + ): AutocompleteItem[] { + const searchTerm = originalPath.toLowerCase(); + const lastPathSegment = searchTerm.split('/').pop() || ''; + + return completions.sort((a, b) => { + // 1. Direct name matches come first + const aNameMatch = a.name.toLowerCase() === lastPathSegment; + const bNameMatch = b.name.toLowerCase() === lastPathSegment; + if (aNameMatch && !bNameMatch) return -1; + if (!aNameMatch && bNameMatch) return 1; + + // 2. Name starts with search term + const aStartsWith = a.name.toLowerCase().startsWith(lastPathSegment); + const bStartsWith = b.name.toLowerCase().startsWith(lastPathSegment); + if (aStartsWith && !bStartsWith) return -1; + if (!aStartsWith && bStartsWith) return 1; + + // 3. Directories before files + if (a.type !== b.type) { + return a.type === 'directory' ? -1 : 1; + } + + // 4. Git repositories before regular directories + if (a.type === 'directory' && b.type === 'directory') { + if (a.isRepository && !b.isRepository) return -1; + if (!a.isRepository && b.isRepository) return 1; + } + + // 5. Alphabetical order + return a.name.localeCompare(b.name); + }); + } + + filterCompletions(completions: AutocompleteItem[], searchTerm: string): AutocompleteItem[] { + if (!searchTerm) return completions; + + const lowerSearch = searchTerm.toLowerCase(); + return completions.filter((item) => { + const name = item.name.toLowerCase(); + const path = item.path.toLowerCase(); + return name.includes(lowerSearch) || path.includes(lowerSearch); + }); + } +} diff --git a/web/src/client/components/session-create-form.ts b/web/src/client/components/session-create-form.ts index a8c780ee..fcf1b39a 100644 --- a/web/src/client/components/session-create-form.ts +++ b/web/src/client/components/session-create-form.ts @@ -16,7 +16,24 @@ import { customElement, property, state } from 'lit/decorators.js'; import './file-browser.js'; import { TitleMode } from '../../shared/types.js'; import type { AuthClient } from '../services/auth-client.js'; +import { RepositoryService } from '../services/repository-service.js'; +import { type SessionCreateData, SessionService } from '../services/session-service.js'; +import { parseCommand } from '../utils/command-utils.js'; import { createLogger } from '../utils/logger.js'; +import { formatPathForDisplay } from '../utils/path-utils.js'; +import { + getSessionFormValue, + loadSessionFormData, + removeSessionFormValue, + saveSessionFormData, + setSessionFormValue, +} from '../utils/storage-utils.js'; +import { getTitleModeDescription } from '../utils/title-mode-utils.js'; +import { + type AutocompleteItem, + AutocompleteManager, + type Repository, +} from './autocomplete-manager.js'; import type { Session } from './session-list.js'; import { STORAGE_KEY as APP_PREFERENCES_STORAGE_KEY, @@ -25,16 +42,6 @@ import { const logger = createLogger('session-create-form'); -export interface SessionCreateData { - command: string[]; - workingDir: string; - name?: string; - spawn_terminal?: boolean; - cols?: number; - rows?: number; - titleMode?: TitleMode; -} - @customElement('session-create-form') export class SessionCreateForm extends LitElement { // Disable shadow DOM to use Tailwind @@ -55,15 +62,13 @@ export class SessionCreateForm extends LitElement { @state() private showFileBrowser = false; @state() private selectedQuickStart = 'zsh'; @state() private showRepositoryDropdown = false; - @state() private repositories: Array<{ - id: string; - path: string; - folderName: string; - lastModified: string; - relativePath: string; - }> = []; + @state() private repositories: Repository[] = []; @state() private isDiscovering = false; @state() private macAppConnected = false; + @state() private showCompletions = false; + @state() private completions: AutocompleteItem[] = []; + @state() private selectedCompletionIndex = -1; + @state() private isLoadingCompletions = false; quickStartCommands = [ { label: 'claude', command: 'claude' }, @@ -74,13 +79,21 @@ export class SessionCreateForm extends LitElement { { label: 'pnpm run dev', command: 'pnpm run dev' }, ]; - private readonly STORAGE_KEY_WORKING_DIR = 'vibetunnel_last_working_dir'; - private readonly STORAGE_KEY_COMMAND = 'vibetunnel_last_command'; - private readonly STORAGE_KEY_SPAWN_WINDOW = 'vibetunnel_spawn_window'; - private readonly STORAGE_KEY_TITLE_MODE = 'vibetunnel_title_mode'; + private completionsDebounceTimer?: NodeJS.Timeout; + private autocompleteManager!: AutocompleteManager; + private repositoryService?: RepositoryService; + private sessionService?: SessionService; connectedCallback() { super.connectedCallback(); + // Initialize services - AutocompleteManager handles optional authClient + this.autocompleteManager = new AutocompleteManager(this.authClient); + + // Initialize other services only if authClient is available + if (this.authClient) { + this.repositoryService = new RepositoryService(this.authClient); + this.sessionService = new SessionService(this.authClient); + } // Load from localStorage when component is first created this.loadFromLocalStorage(); // Check server status @@ -93,6 +106,10 @@ export class SessionCreateForm extends LitElement { if (this.visible) { document.removeEventListener('keydown', this.handleGlobalKeyDown); } + // Clean up debounce timer + if (this.completionsDebounceTimer) { + clearTimeout(this.completionsDebounceTimer); + } } private handleGlobalKeyDown = (e: KeyboardEvent) => { @@ -107,6 +124,9 @@ export class SessionCreateForm extends LitElement { // Don't interfere with Enter in textarea elements if (e.target instanceof HTMLTextAreaElement) return; + // Don't submit if autocomplete is active and an item is selected + if (this.showCompletions && this.selectedCompletionIndex >= 0) return; + // Check if form is valid (same conditions as Create button) const canCreate = !this.disabled && !this.isCreating && this.workingDir?.trim() && this.command?.trim(); @@ -120,72 +140,45 @@ export class SessionCreateForm extends LitElement { }; private loadFromLocalStorage() { - try { - const savedWorkingDir = localStorage.getItem(this.STORAGE_KEY_WORKING_DIR); - const savedCommand = localStorage.getItem(this.STORAGE_KEY_COMMAND); - const savedSpawnWindow = localStorage.getItem(this.STORAGE_KEY_SPAWN_WINDOW); - const savedTitleMode = localStorage.getItem(this.STORAGE_KEY_TITLE_MODE); + const formData = loadSessionFormData(); - // Get app preferences for repository base path to use as default working dir - let appRepoBasePath = '~/'; - const savedPreferences = localStorage.getItem(APP_PREFERENCES_STORAGE_KEY); - if (savedPreferences) { - try { - const preferences: AppPreferences = JSON.parse(savedPreferences); - appRepoBasePath = preferences.repositoryBasePath || '~/'; - } catch (error) { - logger.error('Failed to parse app preferences:', error); - } + // Get app preferences for repository base path to use as default working dir + let appRepoBasePath = '~/'; + const savedPreferences = localStorage.getItem(APP_PREFERENCES_STORAGE_KEY); + if (savedPreferences) { + try { + const preferences: AppPreferences = JSON.parse(savedPreferences); + appRepoBasePath = preferences.repositoryBasePath || '~/'; + } catch (error) { + logger.error('Failed to parse app preferences:', error); } - - // Always set values, using saved values or defaults - // Priority: savedWorkingDir > appRepoBasePath > default - this.workingDir = savedWorkingDir || appRepoBasePath || '~/'; - this.command = savedCommand || 'zsh'; - - // For spawn window, only use saved value if it exists and is valid - // This ensures we respect the default (false) when nothing is saved - if (savedSpawnWindow !== null && savedSpawnWindow !== '') { - this.spawnWindow = savedSpawnWindow === 'true'; - } - - if (savedTitleMode !== null) { - // Validate the saved mode is a valid enum value - if (Object.values(TitleMode).includes(savedTitleMode as TitleMode)) { - this.titleMode = savedTitleMode as TitleMode; - } else { - // If invalid value in localStorage, default to DYNAMIC - this.titleMode = TitleMode.DYNAMIC; - } - } else { - // If no value in localStorage, ensure DYNAMIC is set - this.titleMode = TitleMode.DYNAMIC; - } - - // Force re-render to update the input values - this.requestUpdate(); - } catch (_error) { - logger.warn('failed to load from localStorage'); } + + // Always set values, using saved values or defaults + // Priority: savedWorkingDir > appRepoBasePath > default + this.workingDir = formData.workingDir || appRepoBasePath || '~/'; + this.command = formData.command || 'zsh'; + + // For spawn window, use saved value or default to false + this.spawnWindow = formData.spawnWindow ?? false; + + // For title mode, use saved value or default to DYNAMIC + this.titleMode = formData.titleMode || TitleMode.DYNAMIC; + + // Force re-render to update the input values + this.requestUpdate(); } private saveToLocalStorage() { - try { - const workingDir = this.workingDir?.trim() || ''; - const command = this.command?.trim() || ''; + const workingDir = this.workingDir?.trim() || ''; + const command = this.command?.trim() || ''; - // Only save non-empty values - if (workingDir) { - localStorage.setItem(this.STORAGE_KEY_WORKING_DIR, workingDir); - } - if (command) { - localStorage.setItem(this.STORAGE_KEY_COMMAND, command); - } - localStorage.setItem(this.STORAGE_KEY_SPAWN_WINDOW, String(this.spawnWindow)); - localStorage.setItem(this.STORAGE_KEY_TITLE_MODE, this.titleMode); - } catch (_error) { - logger.warn('failed to save to localStorage'); - } + saveSessionFormData({ + workingDir, + command, + spawnWindow: this.spawnWindow, + titleMode: this.titleMode, + }); } private async checkServerStatus() { @@ -215,6 +208,19 @@ export class SessionCreateForm extends LitElement { updated(changedProperties: PropertyValues) { super.updated(changedProperties); + // Handle authClient becoming available + if (changedProperties.has('authClient') && this.authClient) { + // Initialize services if they haven't been created yet + if (!this.repositoryService) { + this.repositoryService = new RepositoryService(this.authClient); + } + if (!this.sessionService) { + this.sessionService = new SessionService(this.authClient); + } + // Update autocomplete manager's authClient + this.autocompleteManager.setAuthClient(this.authClient); + } + // Handle visibility changes if (changedProperties.has('visible')) { if (this.visible) { @@ -259,6 +265,18 @@ export class SessionCreateForm extends LitElement { detail: this.workingDir, }) ); + + // Hide repository dropdown when typing + this.showRepositoryDropdown = false; + + // Trigger autocomplete with debounce + if (this.completionsDebounceTimer) { + clearTimeout(this.completionsDebounceTimer); + } + + this.completionsDebounceTimer = setTimeout(() => { + this.fetchCompletions(); + }, 300); } private handleCommandChange(e: Event) { @@ -285,21 +303,6 @@ export class SessionCreateForm extends LitElement { this.titleMode = select.value as TitleMode; } - private getTitleModeDescription(): string { - switch (this.titleMode) { - case TitleMode.NONE: - return 'Apps control their own titles'; - case TitleMode.FILTER: - return 'Blocks all title changes'; - case TitleMode.STATIC: - return 'Shows path and command'; - case TitleMode.DYNAMIC: - return '○ idle ● active ▶ running'; - default: - return ''; - } - } - private handleBrowse() { logger.debug('handleBrowse called, setting showFileBrowser to true'); this.showFileBrowser = true; @@ -307,7 +310,7 @@ export class SessionCreateForm extends LitElement { } private handleDirectorySelected(e: CustomEvent) { - this.workingDir = e.detail; + this.workingDir = formatPathForDisplay(e.detail); this.showFileBrowser = false; } @@ -331,7 +334,7 @@ export class SessionCreateForm extends LitElement { const effectiveSpawnTerminal = this.spawnWindow && this.macAppConnected; const sessionData: SessionCreateData = { - command: this.parseCommand(this.command?.trim() || ''), + command: parseCommand(this.command?.trim() || ''), workingDir: this.workingDir?.trim() || '', spawn_terminal: effectiveSpawnTerminal, titleMode: this.titleMode, @@ -351,60 +354,45 @@ export class SessionCreateForm extends LitElement { } try { - const response = await fetch('/api/sessions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...this.authClient.getAuthHeader(), - }, - body: JSON.stringify(sessionData), - }); - - if (response.ok) { - const result = await response.json(); - - // Save to localStorage before clearing the fields - // In test environments, don't save spawn window to avoid cross-test contamination - const isTestEnvironment = - window.location.search.includes('test=true') || - navigator.userAgent.includes('HeadlessChrome'); - - if (isTestEnvironment) { - // Save everything except spawn window in tests - const currentSpawnWindow = localStorage.getItem(this.STORAGE_KEY_SPAWN_WINDOW); - this.saveToLocalStorage(); - // Restore the original spawn window value - if (currentSpawnWindow !== null) { - localStorage.setItem(this.STORAGE_KEY_SPAWN_WINDOW, currentSpawnWindow); - } else { - localStorage.removeItem(this.STORAGE_KEY_SPAWN_WINDOW); - } - } else { - this.saveToLocalStorage(); - } - - this.command = ''; // Clear command on success - this.sessionName = ''; // Clear session name on success - this.dispatchEvent( - new CustomEvent('session-created', { - detail: result, - }) - ); - } else { - const error = await response.json(); - // Use the detailed error message if available, otherwise fall back to the error field - const errorMessage = error.details || error.error || 'Unknown error'; - this.dispatchEvent( - new CustomEvent('error', { - detail: errorMessage, - }) - ); + // Check if sessionService is initialized + if (!this.sessionService) { + throw new Error('Session service not initialized'); } + const result = await this.sessionService.createSession(sessionData); + + // Save to localStorage before clearing the fields + // In test environments, don't save spawn window to avoid cross-test contamination + const isTestEnvironment = + window.location.search.includes('test=true') || + navigator.userAgent.includes('HeadlessChrome'); + + if (isTestEnvironment) { + // Save everything except spawn window in tests + const currentSpawnWindow = getSessionFormValue('SPAWN_WINDOW'); + this.saveToLocalStorage(); + // Restore the original spawn window value + if (currentSpawnWindow !== null) { + setSessionFormValue('SPAWN_WINDOW', currentSpawnWindow); + } else { + removeSessionFormValue('SPAWN_WINDOW'); + } + } else { + this.saveToLocalStorage(); + } + + this.command = ''; // Clear command on success + this.sessionName = ''; // Clear session name on success + this.dispatchEvent( + new CustomEvent('session-created', { + detail: result, + }) + ); } catch (error) { - logger.error('error creating session:', error); + const errorMessage = error instanceof Error ? error.message : 'Failed to create session'; + logger.error('Error creating session:', error); this.dispatchEvent( new CustomEvent('error', { - detail: 'Failed to create session', + detail: errorMessage, }) ); } finally { @@ -412,39 +400,6 @@ export class SessionCreateForm extends LitElement { } } - private parseCommand(commandStr: string): string[] { - // Simple command parsing - split by spaces but respect quotes - const args: string[] = []; - let current = ''; - let inQuotes = false; - let quoteChar = ''; - - for (let i = 0; i < commandStr.length; i++) { - const char = commandStr[i]; - - if ((char === '"' || char === "'") && !inQuotes) { - inQuotes = true; - quoteChar = char; - } else if (char === quoteChar && inQuotes) { - inQuotes = false; - quoteChar = ''; - } else if (char === ' ' && !inQuotes) { - if (current) { - args.push(current); - current = ''; - } - } else { - current += char; - } - } - - if (current) { - args.push(current); - } - - return args; - } - private handleCancel() { this.dispatchEvent(new CustomEvent('cancel')); } @@ -466,37 +421,17 @@ export class SessionCreateForm extends LitElement { } private async discoverRepositories() { - // Get app preferences to read repositoryBasePath - const savedPreferences = localStorage.getItem(APP_PREFERENCES_STORAGE_KEY); - let basePath = '~/'; - - if (savedPreferences) { - try { - const preferences: AppPreferences = JSON.parse(savedPreferences); - basePath = preferences.repositoryBasePath || '~/'; - } catch (error) { - logger.error('Failed to parse app preferences:', error); - } - } - this.isDiscovering = true; - try { - const response = await fetch( - `/api/repositories/discover?path=${encodeURIComponent(basePath)}`, - { - headers: this.authClient.getAuthHeader(), - } - ); - - if (response.ok) { - this.repositories = await response.json(); - logger.debug(`Discovered ${this.repositories.length} repositories`); + // Only proceed if repositoryService is initialized + if (this.repositoryService) { + this.repositories = await this.repositoryService.discoverRepositories(); + // Update autocomplete manager with discovered repositories + this.autocompleteManager.setRepositories(this.repositories); } else { - logger.error('Failed to discover repositories'); + logger.warn('Repository service not initialized yet'); + this.repositories = []; } - } catch (error) { - logger.error('Error discovering repositories:', error); } finally { this.isDiscovering = false; } @@ -506,11 +441,87 @@ export class SessionCreateForm extends LitElement { this.showRepositoryDropdown = !this.showRepositoryDropdown; } + private handleToggleAutocomplete() { + // If we have text input, toggle the autocomplete + if (this.workingDir?.trim()) { + if (this.showCompletions) { + this.showCompletions = false; + this.completions = []; + } else { + this.fetchCompletions(); + } + } else { + // If no text, show repository dropdown instead + this.showRepositoryDropdown = !this.showRepositoryDropdown; + } + } + private handleSelectRepository(repoPath: string) { - this.workingDir = repoPath; + this.workingDir = formatPathForDisplay(repoPath); this.showRepositoryDropdown = false; } + private async fetchCompletions() { + const path = this.workingDir?.trim(); + if (!path || path === '') { + this.completions = []; + this.showCompletions = false; + return; + } + + this.isLoadingCompletions = true; + + try { + // Use the autocomplete manager to fetch completions + this.completions = await this.autocompleteManager.fetchCompletions(path); + this.showCompletions = this.completions.length > 0; + this.selectedCompletionIndex = -1; + } catch (error) { + logger.error('Error fetching completions:', error); + this.completions = []; + this.showCompletions = false; + } finally { + this.isLoadingCompletions = false; + } + } + + private handleSelectCompletion(suggestion: string) { + this.workingDir = formatPathForDisplay(suggestion); + this.showCompletions = false; + this.completions = []; + this.selectedCompletionIndex = -1; + } + + private handleWorkingDirKeydown(e: KeyboardEvent) { + if (!this.showCompletions || this.completions.length === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + this.selectedCompletionIndex = Math.min( + this.selectedCompletionIndex + 1, + this.completions.length - 1 + ); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + this.selectedCompletionIndex = Math.max(this.selectedCompletionIndex - 1, -1); + } else if ((e.key === 'Tab' || e.key === 'Enter') && this.selectedCompletionIndex >= 0) { + e.preventDefault(); + e.stopPropagation(); + this.handleSelectCompletion(this.completions[this.selectedCompletionIndex].suggestion); + } else if (e.key === 'Escape') { + this.showCompletions = false; + this.selectedCompletionIndex = -1; + } + } + + private handleWorkingDirBlur() { + // Hide completions after a delay to allow clicking on them + setTimeout(() => { + this.showCompletions = false; + this.selectedCompletionIndex = -1; + }, 200); + } + render() { if (!this.visible) { return html``; @@ -581,15 +592,19 @@ export class SessionCreateForm extends LitElement {
-
+
+
+ ${ + this.showCompletions && this.completions.length > 0 + ? html` +
+
+ ${this.completions.map( + (completion, index) => html` + + ` + )} +
+
+ ` + : '' + } ${ this.showRepositoryDropdown && this.repositories.length > 0 ? html` @@ -683,11 +749,11 @@ export class SessionCreateForm extends LitElement { } -
+
Terminal Title Mode
diff --git a/web/src/client/components/unified-settings.ts b/web/src/client/components/unified-settings.ts index 17910f3c..55feaff0 100644 --- a/web/src/client/components/unified-settings.ts +++ b/web/src/client/components/unified-settings.ts @@ -1,5 +1,6 @@ import { html, LitElement, type PropertyValues } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; +import type { AuthClient } from '../services/auth-client.js'; import { type NotificationPreferences, type PushSubscription, @@ -39,6 +40,7 @@ export class UnifiedSettings extends LitElement { } @property({ type: Boolean }) visible = false; + @property({ type: Object }) authClient?: AuthClient; // Notification settings state @state() private notificationPreferences: NotificationPreferences = { @@ -61,6 +63,8 @@ export class UnifiedSettings extends LitElement { @state() private mediaState: MediaQueryState = responsiveObserver.getCurrentState(); @state() private serverConfig: ServerConfig | null = null; @state() private isServerConfigured = false; + @state() private repositoryCount = 0; + @state() private isDiscoveringRepositories = false; private permissionChangeUnsubscribe?: () => void; private subscriptionChangeUnsubscribe?: () => void; @@ -72,6 +76,7 @@ export class UnifiedSettings extends LitElement { this.initializeNotifications(); this.loadAppPreferences(); this.connectConfigWebSocket(); + this.discoverRepositories(); // Subscribe to responsive changes this.unsubscribeResponsive = responsiveObserver.subscribe((state) => { @@ -282,6 +287,36 @@ export class UnifiedSettings extends LitElement { path: value as string, }) ); + // Re-discover repositories when path changes + this.discoverRepositories(); + } + } + + private async discoverRepositories() { + this.isDiscoveringRepositories = true; + + try { + const basePath = this.appPreferences.repositoryBasePath || '~/'; + const response = await fetch( + `/api/repositories/discover?path=${encodeURIComponent(basePath)}`, + { + headers: this.authClient?.getAuthHeader() || {}, + } + ); + + if (response.ok) { + const repositories = await response.json(); + this.repositoryCount = repositories.length; + logger.debug(`Discovered ${this.repositoryCount} repositories in ${basePath}`); + } else { + logger.error('Failed to discover repositories'); + this.repositoryCount = 0; + } + } catch (error) { + logger.error('Error discovering repositories:', error); + this.repositoryCount = 0; + } finally { + this.isDiscoveringRepositories = false; } } @@ -621,7 +656,14 @@ export class UnifiedSettings extends LitElement {
- +
+ + ${ + this.isDiscoveringRepositories + ? html`Scanning...` + : html`${this.repositoryCount} repositories found` + } +

${ this.isServerConfigured diff --git a/web/src/client/services/repository-service.test.ts b/web/src/client/services/repository-service.test.ts new file mode 100644 index 00000000..e933dd4d --- /dev/null +++ b/web/src/client/services/repository-service.test.ts @@ -0,0 +1,186 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Repository } from '../components/autocomplete-manager'; +import type { AuthClient } from './auth-client'; +import { RepositoryService } from './repository-service'; + +describe('RepositoryService', () => { + let service: RepositoryService; + let mockAuthClient: AuthClient; + let fetchMock: ReturnType; + let mockStorage: { [key: string]: string }; + + beforeEach(() => { + // Mock localStorage + mockStorage = {}; + Object.defineProperty(window, 'localStorage', { + value: { + getItem: vi.fn((key: string) => mockStorage[key] || null), + setItem: vi.fn((key: string, value: string) => { + mockStorage[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete mockStorage[key]; + }), + clear: vi.fn(() => { + mockStorage = {}; + }), + }, + writable: true, + configurable: true, + }); + + // Mock fetch + fetchMock = vi.fn(); + global.fetch = fetchMock; + + // Mock auth client + mockAuthClient = { + getAuthHeader: vi.fn(() => ({ Authorization: 'Bearer test-token' })), + } as unknown as AuthClient; + + // Create service instance + service = new RepositoryService(mockAuthClient); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('discoverRepositories', () => { + it('should fetch repositories with default base path', async () => { + const mockRepositories: Repository[] = [ + { + id: '1', + path: '/home/user/project1', + folderName: 'project1', + lastModified: '2024-01-01', + relativePath: '~/project1', + }, + { + id: '2', + path: '/home/user/project2', + folderName: 'project2', + lastModified: '2024-01-02', + relativePath: '~/project2', + }, + ]; + + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => mockRepositories, + }); + + const result = await service.discoverRepositories(); + + expect(fetchMock).toHaveBeenCalledWith( + `/api/repositories/discover?path=${encodeURIComponent('~/')}`, + { + headers: { Authorization: 'Bearer test-token' }, + } + ); + expect(result).toEqual(mockRepositories); + }); + + it('should use repository base path from preferences', async () => { + // Set preferences in localStorage - using the correct key from unified-settings.js + mockStorage.vibetunnel_app_preferences = JSON.stringify({ + repositoryBasePath: '/custom/path', + }); + + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => [], + }); + + await service.discoverRepositories(); + + expect(fetchMock).toHaveBeenCalledWith( + `/api/repositories/discover?path=${encodeURIComponent('/custom/path')}`, + { + headers: { Authorization: 'Bearer test-token' }, + } + ); + }); + + it('should handle invalid preferences JSON', async () => { + // Set invalid JSON in localStorage + mockStorage.vibetunnel_app_preferences = 'invalid-json'; + + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => [], + }); + + await service.discoverRepositories(); + + // Should fall back to default path + expect(fetchMock).toHaveBeenCalledWith( + `/api/repositories/discover?path=${encodeURIComponent('~/')}`, + { + headers: { Authorization: 'Bearer test-token' }, + } + ); + }); + + it('should handle fetch errors', async () => { + fetchMock.mockRejectedValueOnce(new Error('Network error')); + + const result = await service.discoverRepositories(); + + expect(result).toEqual([]); + }); + + it('should handle non-ok responses', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ error: 'Server error' }), + }); + + const result = await service.discoverRepositories(); + + expect(result).toEqual([]); + }); + + it('should handle empty repository base path in preferences', async () => { + mockStorage.vibetunnel_app_preferences = JSON.stringify({ + repositoryBasePath: '', + }); + + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => [], + }); + + await service.discoverRepositories(); + + // Should fall back to default path + expect(fetchMock).toHaveBeenCalledWith( + `/api/repositories/discover?path=${encodeURIComponent('~/')}`, + { + headers: { Authorization: 'Bearer test-token' }, + } + ); + }); + + it('should include auth header in request', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => [], + }); + + await service.discoverRepositories(); + + expect(mockAuthClient.getAuthHeader).toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { Authorization: 'Bearer test-token' }, + }) + ); + }); + }); +}); diff --git a/web/src/client/services/repository-service.ts b/web/src/client/services/repository-service.ts new file mode 100644 index 00000000..577b8bb4 --- /dev/null +++ b/web/src/client/services/repository-service.ts @@ -0,0 +1,66 @@ +import type { Repository } from '../components/autocomplete-manager.js'; +import { + STORAGE_KEY as APP_PREFERENCES_STORAGE_KEY, + type AppPreferences, +} from '../components/unified-settings.js'; +import { createLogger } from '../utils/logger.js'; +import type { AuthClient } from './auth-client.js'; + +const logger = createLogger('repository-service'); + +export class RepositoryService { + private authClient: AuthClient; + + constructor(authClient: AuthClient) { + this.authClient = authClient; + } + + /** + * Discovers git repositories in the configured base path + * @returns Promise with discovered repositories + */ + async discoverRepositories(): Promise { + // Get app preferences to read repositoryBasePath + const basePath = this.getRepositoryBasePath(); + + try { + const response = await fetch( + `/api/repositories/discover?path=${encodeURIComponent(basePath)}`, + { + headers: this.authClient.getAuthHeader(), + } + ); + + if (response.ok) { + const repositories = await response.json(); + logger.debug(`Discovered ${repositories.length} repositories`); + return repositories; + } else { + logger.error('Failed to discover repositories'); + return []; + } + } catch (error) { + logger.error('Error discovering repositories:', error); + return []; + } + } + + /** + * Gets the repository base path from app preferences + * @returns The base path or default '~/' + */ + private getRepositoryBasePath(): string { + const savedPreferences = localStorage.getItem(APP_PREFERENCES_STORAGE_KEY); + + if (savedPreferences) { + try { + const preferences: AppPreferences = JSON.parse(savedPreferences); + return preferences.repositoryBasePath || '~/'; + } catch (error) { + logger.error('Failed to parse app preferences:', error); + } + } + + return '~/'; + } +} diff --git a/web/src/client/services/session-service.test.ts b/web/src/client/services/session-service.test.ts new file mode 100644 index 00000000..cfa8b7f7 --- /dev/null +++ b/web/src/client/services/session-service.test.ts @@ -0,0 +1,197 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TitleMode } from '../../shared/types'; +import type { AuthClient } from './auth-client'; +import { type SessionCreateData, SessionService } from './session-service'; + +describe('SessionService', () => { + let service: SessionService; + let mockAuthClient: AuthClient; + let fetchMock: ReturnType; + + beforeEach(() => { + // Mock fetch + fetchMock = vi.fn(); + global.fetch = fetchMock; + + // Mock auth client + mockAuthClient = { + getAuthHeader: vi.fn(() => ({ Authorization: 'Bearer test-token' })), + } as unknown as AuthClient; + + // Create service instance + service = new SessionService(mockAuthClient); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('createSession', () => { + const mockSessionData: SessionCreateData = { + command: ['npm', 'run', 'dev'], + workingDir: '/home/user/project', + name: 'Test Session', + spawn_terminal: false, + cols: 120, + rows: 30, + titleMode: TitleMode.DYNAMIC, + }; + + it('should create a session successfully', async () => { + const mockResult = { + sessionId: 'session-123', + message: 'Session created successfully', + }; + + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => mockResult, + }); + + const result = await service.createSession(mockSessionData); + + expect(fetchMock).toHaveBeenCalledWith('/api/sessions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer test-token', + }, + body: JSON.stringify(mockSessionData), + }); + expect(result).toEqual(mockResult); + }); + + it('should include auth header in request', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ sessionId: 'test-123' }), + }); + + await service.createSession(mockSessionData); + + expect(mockAuthClient.getAuthHeader).toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledWith( + '/api/sessions', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer test-token', + }), + }) + ); + }); + + it('should handle error response with details', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ + details: 'Invalid working directory', + }), + }); + + await expect(service.createSession(mockSessionData)).rejects.toThrow( + 'Invalid working directory' + ); + }); + + it('should handle error response with error field', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ + error: 'Internal server error', + }), + }); + + await expect(service.createSession(mockSessionData)).rejects.toThrow('Internal server error'); + }); + + it('should handle error response with unknown format', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({}), + }); + + await expect(service.createSession(mockSessionData)).rejects.toThrow('Unknown error'); + }); + + it('should handle network errors', async () => { + fetchMock.mockRejectedValueOnce(new Error('Network failure')); + + await expect(service.createSession(mockSessionData)).rejects.toThrow('Network failure'); + }); + + it('should handle JSON parsing errors', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => { + throw new Error('Invalid JSON'); + }, + }); + + await expect(service.createSession(mockSessionData)).rejects.toThrow('Invalid JSON'); + }); + + it('should handle minimal session data', async () => { + const minimalData: SessionCreateData = { + command: ['zsh'], + workingDir: '~/', + }; + + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ sessionId: 'minimal-123' }), + }); + + const result = await service.createSession(minimalData); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/sessions', + expect.objectContaining({ + body: JSON.stringify(minimalData), + }) + ); + expect(result.sessionId).toBe('minimal-123'); + }); + + it('should serialize all session properties correctly', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ sessionId: 'test' }), + }); + + await service.createSession(mockSessionData); + + const calledBody = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(calledBody).toEqual({ + command: ['npm', 'run', 'dev'], + workingDir: '/home/user/project', + name: 'Test Session', + spawn_terminal: false, + cols: 120, + rows: 30, + titleMode: TitleMode.DYNAMIC, + }); + }); + + it('should re-throw existing Error instances', async () => { + const customError = new Error('Custom error message'); + fetchMock.mockRejectedValueOnce(customError); + + await expect(service.createSession(mockSessionData)).rejects.toThrow('Custom error message'); + }); + + it('should wrap non-Error exceptions', async () => { + fetchMock.mockRejectedValueOnce('String error'); + + await expect(service.createSession(mockSessionData)).rejects.toThrow( + 'Failed to create session' + ); + }); + }); +}); diff --git a/web/src/client/services/session-service.ts b/web/src/client/services/session-service.ts new file mode 100644 index 00000000..c938c8af --- /dev/null +++ b/web/src/client/services/session-service.ts @@ -0,0 +1,72 @@ +import type { TitleMode } from '../../shared/types.js'; +import { createLogger } from '../utils/logger.js'; +import type { AuthClient } from './auth-client.js'; + +const logger = createLogger('session-service'); + +export interface SessionCreateData { + command: string[]; + workingDir: string; + name?: string; + spawn_terminal?: boolean; + cols?: number; + rows?: number; + titleMode?: TitleMode; +} + +export interface SessionCreateResult { + sessionId: string; + message?: string; +} + +export interface SessionCreateError { + error?: string; + details?: string; +} + +export class SessionService { + private authClient: AuthClient; + + constructor(authClient: AuthClient) { + this.authClient = authClient; + } + + /** + * Create a new terminal session + * @param sessionData The session configuration + * @returns Promise with the created session result + * @throws Error if the session creation fails + */ + async createSession(sessionData: SessionCreateData): Promise { + try { + const response = await fetch('/api/sessions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.authClient.getAuthHeader(), + }, + body: JSON.stringify(sessionData), + }); + + if (response.ok) { + const result = await response.json(); + logger.log('Session created successfully:', result.sessionId); + return result; + } else { + const error: SessionCreateError = await response.json(); + // Use the detailed error message if available, otherwise fall back to the error field + const errorMessage = error.details || error.error || 'Unknown error'; + logger.error('Failed to create session:', errorMessage); + throw new Error(errorMessage); + } + } catch (error) { + // Re-throw if it's already an Error with a message + if (error instanceof Error && error.message) { + throw error; + } + // Otherwise wrap it + logger.error('Error creating session:', error); + throw new Error('Failed to create session'); + } + } +} diff --git a/web/src/client/utils/command-utils.test.ts b/web/src/client/utils/command-utils.test.ts new file mode 100644 index 00000000..02525aaa --- /dev/null +++ b/web/src/client/utils/command-utils.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { formatCommand, parseCommand } from './command-utils'; + +describe('command-utils', () => { + describe('parseCommand', () => { + it('should parse simple commands', () => { + expect(parseCommand('ls')).toEqual(['ls']); + expect(parseCommand('ls -la')).toEqual(['ls', '-la']); + expect(parseCommand('npm run dev')).toEqual(['npm', 'run', 'dev']); + }); + + it('should handle double quotes', () => { + expect(parseCommand('echo "hello world"')).toEqual(['echo', 'hello world']); + expect(parseCommand('git commit -m "Initial commit"')).toEqual([ + 'git', + 'commit', + '-m', + 'Initial commit', + ]); + }); + + it('should handle single quotes', () => { + expect(parseCommand("echo 'hello world'")).toEqual(['echo', 'hello world']); + expect(parseCommand("ls -la '/my path/with spaces'")).toEqual([ + 'ls', + '-la', + '/my path/with spaces', + ]); + }); + + it('should handle mixed quotes', () => { + expect(parseCommand(`echo "It's working"`)).toEqual(['echo', "It's working"]); + expect(parseCommand(`echo 'He said "hello"'`)).toEqual(['echo', 'He said "hello"']); + }); + + it('should handle multiple spaces', () => { + expect(parseCommand('ls -la')).toEqual(['ls', '-la']); + expect(parseCommand(' npm run dev ')).toEqual(['npm', 'run', 'dev']); + }); + + it('should handle empty strings', () => { + expect(parseCommand('')).toEqual([]); + expect(parseCommand(' ')).toEqual([]); + }); + + it('should handle commands with paths', () => { + expect(parseCommand('/usr/bin/python3 script.py')).toEqual(['/usr/bin/python3', 'script.py']); + expect(parseCommand('cd "/Users/john/My Documents"')).toEqual([ + 'cd', + '/Users/john/My Documents', + ]); + }); + + it('should handle complex commands', () => { + expect(parseCommand('docker run -it --rm -v "/home/user:/app" node:latest npm test')).toEqual( + ['docker', 'run', '-it', '--rm', '-v', '/home/user:/app', 'node:latest', 'npm', 'test'] + ); + }); + + it('should handle quotes at the beginning and end', () => { + expect(parseCommand('"quoted command"')).toEqual(['quoted command']); + expect(parseCommand("'single quoted'")).toEqual(['single quoted']); + }); + + it('should handle unclosed quotes by including them literally', () => { + expect(parseCommand('echo "unclosed')).toEqual(['echo', 'unclosed']); + expect(parseCommand("echo 'unclosed")).toEqual(['echo', 'unclosed']); + }); + + it('should handle environment variables', () => { + expect(parseCommand('FOO=bar BAZ="with spaces" npm test')).toEqual([ + 'FOO=bar', + 'BAZ=with spaces', + 'npm', + 'test', + ]); + }); + }); + + describe('formatCommand', () => { + it('should format simple commands', () => { + expect(formatCommand(['ls'])).toBe('ls'); + expect(formatCommand(['ls', '-la'])).toBe('ls -la'); + expect(formatCommand(['npm', 'run', 'dev'])).toBe('npm run dev'); + }); + + it('should add quotes to arguments with spaces', () => { + expect(formatCommand(['echo', 'hello world'])).toBe('echo "hello world"'); + expect(formatCommand(['cd', '/my path/with spaces'])).toBe('cd "/my path/with spaces"'); + }); + + it('should escape double quotes in arguments', () => { + expect(formatCommand(['echo', 'He said "hello"'])).toBe('echo "He said \\"hello\\""'); + expect(formatCommand(['echo', '"quoted"'])).toBe('echo "quoted"'); // No spaces, no quotes added + }); + + it('should handle empty arrays', () => { + expect(formatCommand([])).toBe(''); + }); + + it('should handle single arguments with spaces', () => { + expect(formatCommand(['my command'])).toBe('"my command"'); + }); + + it('should not quote arguments without spaces', () => { + expect(formatCommand(['--option=value'])).toBe('--option=value'); + expect(formatCommand(['-v', '/home/user:/app'])).toBe('-v /home/user:/app'); + }); + + it('should handle complex commands', () => { + expect( + formatCommand([ + 'docker', + 'run', + '-it', + '--rm', + '-v', + '/home/user:/app', + 'node:latest', + 'npm', + 'test', + ]) + ).toBe('docker run -it --rm -v /home/user:/app node:latest npm test'); + }); + + it('should round-trip with parseCommand', () => { + const commands = [ + 'ls -la', + 'echo "hello world"', + 'git commit -m "Initial commit"', + 'cd "/Users/john/My Documents"', + ]; + + commands.forEach((cmd) => { + const parsed = parseCommand(cmd); + const formatted = formatCommand(parsed); + const reparsed = parseCommand(formatted); + expect(reparsed).toEqual(parsed); + }); + }); + }); +}); diff --git a/web/src/client/utils/command-utils.ts b/web/src/client/utils/command-utils.ts new file mode 100644 index 00000000..3821c55a --- /dev/null +++ b/web/src/client/utils/command-utils.ts @@ -0,0 +1,67 @@ +/** + * Parse a command string into an array of arguments + * Handles quoted strings properly (both single and double quotes) + * + * @param commandStr The command string to parse + * @returns Array of parsed arguments + * + * @example + * parseCommand('echo "hello world"') // ['echo', 'hello world'] + * parseCommand("ls -la '/my path'") // ['ls', '-la', '/my path'] + */ +export function parseCommand(commandStr: string): string[] { + // Simple command parsing - split by spaces but respect quotes + const args: string[] = []; + let current = ''; + let inQuotes = false; + let quoteChar = ''; + + for (let i = 0; i < commandStr.length; i++) { + const char = commandStr[i]; + + if ((char === '"' || char === "'") && !inQuotes) { + inQuotes = true; + quoteChar = char; + } else if (char === quoteChar && inQuotes) { + inQuotes = false; + quoteChar = ''; + } else if (char === ' ' && !inQuotes) { + if (current) { + args.push(current); + current = ''; + } + } else { + current += char; + } + } + + if (current) { + args.push(current); + } + + return args; +} + +/** + * Format a command array back into a string + * Adds quotes around arguments that contain spaces + * + * @param command Array of command arguments + * @returns Formatted command string + * + * @example + * formatCommand(['echo', 'hello world']) // 'echo "hello world"' + * formatCommand(['ls', '-la', '/my path']) // 'ls -la "/my path"' + */ +export function formatCommand(command: string[]): string { + return command + .map((arg) => { + // Add quotes if the argument contains spaces + if (arg.includes(' ')) { + // Use double quotes and escape any existing double quotes + return `"${arg.replace(/"/g, '\\"')}"`; + } + return arg; + }) + .join(' '); +} diff --git a/web/src/client/utils/storage-utils.test.ts b/web/src/client/utils/storage-utils.test.ts new file mode 100644 index 00000000..25e82ed3 --- /dev/null +++ b/web/src/client/utils/storage-utils.test.ts @@ -0,0 +1,233 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TitleMode } from '../../shared/types'; +import { + getSessionFormValue, + loadSessionFormData, + removeSessionFormValue, + SESSION_FORM_STORAGE_KEYS, + saveSessionFormData, + setSessionFormValue, +} from './storage-utils'; + +describe('storage-utils', () => { + let mockStorage: { [key: string]: string }; + + beforeEach(() => { + // Create a mock localStorage that persists between calls + mockStorage = {}; + + Object.defineProperty(window, 'localStorage', { + value: { + getItem: vi.fn((key: string) => mockStorage[key] || null), + setItem: vi.fn((key: string, value: string) => { + mockStorage[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete mockStorage[key]; + }), + clear: vi.fn(() => { + mockStorage = {}; + }), + }, + writable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('loadSessionFormData', () => { + it('should return empty object when localStorage is empty', () => { + const result = loadSessionFormData(); + expect(result).toEqual({}); + }); + + it('should load all stored values correctly', () => { + mockStorage[SESSION_FORM_STORAGE_KEYS.WORKING_DIR] = '/home/user/projects'; + mockStorage[SESSION_FORM_STORAGE_KEYS.COMMAND] = 'npm run dev'; + mockStorage[SESSION_FORM_STORAGE_KEYS.SPAWN_WINDOW] = 'true'; + mockStorage[SESSION_FORM_STORAGE_KEYS.TITLE_MODE] = TitleMode.DYNAMIC; + + const result = loadSessionFormData(); + + expect(result).toEqual({ + workingDir: '/home/user/projects', + command: 'npm run dev', + spawnWindow: true, + titleMode: TitleMode.DYNAMIC, + }); + }); + + it('should handle false spawn window value', () => { + mockStorage[SESSION_FORM_STORAGE_KEYS.SPAWN_WINDOW] = 'false'; + + const result = loadSessionFormData(); + expect(result.spawnWindow).toBe(false); + }); + + it('should return undefined for missing values', () => { + mockStorage[SESSION_FORM_STORAGE_KEYS.WORKING_DIR] = '/home/user'; + + const result = loadSessionFormData(); + expect(result.workingDir).toBe('/home/user'); + expect(result.command).toBeUndefined(); + expect(result.spawnWindow).toBeUndefined(); + expect(result.titleMode).toBeUndefined(); + }); + + it('should handle localStorage errors gracefully', () => { + window.localStorage.getItem = vi.fn(() => { + throw new Error('Storage error'); + }); + + const result = loadSessionFormData(); + expect(result).toEqual({}); + }); + }); + + describe('saveSessionFormData', () => { + it('should save all provided values', () => { + saveSessionFormData({ + workingDir: '/projects', + command: 'zsh', + spawnWindow: true, + titleMode: TitleMode.STATIC, + }); + + expect(window.localStorage.setItem).toHaveBeenCalledWith( + SESSION_FORM_STORAGE_KEYS.WORKING_DIR, + '/projects' + ); + expect(window.localStorage.setItem).toHaveBeenCalledWith( + SESSION_FORM_STORAGE_KEYS.COMMAND, + 'zsh' + ); + expect(window.localStorage.setItem).toHaveBeenCalledWith( + SESSION_FORM_STORAGE_KEYS.SPAWN_WINDOW, + 'true' + ); + expect(window.localStorage.setItem).toHaveBeenCalledWith( + SESSION_FORM_STORAGE_KEYS.TITLE_MODE, + TitleMode.STATIC + ); + }); + + it('should only save non-empty values', () => { + saveSessionFormData({ + workingDir: '', + command: 'bash', + spawnWindow: false, + }); + + expect(window.localStorage.setItem).not.toHaveBeenCalledWith( + SESSION_FORM_STORAGE_KEYS.WORKING_DIR, + '' + ); + expect(window.localStorage.setItem).toHaveBeenCalledWith( + SESSION_FORM_STORAGE_KEYS.COMMAND, + 'bash' + ); + expect(window.localStorage.setItem).toHaveBeenCalledWith( + SESSION_FORM_STORAGE_KEYS.SPAWN_WINDOW, + 'false' + ); + }); + + it('should handle undefined values', () => { + saveSessionFormData({ + workingDir: '/home', + }); + + expect(window.localStorage.setItem).toHaveBeenCalledWith( + SESSION_FORM_STORAGE_KEYS.WORKING_DIR, + '/home' + ); + expect(window.localStorage.setItem).toHaveBeenCalledTimes(1); + }); + + it('should handle localStorage errors gracefully', () => { + window.localStorage.setItem = vi.fn(() => { + throw new Error('Storage error'); + }); + + // Should not throw + expect(() => { + saveSessionFormData({ workingDir: '/test' }); + }).not.toThrow(); + }); + }); + + describe('getSessionFormValue', () => { + it('should get specific values from localStorage', () => { + mockStorage[SESSION_FORM_STORAGE_KEYS.COMMAND] = 'python3'; + + const result = getSessionFormValue('COMMAND'); + expect(result).toBe('python3'); + }); + + it('should return null for missing values', () => { + const result = getSessionFormValue('WORKING_DIR'); + expect(result).toBeNull(); + }); + + it('should handle localStorage errors gracefully', () => { + window.localStorage.getItem = vi.fn(() => { + throw new Error('Storage error'); + }); + + const result = getSessionFormValue('COMMAND'); + expect(result).toBeNull(); + }); + }); + + describe('setSessionFormValue', () => { + it('should set specific values in localStorage', () => { + setSessionFormValue('SPAWN_WINDOW', 'true'); + + expect(window.localStorage.setItem).toHaveBeenCalledWith( + SESSION_FORM_STORAGE_KEYS.SPAWN_WINDOW, + 'true' + ); + expect(mockStorage[SESSION_FORM_STORAGE_KEYS.SPAWN_WINDOW]).toBe('true'); + }); + + it('should handle localStorage errors gracefully', () => { + window.localStorage.setItem = vi.fn(() => { + throw new Error('Storage error'); + }); + + // Should not throw + expect(() => { + setSessionFormValue('TITLE_MODE', TitleMode.FILTER); + }).not.toThrow(); + }); + }); + + describe('removeSessionFormValue', () => { + it('should remove specific values from localStorage', () => { + mockStorage[SESSION_FORM_STORAGE_KEYS.SPAWN_WINDOW] = 'true'; + + removeSessionFormValue('SPAWN_WINDOW'); + + expect(window.localStorage.removeItem).toHaveBeenCalledWith( + SESSION_FORM_STORAGE_KEYS.SPAWN_WINDOW + ); + expect(mockStorage[SESSION_FORM_STORAGE_KEYS.SPAWN_WINDOW]).toBeUndefined(); + }); + + it('should handle localStorage errors gracefully', () => { + window.localStorage.removeItem = vi.fn(() => { + throw new Error('Storage error'); + }); + + // Should not throw + expect(() => { + removeSessionFormValue('COMMAND'); + }).not.toThrow(); + }); + }); +}); diff --git a/web/src/client/utils/storage-utils.ts b/web/src/client/utils/storage-utils.ts new file mode 100644 index 00000000..3e10df27 --- /dev/null +++ b/web/src/client/utils/storage-utils.ts @@ -0,0 +1,110 @@ +import type { TitleMode } from '../../shared/types.js'; +import { createLogger } from './logger.js'; + +const logger = createLogger('storage-utils'); + +/** + * Storage keys for session creation form + */ +export const SESSION_FORM_STORAGE_KEYS = { + WORKING_DIR: 'vibetunnel_last_working_dir', + COMMAND: 'vibetunnel_last_command', + SPAWN_WINDOW: 'vibetunnel_spawn_window', + TITLE_MODE: 'vibetunnel_title_mode', +} as const; + +/** + * Session form data stored in localStorage + */ +export interface SessionFormData { + workingDir?: string; + command?: string; + spawnWindow?: boolean; + titleMode?: TitleMode; +} + +/** + * Load session form data from localStorage + */ +export function loadSessionFormData(): SessionFormData { + try { + const workingDir = localStorage.getItem(SESSION_FORM_STORAGE_KEYS.WORKING_DIR) || undefined; + const command = localStorage.getItem(SESSION_FORM_STORAGE_KEYS.COMMAND) || undefined; + const spawnWindowStr = localStorage.getItem(SESSION_FORM_STORAGE_KEYS.SPAWN_WINDOW); + const titleModeStr = localStorage.getItem(SESSION_FORM_STORAGE_KEYS.TITLE_MODE); + + return { + workingDir, + command, + spawnWindow: spawnWindowStr !== null ? spawnWindowStr === 'true' : undefined, + titleMode: titleModeStr ? (titleModeStr as TitleMode) : undefined, + }; + } catch (error) { + logger.warn('Failed to load from localStorage:', error); + return {}; + } +} + +/** + * Save session form data to localStorage + */ +export function saveSessionFormData(data: SessionFormData): void { + try { + // Only save non-empty values + if (data.workingDir) { + localStorage.setItem(SESSION_FORM_STORAGE_KEYS.WORKING_DIR, data.workingDir); + } + if (data.command) { + localStorage.setItem(SESSION_FORM_STORAGE_KEYS.COMMAND, data.command); + } + if (data.spawnWindow !== undefined) { + localStorage.setItem(SESSION_FORM_STORAGE_KEYS.SPAWN_WINDOW, String(data.spawnWindow)); + } + if (data.titleMode !== undefined) { + localStorage.setItem(SESSION_FORM_STORAGE_KEYS.TITLE_MODE, data.titleMode); + } + } catch (error) { + logger.warn('Failed to save to localStorage:', error); + } +} + +/** + * Get a single value from localStorage + */ +export function getSessionFormValue( + key: K +): string | null { + try { + return localStorage.getItem(SESSION_FORM_STORAGE_KEYS[key]); + } catch (error) { + logger.warn(`Failed to get ${key} from localStorage:`, error); + return null; + } +} + +/** + * Set a single value in localStorage + */ +export function setSessionFormValue( + key: K, + value: string +): void { + try { + localStorage.setItem(SESSION_FORM_STORAGE_KEYS[key], value); + } catch (error) { + logger.warn(`Failed to set ${key} in localStorage:`, error); + } +} + +/** + * Remove a single value from localStorage + */ +export function removeSessionFormValue( + key: K +): void { + try { + localStorage.removeItem(SESSION_FORM_STORAGE_KEYS[key]); + } catch (error) { + logger.warn(`Failed to remove ${key} from localStorage:`, error); + } +} diff --git a/web/src/client/utils/title-mode-utils.test.ts b/web/src/client/utils/title-mode-utils.test.ts new file mode 100644 index 00000000..c1118b8a --- /dev/null +++ b/web/src/client/utils/title-mode-utils.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { TitleMode } from '../../shared/types'; +import { getTitleModeDescription, getTitleModeDisplayName } from './title-mode-utils'; + +describe('title-mode-utils', () => { + describe('getTitleModeDescription', () => { + it('should return correct description for NONE mode', () => { + expect(getTitleModeDescription(TitleMode.NONE)).toBe('Apps control their own titles'); + }); + + it('should return correct description for FILTER mode', () => { + expect(getTitleModeDescription(TitleMode.FILTER)).toBe('Blocks all title changes'); + }); + + it('should return correct description for STATIC mode', () => { + expect(getTitleModeDescription(TitleMode.STATIC)).toBe('Shows path and command'); + }); + + it('should return correct description for DYNAMIC mode', () => { + expect(getTitleModeDescription(TitleMode.DYNAMIC)).toBe('○ idle ● active ▶ running'); + }); + + it('should return empty string for unknown mode', () => { + // @ts-expect-error Testing invalid input + expect(getTitleModeDescription('UNKNOWN')).toBe(''); + }); + + it('should handle all TitleMode enum values', () => { + // Ensure all enum values are covered + Object.values(TitleMode).forEach((mode) => { + const description = getTitleModeDescription(mode); + expect(description).toBeTruthy(); + expect(typeof description).toBe('string'); + }); + }); + }); + + describe('getTitleModeDisplayName', () => { + it('should return correct display name for NONE mode', () => { + expect(getTitleModeDisplayName(TitleMode.NONE)).toBe('None'); + }); + + it('should return correct display name for FILTER mode', () => { + expect(getTitleModeDisplayName(TitleMode.FILTER)).toBe('Filter'); + }); + + it('should return correct display name for STATIC mode', () => { + expect(getTitleModeDisplayName(TitleMode.STATIC)).toBe('Static'); + }); + + it('should return correct display name for DYNAMIC mode', () => { + expect(getTitleModeDisplayName(TitleMode.DYNAMIC)).toBe('Dynamic'); + }); + + it('should return the input value for unknown mode', () => { + // @ts-expect-error Testing invalid input + expect(getTitleModeDisplayName('CUSTOM_MODE')).toBe('CUSTOM_MODE'); + }); + + it('should handle all TitleMode enum values', () => { + // Ensure all enum values are covered + Object.values(TitleMode).forEach((mode) => { + const displayName = getTitleModeDisplayName(mode); + expect(displayName).toBeTruthy(); + expect(typeof displayName).toBe('string'); + // Display names should start with uppercase + expect(displayName[0]).toBe(displayName[0].toUpperCase()); + }); + }); + }); + + describe('consistency between functions', () => { + it('should have descriptions for all modes that have display names', () => { + Object.values(TitleMode).forEach((mode) => { + const displayName = getTitleModeDisplayName(mode); + const description = getTitleModeDescription(mode); + + // If we have a display name, we should have a description + if (displayName && displayName !== mode) { + expect(description).toBeTruthy(); + expect(description.length).toBeGreaterThan(0); + } + }); + }); + }); +}); diff --git a/web/src/client/utils/title-mode-utils.ts b/web/src/client/utils/title-mode-utils.ts new file mode 100644 index 00000000..40ceb263 --- /dev/null +++ b/web/src/client/utils/title-mode-utils.ts @@ -0,0 +1,41 @@ +import { TitleMode } from '../../shared/types.js'; + +/** + * Get a human-readable description for a title mode + * @param titleMode The title mode to describe + * @returns Description of what the title mode does + */ +export function getTitleModeDescription(titleMode: TitleMode): string { + switch (titleMode) { + case TitleMode.NONE: + return 'Apps control their own titles'; + case TitleMode.FILTER: + return 'Blocks all title changes'; + case TitleMode.STATIC: + return 'Shows path and command'; + case TitleMode.DYNAMIC: + return '○ idle ● active ▶ running'; + default: + return ''; + } +} + +/** + * Get display name for a title mode + * @param titleMode The title mode + * @returns Display name for UI + */ +export function getTitleModeDisplayName(titleMode: TitleMode): string { + switch (titleMode) { + case TitleMode.NONE: + return 'None'; + case TitleMode.FILTER: + return 'Filter'; + case TitleMode.STATIC: + return 'Static'; + case TitleMode.DYNAMIC: + return 'Dynamic'; + default: + return titleMode; + } +} diff --git a/web/src/server/routes/filesystem.ts b/web/src/server/routes/filesystem.ts index 22dd6dc4..365cd477 100644 --- a/web/src/server/routes/filesystem.ts +++ b/web/src/server/routes/filesystem.ts @@ -618,6 +618,135 @@ export function createFilesystemRoutes(): Router { } }); + // Path completions endpoint for autocomplete + router.get('/fs/completions', async (req: Request, res: Response) => { + try { + const originalPath = (req.query.path as string) || ''; + let partialPath = originalPath; + + // Handle tilde expansion for home directory + if (partialPath === '~' || partialPath.startsWith('~/')) { + const homeDir = process.env.HOME || process.env.USERPROFILE; + if (!homeDir) { + logger.error('unable to determine home directory for completions'); + return res.status(500).json({ error: 'Unable to determine home directory' }); + } + partialPath = partialPath === '~' ? homeDir : path.join(homeDir, partialPath.slice(2)); + } + + // Separate directory and partial name + let dirPath: string; + let partialName: string; + + if (partialPath.endsWith('/')) { + // If path ends with slash, list contents of that directory + dirPath = partialPath; + partialName = ''; + } else { + // Otherwise, get the directory and partial filename + dirPath = path.dirname(partialPath); + partialName = path.basename(partialPath); + } + + // Resolve the directory path + const fullDirPath = path.resolve(dirPath); + + // Security check + if (!isPathSafe(fullDirPath, '/')) { + logger.warn(`access denied for path completions: ${fullDirPath}`); + return res.status(403).json({ error: 'Access denied' }); + } + + // Check if directory exists + let dirStats: Awaited>; + try { + dirStats = await fs.stat(fullDirPath); + if (!dirStats.isDirectory()) { + return res.json({ completions: [] }); + } + } catch { + // Directory doesn't exist, return empty completions + return res.json({ completions: [] }); + } + + // Read directory contents + const entries = await fs.readdir(fullDirPath, { withFileTypes: true }); + + // Filter and map entries + const mappedEntries = await Promise.all( + entries + .filter((entry) => { + // Filter by partial name (case-insensitive) + if (partialName && !entry.name.toLowerCase().startsWith(partialName.toLowerCase())) { + return false; + } + // Optionally hide hidden files unless the partial name starts with '.' + if (!partialName.startsWith('.') && entry.name.startsWith('.')) { + return false; + } + return true; + }) + .map(async (entry) => { + const isDirectory = entry.isDirectory(); + const entryPath = path.join(fullDirPath, entry.name); + + // Build the suggestion path based on the original input + let displayPath: string; + if (originalPath.endsWith('/')) { + displayPath = originalPath + entry.name; + } else { + const lastSlash = originalPath.lastIndexOf('/'); + if (lastSlash >= 0) { + displayPath = originalPath.substring(0, lastSlash + 1) + entry.name; + } else { + displayPath = entry.name; + } + } + + // Check if this directory is a git repository + let isGitRepo = false; + if (isDirectory) { + try { + await fs.stat(path.join(entryPath, '.git')); + isGitRepo = true; + } catch { + // Not a git repository + } + } + + return { + name: entry.name, + path: displayPath, + type: isDirectory ? 'directory' : 'file', + // Add trailing slash for directories + suggestion: isDirectory ? `${displayPath}/` : displayPath, + isRepository: isGitRepo, + }; + }) + ); + + const completions = mappedEntries + .sort((a, b) => { + // Sort directories first, then by name + if (a.type !== b.type) { + return a.type === 'directory' ? -1 : 1; + } + return a.name.localeCompare(b.name); + }) + .slice(0, 20); // Limit to 20 suggestions + + logger.debug(`path completions for "${originalPath}": ${completions.length} results`); + + res.json({ + completions, + partialPath: originalPath, + }); + } catch (error) { + logger.error(`failed to get path completions for ${req.query.path}:`, error); + res.status(500).json({ error: error instanceof Error ? error.message : String(error) }); + } + }); + return router; } diff --git a/web/src/test/client/components/autocomplete-manager.test.ts b/web/src/test/client/components/autocomplete-manager.test.ts new file mode 100644 index 00000000..19b7296c --- /dev/null +++ b/web/src/test/client/components/autocomplete-manager.test.ts @@ -0,0 +1,456 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Repository } from '../../../client/components/autocomplete-manager.js'; +import { AutocompleteManager } from '../../../client/components/autocomplete-manager.js'; +import type { AuthClient } from '../../../client/services/auth-client.js'; + +// Mock the global fetch +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe('AutocompleteManager', () => { + let manager: AutocompleteManager; + let mockAuthClient: AuthClient; + + beforeEach(() => { + // Mock auth client + mockAuthClient = { + getAuthHeader: vi.fn().mockReturnValue({ Authorization: 'Bearer test-token' }), + } as unknown as AuthClient; + + manager = new AutocompleteManager(mockAuthClient); + mockFetch.mockClear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('fetchCompletions', () => { + it('should return empty array for empty path', async () => { + const result = await manager.fetchCompletions(''); + expect(result).toEqual([]); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should fetch filesystem completions from API', async () => { + const mockCompletions = [ + { + name: 'Documents', + path: '~/Documents', + type: 'directory' as const, + suggestion: '~/Documents/', + }, + { + name: 'Downloads', + path: '~/Downloads', + type: 'directory' as const, + suggestion: '~/Downloads/', + }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ completions: mockCompletions }), + }); + + const result = await manager.fetchCompletions('~/Do'); + + expect(mockFetch).toHaveBeenCalledWith( + `/api/fs/completions?path=${encodeURIComponent('~/Do')}`, + { + headers: { Authorization: 'Bearer test-token' }, + } + ); + expect(result).toHaveLength(2); + expect(result[0].name).toBe('Documents'); + }); + + it('should handle API errors gracefully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + const result = await manager.fetchCompletions('~/test'); + + expect(result).toEqual([]); + }); + + it('should search repositories by partial name', async () => { + const repositories: Repository[] = [ + { + id: '1', + path: '/Users/test/Projects/vibetunnel', + folderName: 'vibetunnel', + lastModified: '2024-01-01', + relativePath: '~/Projects/vibetunnel', + }, + { + id: '2', + path: '/Users/test/Projects/vibetunnel2', + folderName: 'vibetunnel2', + lastModified: '2024-01-02', + relativePath: '~/Projects/vibetunnel2', + }, + { + id: '3', + path: '/Users/test/Projects/other-project', + folderName: 'other-project', + lastModified: '2024-01-03', + relativePath: '~/Projects/other-project', + }, + ]; + + manager.setRepositories(repositories); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ completions: [] }), + }); + + const result = await manager.fetchCompletions('vibe'); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('vibetunnel'); + expect(result[0].isRepository).toBe(true); + expect(result[1].name).toBe('vibetunnel2'); + }); + + it('should merge filesystem and repository completions without duplicates', async () => { + const repositories: Repository[] = [ + { + id: '1', + path: '/Users/test/Projects/myapp', + folderName: 'myapp', + lastModified: '2024-01-01', + relativePath: '~/Projects/myapp', + }, + ]; + + manager.setRepositories(repositories); + + const mockCompletions = [ + { + name: 'myapp', + path: '~/Projects/myapp', + type: 'directory' as const, + suggestion: '/Users/test/Projects/myapp', + isRepository: true, + }, + { + name: 'myapp-docs', + path: '~/Projects/myapp-docs', + type: 'directory' as const, + suggestion: '~/Projects/myapp-docs/', + }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ completions: mockCompletions }), + }); + + const result = await manager.fetchCompletions('myapp'); + + // Should not have duplicates + const uniqueSuggestions = new Set(result.map((r) => r.suggestion)); + expect(uniqueSuggestions.size).toBe(result.length); + }); + }); + + describe('sortCompletions', () => { + it('should prioritize exact name matches', async () => { + const mockCompletions = [ + { + name: 'project-test', + path: '~/project-test', + type: 'directory' as const, + suggestion: '~/project-test/', + }, + { + name: 'test', + path: '~/test', + type: 'directory' as const, + suggestion: '~/test/', + }, + { + name: 'testing', + path: '~/testing', + type: 'directory' as const, + suggestion: '~/testing/', + }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ completions: mockCompletions }), + }); + + const result = await manager.fetchCompletions('~/test'); + + expect(result[0].name).toBe('test'); // Exact match should be first + }); + + it('should prioritize items that start with search term', async () => { + const mockCompletions = [ + { + name: 'myproject', + path: '~/myproject', + type: 'directory' as const, + suggestion: '~/myproject/', + }, + { + name: 'project', + path: '~/project', + type: 'directory' as const, + suggestion: '~/project/', + }, + { + name: 'proj', + path: '~/proj', + type: 'directory' as const, + suggestion: '~/proj/', + }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ completions: mockCompletions }), + }); + + const result = await manager.fetchCompletions('~/proj'); + + expect(result[0].name).toBe('proj'); + expect(result[1].name).toBe('project'); + }); + + it('should prioritize directories over files', async () => { + const mockCompletions = [ + { + name: 'readme.md', + path: '~/readme.md', + type: 'file' as const, + suggestion: '~/readme.md', + }, + { + name: 'Documents', + path: '~/Documents', + type: 'directory' as const, + suggestion: '~/Documents/', + }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ completions: mockCompletions }), + }); + + const result = await manager.fetchCompletions('~/'); + + expect(result[0].type).toBe('directory'); + expect(result[1].type).toBe('file'); + }); + + it('should prioritize git repositories over regular directories', async () => { + const mockCompletions = [ + { + name: 'regular-folder', + path: '~/regular-folder', + type: 'directory' as const, + suggestion: '~/regular-folder/', + isRepository: false, + }, + { + name: 'git-project', + path: '~/git-project', + type: 'directory' as const, + suggestion: '~/git-project/', + isRepository: true, + }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ completions: mockCompletions }), + }); + + const result = await manager.fetchCompletions('~/'); + + expect(result[0].isRepository).toBe(true); + expect(result[1].isRepository).toBeFalsy(); + }); + + it('should return sorted results', async () => { + const mockCompletions = [ + { + name: 'zebra', + path: '~/zebra', + type: 'directory' as const, + suggestion: '~/zebra/', + }, + { + name: 'apple', + path: '~/apple', + type: 'directory' as const, + suggestion: '~/apple/', + }, + { + name: 'banana', + path: '~/banana', + type: 'directory' as const, + suggestion: '~/banana/', + }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ completions: mockCompletions }), + }); + + const result = await manager.fetchCompletions('~/'); + + // Should be sorted alphabetically + expect(result[0].name).toBe('apple'); + expect(result[1].name).toBe('banana'); + expect(result[2].name).toBe('zebra'); + }); + + it('should limit results to 20 items', async () => { + // Create 25 mock completions + const mockCompletions = Array.from({ length: 25 }, (_, i) => ({ + name: `folder${i}`, + path: `~/folder${i}`, + type: 'directory' as const, + suggestion: `~/folder${i}/`, + })); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ completions: mockCompletions }), + }); + + const result = await manager.fetchCompletions('~/folder'); + + expect(result).toHaveLength(20); + }); + }); + + describe('filterCompletions', () => { + it('should filter completions by search term', () => { + const completions = [ + { + name: 'Documents', + path: '~/Documents', + type: 'directory' as const, + suggestion: '~/Documents/', + }, + { + name: 'Downloads', + path: '~/Downloads', + type: 'directory' as const, + suggestion: '~/Downloads/', + }, + { + name: 'Desktop', + path: '~/Desktop', + type: 'directory' as const, + suggestion: '~/Desktop/', + }, + ]; + + const result = manager.filterCompletions(completions, 'down'); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('Downloads'); + }); + + it('should match on both name and path', () => { + const completions = [ + { + name: 'project', + path: '~/Documents/work/project', + type: 'directory' as const, + suggestion: '~/Documents/work/project/', + }, + { + name: 'notes', + path: '~/work/notes', + type: 'directory' as const, + suggestion: '~/work/notes/', + }, + ]; + + const result = manager.filterCompletions(completions, 'work'); + + expect(result).toHaveLength(2); // Both match because 'work' is in their paths + }); + + it('should return all items when search term is empty', () => { + const completions = [ + { + name: 'folder1', + path: '~/folder1', + type: 'directory' as const, + suggestion: '~/folder1/', + }, + { + name: 'folder2', + path: '~/folder2', + type: 'directory' as const, + suggestion: '~/folder2/', + }, + ]; + + const result = manager.filterCompletions(completions, ''); + + expect(result).toHaveLength(2); + }); + }); + + describe('setAuthClient', () => { + it('should update auth client', async () => { + const newAuthClient = { + getAuthHeader: vi.fn().mockReturnValue({ Authorization: 'Bearer new-token' }), + } as unknown as AuthClient; + + manager.setAuthClient(newAuthClient); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ completions: [] }), + }); + + await manager.fetchCompletions('~/test'); + + expect(mockFetch).toHaveBeenCalledWith(expect.any(String), { + headers: { Authorization: 'Bearer new-token' }, + }); + }); + }); + + describe('setRepositories', () => { + it('should update repositories list', async () => { + const repositories: Repository[] = [ + { + id: '1', + path: '/Users/test/Projects/test-repo', + folderName: 'test-repo', + lastModified: '2024-01-01', + relativePath: '~/Projects/test-repo', + }, + ]; + + manager.setRepositories(repositories); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ completions: [] }), + }); + + const result = await manager.fetchCompletions('test'); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('test-repo'); + }); + }); +});