mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
feat: Unified autocomplete for working directory in session creation (#435)
This commit is contained in:
parent
690ac8fe48
commit
b0b4e0b2e9
16 changed files with 2251 additions and 219 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
138
web/src/client/components/autocomplete-manager.ts
Normal file
138
web/src/client/components/autocomplete-manager.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
186
web/src/client/services/repository-service.test.ts
Normal file
186
web/src/client/services/repository-service.test.ts
Normal 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' },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
66
web/src/client/services/repository-service.ts
Normal file
66
web/src/client/services/repository-service.ts
Normal 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 '~/';
|
||||
}
|
||||
}
|
||||
197
web/src/client/services/session-service.test.ts
Normal file
197
web/src/client/services/session-service.test.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
72
web/src/client/services/session-service.ts
Normal file
72
web/src/client/services/session-service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
142
web/src/client/utils/command-utils.test.ts
Normal file
142
web/src/client/utils/command-utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
67
web/src/client/utils/command-utils.ts
Normal file
67
web/src/client/utils/command-utils.ts
Normal 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(' ');
|
||||
}
|
||||
233
web/src/client/utils/storage-utils.test.ts
Normal file
233
web/src/client/utils/storage-utils.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
110
web/src/client/utils/storage-utils.ts
Normal file
110
web/src/client/utils/storage-utils.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
86
web/src/client/utils/title-mode-utils.test.ts
Normal file
86
web/src/client/utils/title-mode-utils.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
41
web/src/client/utils/title-mode-utils.ts
Normal file
41
web/src/client/utils/title-mode-utils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
456
web/src/test/client/components/autocomplete-manager.test.ts
Normal file
456
web/src/test/client/components/autocomplete-manager.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue