diff --git a/web/src/client/components/session-create-form.ts b/web/src/client/components/session-create-form.ts index 93900d42..0f53f84b 100644 --- a/web/src/client/components/session-create-form.ts +++ b/web/src/client/components/session-create-form.ts @@ -18,6 +18,10 @@ import { TitleMode } from '../../shared/types.js'; import type { AuthClient } from '../services/auth-client.js'; import { createLogger } from '../utils/logger.js'; import type { Session } from './session-list.js'; +import { + STORAGE_KEY as APP_PREFERENCES_STORAGE_KEY, + type AppPreferences, +} from './unified-settings.js'; const logger = createLogger('session-create-form'); @@ -50,6 +54,15 @@ export class SessionCreateForm extends LitElement { @state() private isCreating = false; @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 isDiscovering = false; quickStartCommands = [ { label: 'claude', command: 'claude' }, @@ -110,8 +123,21 @@ export class SessionCreateForm extends LitElement { const savedSpawnWindow = localStorage.getItem(this.STORAGE_KEY_SPAWN_WINDOW); const savedTitleMode = localStorage.getItem(this.STORAGE_KEY_TITLE_MODE); + // 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 - this.workingDir = savedWorkingDir || '~/'; + // 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 @@ -181,6 +207,9 @@ export class SessionCreateForm extends LitElement { // Set data attributes for testing - both synchronously to avoid race conditions this.setAttribute('data-modal-state', 'open'); this.setAttribute('data-modal-rendered', 'true'); + + // Discover repositories + this.discoverRepositories(); } else { // Remove global keyboard listener when hidden document.removeEventListener('keydown', this.handleGlobalKeyDown); @@ -401,6 +430,52 @@ 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`); + } else { + logger.error('Failed to discover repositories'); + } + } catch (error) { + logger.error('Error discovering repositories:', error); + } finally { + this.isDiscovering = false; + } + } + + private handleToggleRepositoryDropdown() { + this.showRepositoryDropdown = !this.showRepositoryDropdown; + } + + private handleSelectRepository(repoPath: string) { + this.workingDir = repoPath; + this.showRepositoryDropdown = false; + } + render() { if (!this.visible) { return html``; @@ -494,7 +569,52 @@ export class SessionCreateForm extends LitElement { /> + + ${ + this.showRepositoryDropdown && this.repositories.length > 0 + ? html` +
+
+ ${this.repositories.map( + (repo) => html` + + ` + )} +
+
+ ` + : '' + } diff --git a/web/src/client/components/unified-settings.ts b/web/src/client/components/unified-settings.ts index d9497d11..3248759d 100644 --- a/web/src/client/components/unified-settings.ts +++ b/web/src/client/components/unified-settings.ts @@ -13,14 +13,16 @@ const logger = createLogger('unified-settings'); export interface AppPreferences { useDirectKeyboard: boolean; showLogLink: boolean; + repositoryBasePath: string; } const DEFAULT_APP_PREFERENCES: AppPreferences = { useDirectKeyboard: true, // Default to modern direct keyboard for new users showLogLink: false, + repositoryBasePath: '~/', }; -const STORAGE_KEY = 'vibetunnel_app_preferences'; +export const STORAGE_KEY = 'vibetunnel_app_preferences'; @customElement('unified-settings') export class UnifiedSettings extends LitElement { @@ -223,7 +225,7 @@ export class UnifiedSettings extends LitElement { pushNotificationService.savePreferences(this.notificationPreferences); } - private handleAppPreferenceChange(key: keyof AppPreferences, value: boolean) { + private handleAppPreferenceChange(key: keyof AppPreferences, value: boolean | string) { this.appPreferences = { ...this.appPreferences, [key]: value }; this.saveAppPreferences(); } @@ -508,6 +510,28 @@ export class UnifiedSettings extends LitElement { > + + +
+
+ +

+ Default directory for new sessions and repository discovery +

+
+
+ { + const input = e.target as HTMLInputElement; + this.handleAppPreferenceChange('repositoryBasePath', input.value); + }} + placeholder="~/" + class="input-field py-2 text-sm flex-1" + /> +
+
`; } diff --git a/web/src/server/routes/repositories.ts b/web/src/server/routes/repositories.ts new file mode 100644 index 00000000..2762ba42 --- /dev/null +++ b/web/src/server/routes/repositories.ts @@ -0,0 +1,139 @@ +import { Router } from 'express'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { createLogger } from '../utils/logger.js'; + +const logger = createLogger('repositories'); + +export interface DiscoveredRepository { + id: string; + path: string; + folderName: string; + lastModified: string; + relativePath: string; +} + +interface RepositorySearchOptions { + basePath: string; + maxDepth?: number; +} + +/** + * Create routes for repository discovery functionality + */ +export function createRepositoryRoutes(): Router { + const router = Router(); + + // Discover repositories endpoint + router.get('/repositories/discover', async (req, res) => { + try { + const basePath = (req.query.path as string) || '~/'; + const maxDepth = Number.parseInt(req.query.maxDepth as string) || 3; + + logger.debug(`[GET /repositories/discover] Discovering repositories in: ${basePath}`); + + const expandedPath = resolvePath(basePath); + const repositories = await discoverRepositories({ + basePath: expandedPath, + maxDepth, + }); + + logger.debug(`[GET /repositories/discover] Found ${repositories.length} repositories`); + res.json(repositories); + } catch (error) { + logger.error('[GET /repositories/discover] Error discovering repositories:', error); + res.status(500).json({ error: 'Failed to discover repositories' }); + } + }); + + return router; +} + +/** + * Resolve path handling ~ expansion + */ +function resolvePath(inputPath: string): string { + if (inputPath.startsWith('~/')) { + return path.join(os.homedir(), inputPath.slice(2)); + } + return path.isAbsolute(inputPath) ? inputPath : path.resolve(inputPath); +} + +/** + * Discover git repositories in the specified base path + */ +async function discoverRepositories( + options: RepositorySearchOptions +): Promise { + const { basePath, maxDepth = 3 } = options; + const repositories: DiscoveredRepository[] = []; + + async function scanDirectory(dirPath: string, depth: number): Promise { + if (depth > maxDepth) { + return; + } + + try { + // Check if directory is accessible + await fs.access(dirPath, fs.constants.R_OK); + + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + // Skip hidden directories except .git + if (entry.name.startsWith('.') && entry.name !== '.git') continue; + + const fullPath = path.join(dirPath, entry.name); + + // Check if this is a git repository + const gitPath = path.join(fullPath, '.git'); + try { + await fs.stat(gitPath); + // If .git exists (either as a file or directory), this is a git repository + const repository = await createDiscoveredRepository(fullPath); + repositories.push(repository); + } catch { + // .git doesn't exist, scan subdirectories + await scanDirectory(fullPath, depth + 1); + } + } + } catch (error) { + logger.debug(`Cannot access directory ${dirPath}: ${error}`); + } + } + + await scanDirectory(basePath, 0); + + // Sort by folder name + repositories.sort((a, b) => a.folderName.localeCompare(b.folderName)); + + return repositories; +} + +/** + * Create a DiscoveredRepository from a path + */ +async function createDiscoveredRepository(repoPath: string): Promise { + const folderName = path.basename(repoPath); + + // Get last modified date + const stats = await fs.stat(repoPath); + const lastModified = stats.mtime.toISOString(); + + // Get relative path from home directory + const homeDir = os.homedir(); + const relativePath = repoPath.startsWith(homeDir) + ? `~${repoPath.slice(homeDir.length)}` + : repoPath; + + return { + id: `${folderName}-${stats.ino}`, + path: repoPath, + folderName, + lastModified, + relativePath, + }; +} diff --git a/web/src/server/server.ts b/web/src/server/server.ts index 9219dc35..a7318db8 100644 --- a/web/src/server/server.ts +++ b/web/src/server/server.ts @@ -19,6 +19,7 @@ import { createFilesystemRoutes } from './routes/filesystem.js'; import { createLogRoutes } from './routes/logs.js'; import { createPushRoutes } from './routes/push.js'; import { createRemoteRoutes } from './routes/remotes.js'; +import { createRepositoryRoutes } from './routes/repositories.js'; import { createScreencapRoutes, initializeScreencap } from './routes/screencap.js'; import { createSessionRoutes } from './routes/sessions.js'; import { createWebRTCConfigRouter } from './routes/webrtc-config.js'; @@ -657,6 +658,10 @@ export async function createApp(): Promise { app.use('/api', createFileRoutes()); logger.debug('Mounted file routes'); + // Mount repository routes + app.use('/api', createRepositoryRoutes()); + logger.debug('Mounted repository routes'); + // Mount push notification routes if (vapidManager) { app.use(