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(