feat: Unified autocomplete for working directory in session creation (#435)

This commit is contained in:
Peter Steinberger 2025-07-20 21:11:21 +02:00 committed by GitHub
parent 690ac8fe48
commit b0b4e0b2e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 2251 additions and 219 deletions

View file

@ -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

View file

@ -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<AutocompleteItem[]> {
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);
});
}
}

View file

@ -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 {
<!-- Working Directory -->
<div class="mb-2 sm:mb-3 lg:mb-5">
<label class="form-label text-text-muted text-[10px] sm:text-xs lg:text-sm">Working Directory:</label>
<div class="flex gap-1.5 sm:gap-2">
<div class="relative">
<div class="flex gap-1.5 sm:gap-2">
<input
type="text"
class="input-field py-1.5 sm:py-2 lg:py-3 text-xs sm:text-sm"
class="input-field py-1.5 sm:py-2 lg:py-3 text-xs sm:text-sm flex-1"
.value=${this.workingDir}
@input=${this.handleWorkingDirChange}
@keydown=${this.handleWorkingDirKeydown}
@blur=${this.handleWorkingDirBlur}
placeholder="~/"
?disabled=${this.disabled || this.isCreating}
data-testid="working-dir-input"
autocomplete="off"
/>
<button
class="bg-bg-tertiary border border-border/50 rounded-lg p-1.5 sm:p-2 lg:p-3 font-mono text-text-muted transition-all duration-200 hover:text-primary hover:bg-surface-hover hover:border-primary/50 hover:shadow-sm flex-shrink-0"
@ -606,21 +621,72 @@ export class SessionCreateForm extends LitElement {
</button>
<button
class="bg-bg-tertiary border border-border/50 rounded-lg p-1.5 sm:p-2 lg:p-3 font-mono text-text-muted transition-all duration-200 hover:text-primary hover:bg-surface-hover hover:border-primary/50 hover:shadow-sm flex-shrink-0 ${
this.showRepositoryDropdown ? 'text-primary border-primary/50' : ''
this.showRepositoryDropdown || this.showCompletions
? 'text-primary border-primary/50'
: ''
}"
@click=${this.handleToggleRepositoryDropdown}
?disabled=${this.disabled || this.isCreating || this.repositories.length === 0 || this.isDiscovering}
title="Choose from repositories"
@click=${this.handleToggleAutocomplete}
?disabled=${this.disabled || this.isCreating}
title="Choose from repositories or recent directories"
type="button"
>
<svg width="12" height="12" class="sm:w-3.5 sm:h-3.5 lg:w-4 lg:h-4" viewBox="0 0 16 16" fill="currentColor">
<svg
width="12"
height="12"
class="sm:w-3.5 sm:h-3.5 lg:w-4 lg:h-4 transition-transform duration-200"
viewBox="0 0 16 16"
fill="currentColor"
style="transform: ${this.showRepositoryDropdown || this.showCompletions ? 'rotate(90deg)' : 'rotate(0deg)'}"
>
<path
d="M5.22 1.22a.75.75 0 011.06 0l6.25 6.25a.75.75 0 010 1.06l-6.25 6.25a.75.75 0 01-1.06-1.06L10.94 8 5.22 2.28a.75.75 0 010-1.06z"
transform=${this.showRepositoryDropdown ? 'rotate(90 8 8)' : ''}
/>
</svg>
</button>
</div>
${
this.showCompletions && this.completions.length > 0
? html`
<div class="absolute left-0 right-0 mt-1 bg-bg-elevated border border-border/50 rounded-lg overflow-hidden shadow-lg z-50">
<div class="max-h-48 sm:max-h-64 lg:max-h-80 overflow-y-auto">
${this.completions.map(
(completion, index) => html`
<button
@click=${() => this.handleSelectCompletion(completion.suggestion)}
class="w-full text-left px-3 py-2 hover:bg-surface-hover transition-colors duration-200 flex items-center gap-2 ${
index === this.selectedCompletionIndex
? 'bg-primary/20 border-l-2 border-primary'
: ''
}"
type="button"
>
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="currentColor"
class="${completion.isRepository ? 'text-primary' : 'text-text-muted'} flex-shrink-0"
>
${
completion.isRepository
? html`<path d="M4.177 7.823A4.5 4.5 0 118 12.5a4.474 4.474 0 01-1.653-.316.75.75 0 11.557-1.392 2.999 2.999 0 001.096.208 3 3 0 10-2.108-5.134.75.75 0 01.236.662l.428 3.009a.75.75 0 01-1.255.592L2.847 7.677a.75.75 0 01.426-1.27A4.476 4.476 0 014.177 7.823zM8 1a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 018 1zm3.197 2.197a.75.75 0 01.092.992l-1 1.25a.75.75 0 01-1.17-.938l1-1.25a.75.75 0 01.992-.092.75.75 0 01.086.038zM5.75 8a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 015.75 8zm5.447 2.197a.75.75 0 01.092.992l-1 1.25a.75.75 0 11-1.17-.938l1-1.25a.75.75 0 01.992-.092.75.75 0 01.086.038z" />`
: completion.type === 'directory'
? html`<path d="M1.75 1h5.5c.966 0 1.75.784 1.75 1.75v1h4c.966 0 1.75.784 1.75 1.75v7.75A1.75 1.75 0 0113 15H3a1.75 1.75 0 01-1.75-1.75V2.75C1.25 1.784 1.784 1 1.75 1zM2.75 2.5v10.75c0 .138.112.25.25.25h10a.25.25 0 00.25-.25V5.5a.25.25 0 00-.25-.25H8.75v-2.5a.25.25 0 00-.25-.25h-5.5a.25.25 0 00-.25.25z" />`
: html`<path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0113.25 16h-9.5A1.75 1.75 0 012 14.25V1.75zm1.75-.25a.25.25 0 00-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 00.25-.25V6h-2.75A1.75 1.75 0 019 4.25V1.5H3.75zm6.75.062V4.25c0 .138.112.25.25.25h2.688a.252.252 0 00-.011-.013l-2.914-2.914a.272.272 0 00-.013-.011z" />`
}
</svg>
<span class="text-text text-xs sm:text-sm truncate flex-1">
${completion.name}
</span>
<span class="text-text-muted text-[9px] sm:text-[10px] truncate max-w-[40%]">${completion.path}</span>
</button>
`
)}
</div>
</div>
`
: ''
}
${
this.showRepositoryDropdown && this.repositories.length > 0
? html`
@ -683,11 +749,11 @@ export class SessionCreateForm extends LitElement {
}
<!-- Terminal Title Mode -->
<div class="mb-2 sm:mb-4 lg:mb-6 flex items-center justify-between bg-bg-elevated border border-border/50 rounded-lg p-2 sm:p-3 lg:p-4">
<div class="${this.macAppConnected ? '' : 'mt-2 sm:mt-3 lg:mt-5'} mb-2 sm:mb-4 lg:mb-6 flex items-center justify-between bg-bg-elevated border border-border/50 rounded-lg p-2 sm:p-3 lg:p-4">
<div class="flex-1 pr-2 sm:pr-3 lg:pr-4">
<span class="text-primary text-[10px] sm:text-xs lg:text-sm font-medium">Terminal Title Mode</span>
<p class="text-[9px] sm:text-[10px] lg:text-xs text-text-muted mt-0.5 hidden sm:block">
${this.getTitleModeDescription()}
${getTitleModeDescription(this.titleMode)}
</p>
</div>
<div class="relative">

View file

@ -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 {
<!-- Repository Base Path -->
<div class="p-4 bg-tertiary rounded-lg border border-base">
<div class="mb-3">
<label class="text-primary font-medium">Repository Base Path</label>
<div class="flex items-center justify-between">
<label class="text-primary font-medium">Repository Base Path</label>
${
this.isDiscoveringRepositories
? html`<span class="text-muted text-xs">Scanning...</span>`
: html`<span class="text-muted text-xs">${this.repositoryCount} repositories found</span>`
}
</div>
<p class="text-muted text-xs mt-1">
${
this.isServerConfigured

View file

@ -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<typeof vi.fn>;
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' },
})
);
});
});
});

View file

@ -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<Repository[]> {
// 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 '~/';
}
}

View file

@ -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<typeof vi.fn>;
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'
);
});
});
});

View file

@ -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<SessionCreateResult> {
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');
}
}
}

View file

@ -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);
});
});
});
});

View file

@ -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(' ');
}

View file

@ -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();
});
});
});

View file

@ -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<K extends keyof typeof SESSION_FORM_STORAGE_KEYS>(
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<K extends keyof typeof SESSION_FORM_STORAGE_KEYS>(
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<K extends keyof typeof SESSION_FORM_STORAGE_KEYS>(
key: K
): void {
try {
localStorage.removeItem(SESSION_FORM_STORAGE_KEYS[key]);
} catch (error) {
logger.warn(`Failed to remove ${key} from localStorage:`, error);
}
}

View file

@ -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);
}
});
});
});
});

View file

@ -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;
}
}

View file

@ -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<ReturnType<typeof fs.stat>>;
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;
}

View file

@ -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');
});
});
});