mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-11 12:15:53 +00:00
feat(web): Add repository discovery to web client (#301)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
parent
a3631f6d94
commit
e8191181c9
4 changed files with 291 additions and 3 deletions
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
139
web/src/server/routes/repositories.ts
Normal file
139
web/src/server/routes/repositories.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue