mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +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 type { AuthClient } from '../services/auth-client.js';
|
||||||
import { createLogger } from '../utils/logger.js';
|
import { createLogger } from '../utils/logger.js';
|
||||||
import type { Session } from './session-list.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');
|
const logger = createLogger('session-create-form');
|
||||||
|
|
||||||
|
|
@ -50,6 +54,15 @@ export class SessionCreateForm extends LitElement {
|
||||||
@state() private isCreating = false;
|
@state() private isCreating = false;
|
||||||
@state() private showFileBrowser = false;
|
@state() private showFileBrowser = false;
|
||||||
@state() private selectedQuickStart = 'zsh';
|
@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 = [
|
quickStartCommands = [
|
||||||
{ label: 'claude', command: 'claude' },
|
{ label: 'claude', command: 'claude' },
|
||||||
|
|
@ -110,8 +123,21 @@ export class SessionCreateForm extends LitElement {
|
||||||
const savedSpawnWindow = localStorage.getItem(this.STORAGE_KEY_SPAWN_WINDOW);
|
const savedSpawnWindow = localStorage.getItem(this.STORAGE_KEY_SPAWN_WINDOW);
|
||||||
const savedTitleMode = localStorage.getItem(this.STORAGE_KEY_TITLE_MODE);
|
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
|
// Always set values, using saved values or defaults
|
||||||
this.workingDir = savedWorkingDir || '~/';
|
// Priority: savedWorkingDir > appRepoBasePath > default
|
||||||
|
this.workingDir = savedWorkingDir || appRepoBasePath || '~/';
|
||||||
this.command = savedCommand || 'zsh';
|
this.command = savedCommand || 'zsh';
|
||||||
|
|
||||||
// For spawn window, only use saved value if it exists and is valid
|
// 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
|
// Set data attributes for testing - both synchronously to avoid race conditions
|
||||||
this.setAttribute('data-modal-state', 'open');
|
this.setAttribute('data-modal-state', 'open');
|
||||||
this.setAttribute('data-modal-rendered', 'true');
|
this.setAttribute('data-modal-rendered', 'true');
|
||||||
|
|
||||||
|
// Discover repositories
|
||||||
|
this.discoverRepositories();
|
||||||
} else {
|
} else {
|
||||||
// Remove global keyboard listener when hidden
|
// Remove global keyboard listener when hidden
|
||||||
document.removeEventListener('keydown', this.handleGlobalKeyDown);
|
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() {
|
render() {
|
||||||
if (!this.visible) {
|
if (!this.visible) {
|
||||||
return html``;
|
return html``;
|
||||||
|
|
@ -494,7 +569,52 @@ export class SessionCreateForm extends LitElement {
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Spawn Window Toggle -->
|
<!-- Spawn Window Toggle -->
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,16 @@ const logger = createLogger('unified-settings');
|
||||||
export interface AppPreferences {
|
export interface AppPreferences {
|
||||||
useDirectKeyboard: boolean;
|
useDirectKeyboard: boolean;
|
||||||
showLogLink: boolean;
|
showLogLink: boolean;
|
||||||
|
repositoryBasePath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_APP_PREFERENCES: AppPreferences = {
|
const DEFAULT_APP_PREFERENCES: AppPreferences = {
|
||||||
useDirectKeyboard: true, // Default to modern direct keyboard for new users
|
useDirectKeyboard: true, // Default to modern direct keyboard for new users
|
||||||
showLogLink: false,
|
showLogLink: false,
|
||||||
|
repositoryBasePath: '~/',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_KEY = 'vibetunnel_app_preferences';
|
export const STORAGE_KEY = 'vibetunnel_app_preferences';
|
||||||
|
|
||||||
@customElement('unified-settings')
|
@customElement('unified-settings')
|
||||||
export class UnifiedSettings extends LitElement {
|
export class UnifiedSettings extends LitElement {
|
||||||
|
|
@ -223,7 +225,7 @@ export class UnifiedSettings extends LitElement {
|
||||||
pushNotificationService.savePreferences(this.notificationPreferences);
|
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.appPreferences = { ...this.appPreferences, [key]: value };
|
||||||
this.saveAppPreferences();
|
this.saveAppPreferences();
|
||||||
}
|
}
|
||||||
|
|
@ -508,6 +510,28 @@ export class UnifiedSettings extends LitElement {
|
||||||
></span>
|
></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</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 { createLogRoutes } from './routes/logs.js';
|
||||||
import { createPushRoutes } from './routes/push.js';
|
import { createPushRoutes } from './routes/push.js';
|
||||||
import { createRemoteRoutes } from './routes/remotes.js';
|
import { createRemoteRoutes } from './routes/remotes.js';
|
||||||
|
import { createRepositoryRoutes } from './routes/repositories.js';
|
||||||
import { createScreencapRoutes, initializeScreencap } from './routes/screencap.js';
|
import { createScreencapRoutes, initializeScreencap } from './routes/screencap.js';
|
||||||
import { createSessionRoutes } from './routes/sessions.js';
|
import { createSessionRoutes } from './routes/sessions.js';
|
||||||
import { createWebRTCConfigRouter } from './routes/webrtc-config.js';
|
import { createWebRTCConfigRouter } from './routes/webrtc-config.js';
|
||||||
|
|
@ -657,6 +658,10 @@ export async function createApp(): Promise<AppInstance> {
|
||||||
app.use('/api', createFileRoutes());
|
app.use('/api', createFileRoutes());
|
||||||
logger.debug('Mounted file routes');
|
logger.debug('Mounted file routes');
|
||||||
|
|
||||||
|
// Mount repository routes
|
||||||
|
app.use('/api', createRepositoryRoutes());
|
||||||
|
logger.debug('Mounted repository routes');
|
||||||
|
|
||||||
// Mount push notification routes
|
// Mount push notification routes
|
||||||
if (vapidManager) {
|
if (vapidManager) {
|
||||||
app.use(
|
app.use(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue