feat(web): Add repository discovery to web client (#301)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Marek Šuppa 2025-07-15 03:23:28 +02:00 committed by GitHub
parent a3631f6d94
commit e8191181c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 291 additions and 3 deletions

View file

@ -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 {
/>
</svg>
</button>
<button
class="bg-dark-bg-elevated border border-dark-border rounded-lg p-1.5 sm:p-2 lg:p-3 font-mono text-dark-text-muted transition-all duration-200 hover:text-primary hover:bg-dark-surface-hover hover:border-primary hover:shadow-sm flex-shrink-0 ${
this.showRepositoryDropdown ? 'text-primary border-primary' : ''
}"
@click=${this.handleToggleRepositoryDropdown}
?disabled=${this.disabled || this.isCreating || this.repositories.length === 0 || this.isDiscovering}
title="Choose from repositories"
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">
<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.showRepositoryDropdown && this.repositories.length > 0
? html`
<div class="mt-2 bg-dark-bg-elevated border border-dark-border rounded-lg overflow-hidden">
<div class="max-h-48 overflow-y-auto">
${this.repositories.map(
(repo) => html`
<button
@click=${() => this.handleSelectRepository(repo.path)}
class="w-full text-left px-3 py-2 hover:bg-dark-surface-hover transition-colors duration-200 border-b border-dark-border last:border-b-0"
type="button"
>
<div class="flex items-center justify-between">
<div>
<div class="text-dark-text text-xs sm:text-sm font-medium">${repo.folderName}</div>
<div class="text-dark-text-muted text-[9px] sm:text-[10px] mt-0.5">${repo.relativePath}</div>
</div>
<div class="text-dark-text-muted text-[9px] sm:text-[10px]">
${new Date(repo.lastModified).toLocaleDateString()}
</div>
</div>
</button>
`
)}
</div>
</div>
`
: ''
}
</div>
<!-- Spawn Window Toggle -->

View file

@ -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 {
></span>
</button>
</div>
<!-- Repository Base Path -->
<div class="p-4 bg-dark-bg-tertiary rounded-lg border border-dark-border">
<div class="mb-3">
<label class="text-dark-text font-medium">Repository Base Path</label>
<p class="text-dark-text-muted text-xs mt-1">
Default directory for new sessions and repository discovery
</p>
</div>
<div class="flex gap-2">
<input
type="text"
.value=${this.appPreferences.repositoryBasePath}
@input=${(e: Event) => {
const input = e.target as HTMLInputElement;
this.handleAppPreferenceChange('repositoryBasePath', input.value);
}}
placeholder="~/"
class="input-field py-2 text-sm flex-1"
/>
</div>
</div>
</div>
`;
}

View file

@ -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<DiscoveredRepository[]> {
const { basePath, maxDepth = 3 } = options;
const repositories: DiscoveredRepository[] = [];
async function scanDirectory(dirPath: string, depth: number): Promise<void> {
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<DiscoveredRepository> {
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,
};
}

View file

@ -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<AppInstance> {
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(