Improve Quick Start editor UI consistency across web and macOS (#448)

This commit is contained in:
Peter Steinberger 2025-07-21 17:11:49 +02:00 committed by GitHub
parent bebf977d23
commit 102f6c5e33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 220 additions and 138 deletions

View file

@ -32,6 +32,10 @@ enum FilePathConstants {
static let tmpDirectory = "/tmp/"
// MARK: - Default Paths
static let defaultRepositoryBasePath = "~/Documents"
// MARK: - Common Repository Base Paths
static let projectsPath = "~/Projects"

View file

@ -15,7 +15,7 @@ class ConfigManager: ObservableObject {
// Core configuration
@Published private(set) var quickStartCommands: [QuickStartCommand] = []
@Published var repositoryBasePath: String = "~/"
@Published var repositoryBasePath: String = FilePathConstants.defaultRepositoryBasePath
// Server settings
@Published var serverPort: Int = 4_020
@ -42,7 +42,7 @@ class ConfigManager: ObservableObject {
// Session defaults
@Published var sessionCommand: String = "zsh"
@Published var sessionWorkingDirectory: String = "~/"
@Published var sessionWorkingDirectory: String = FilePathConstants.defaultRepositoryBasePath
@Published var sessionSpawnWindow: Bool = true
@Published var sessionTitleMode: TitleMode = .dynamic
@ -163,7 +163,7 @@ class ConfigManager: ObservableObject {
// Load all configuration values
self.quickStartCommands = config.quickStartCommands
self.repositoryBasePath = config.repositoryBasePath ?? "~/"
self.repositoryBasePath = config.repositoryBasePath ?? FilePathConstants.defaultRepositoryBasePath
// Server settings
if let server = config.server {
@ -217,7 +217,7 @@ class ConfigManager: ObservableObject {
private func useDefaults() {
self.quickStartCommands = defaultCommands
self.repositoryBasePath = "~/"
self.repositoryBasePath = FilePathConstants.defaultRepositoryBasePath
saveConfiguration()
}

View file

@ -10,50 +10,50 @@ import OSLog
@Observable
final class NetworkMonitor {
// MARK: - Properties
/// Shared instance for network monitoring
static let shared = NetworkMonitor()
/// Current network connection status
private(set) var isConnected = true
/// Whether the current connection is expensive (e.g., cellular)
private(set) var isExpensive = false
/// Whether the current connection is constrained (e.g., Low Data Mode)
private(set) var isConstrained = false
/// The type of interface used for the current connection
private(set) var connectionType: NWInterface.InterfaceType?
// MARK: - Private Properties
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "sh.vibetunnel.NetworkMonitor")
private let logger = Logger(subsystem: BundleIdentifiers.main, category: "NetworkMonitor")
// MARK: - Initialization
private init() {
setupMonitor()
}
// MARK: - Public Methods
/// Starts monitoring network connectivity
func startMonitoring() {
monitor.start(queue: queue)
logger.info("Network monitoring started")
}
/// Stops monitoring network connectivity
func stopMonitoring() {
monitor.cancel()
logger.info("Network monitoring stopped")
}
// MARK: - Private Methods
private func setupMonitor() {
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor in
@ -61,15 +61,15 @@ final class NetworkMonitor {
}
}
}
@MainActor
private func updateNetworkStatus(_ path: NWPath) {
let wasConnected = isConnected
isConnected = path.status == .satisfied
isExpensive = path.isExpensive
isConstrained = path.isConstrained
// Determine connection type
if path.usesInterfaceType(.wifi) {
connectionType = .wifi
@ -80,7 +80,7 @@ final class NetworkMonitor {
} else {
connectionType = nil
}
// Log status changes
if wasConnected != isConnected {
if isConnected {
@ -89,7 +89,7 @@ final class NetworkMonitor {
logger.warning("Network disconnected")
}
}
// Post notification for interested observers
NotificationCenter.default.post(
name: .networkStatusChanged,
@ -97,7 +97,7 @@ final class NetworkMonitor {
userInfo: ["isConnected": isConnected]
)
}
/// Human-readable description of the connection type
var connectionTypeString: String {
switch connectionType {
@ -123,4 +123,4 @@ final class NetworkMonitor {
extension Notification.Name {
static let networkStatusChanged = Notification.Name("sh.vibetunnel.networkStatusChanged")
}
}

View file

@ -11,39 +11,39 @@ enum NetworkUtility {
static func getLocalIPAddress() -> String? {
// Check common network interfaces in priority order
let preferredInterfaces = ["en0", "en1", "en2", "en3", "en4", "en5"]
for interfaceName in preferredInterfaces {
if let address = getIPAddress(for: interfaceName) {
return address
}
}
// Fallback: check any "en" interface
return getIPAddressForAnyInterface()
}
/// Get IP address for a specific interface
private static func getIPAddress(for interfaceName: String) -> String? {
var ifaddr: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&ifaddr) == 0 else { return nil }
defer { freeifaddrs(ifaddr) }
var ptr = ifaddr
while ptr != nil {
defer { ptr = ptr?.pointee.ifa_next }
guard let interface = ptr?.pointee else { continue }
// Skip loopback addresses
if interface.ifa_flags & UInt32(IFF_LOOPBACK) != 0 { continue }
// Check for IPv4 interface
let addrFamily = interface.ifa_addr.pointee.sa_family
if addrFamily == UInt8(AF_INET) {
// Get interface name
let name = String(cString: interface.ifa_name)
if name == interfaceName {
var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
if getnameinfo(
@ -56,21 +56,22 @@ enum NetworkUtility {
NI_NUMERICHOST
) == 0 {
let ipAddress = String(cString: &hostname)
// Prefer addresses that look like local network addresses
if ipAddress.hasPrefix("192.168.") ||
ipAddress.hasPrefix("10.") ||
ipAddress.hasPrefix("172.") {
ipAddress.hasPrefix("172.")
{
return ipAddress
}
}
}
}
}
return nil
}
/// Get IP address for any available interface
private static func getIPAddressForAnyInterface() -> String? {
var address: String?

View file

@ -20,7 +20,7 @@ struct NewSessionForm: View {
// Form fields
@State private var command = "zsh"
@State private var sessionName = ""
@State private var workingDirectory = "~/"
@State private var workingDirectory = FilePathConstants.defaultRepositoryBasePath
@State private var spawnWindow = true
@State private var titleMode: TitleMode = .dynamic
@ -432,8 +432,8 @@ struct NewSessionForm: View {
{
workingDirectory = savedDirectory
} else {
// Default to home directory if never set
workingDirectory = "~/"
// Default to repository base path if never set
workingDirectory = configManager.sessionWorkingDirectory
}
// Check if spawn window preference has been explicitly set

View file

@ -129,7 +129,7 @@ final class StatusBarController: NSObject {
private func setupNetworkMonitoring() {
// Start the network monitor
NetworkMonitor.shared.startMonitoring()
// Listen for network status changes
NotificationCenter.default.addObserver(
self,
@ -137,11 +137,11 @@ final class StatusBarController: NSObject {
name: .networkStatusChanged,
object: nil
)
// Set initial state
hasNetworkAccess = NetworkMonitor.shared.isConnected
}
@objc
private func networkStatusChanged(_ notification: Notification) {
hasNetworkAccess = NetworkMonitor.shared.isConnected

View file

@ -19,7 +19,9 @@ enum TooltipProvider {
ngrokService: NgrokService,
tailscaleService: TailscaleService,
sessionMonitor: SessionMonitor
) -> String {
)
-> String
{
var tooltipParts: [String] = []
// Server status
@ -74,4 +76,4 @@ enum TooltipProvider {
return tooltipParts.joined(separator: "\n")
}
}
}

View file

@ -44,7 +44,13 @@ struct AboutView: View {
"Sandeep Aggarwal",
"Tao Xu",
"Zhiqiang Zhou",
"noppe"
"noppe",
"Gopikrishna Kori",
"Claude Mini",
"Alex Mazanov",
"David Gomes",
"Piotr Bosak",
"Zhuojie Zhou"
]
var body: some View {

View file

@ -12,26 +12,13 @@ struct QuickStartSettingsSection: View {
var body: some View {
Section {
VStack(alignment: .leading, spacing: 12) {
// Header with Add button
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Quick Start Commands")
.font(.headline)
Text("Commands shown in the new session form for quick access.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button(action: {
editingCommandId = nil
showingNewCommand = true
}, label: {
Label("Add", systemImage: "plus")
})
.buttonStyle(.bordered)
.disabled(showingNewCommand)
// Header without Add button
VStack(alignment: .leading, spacing: 4) {
Text("Quick Start Commands")
.font(.headline)
Text("Commands shown in the new session form for quick access.")
.font(.caption)
.foregroundStyle(.secondary)
}
// Commands list
@ -103,6 +90,8 @@ struct QuickStartSettingsSection: View {
}
.buttonStyle(.link)
Spacer()
if !configManager.quickStartCommands.isEmpty {
Button("Delete All") {
deleteAllCommands()
@ -111,7 +100,14 @@ struct QuickStartSettingsSection: View {
.foregroundColor(.red)
}
Spacer()
Button(action: {
editingCommandId = nil
showingNewCommand = true
}, label: {
Label("Add", systemImage: "plus")
})
.buttonStyle(.bordered)
.disabled(showingNewCommand)
}
}
}

View file

@ -52,6 +52,9 @@ export class AutocompleteManager {
const data = await response.json();
const completions: AutocompleteItem[] = data.completions || [];
// Filter out files - only show directories and git repositories
const directoryCompletions = completions.filter((item) => item.type === 'directory');
// Also search through discovered repositories if user is typing a partial name
const isSearchingByName =
!path.includes('/') ||
@ -72,14 +75,14 @@ export class AutocompleteManager {
}));
// Merge with filesystem completions, avoiding duplicates
const existingPaths = new Set(completions.map((c) => c.suggestion));
const existingPaths = new Set(directoryCompletions.map((c) => c.suggestion));
const uniqueRepos = matchingRepos.filter((repo) => !existingPaths.has(repo.suggestion));
completions.push(...uniqueRepos);
directoryCompletions.push(...uniqueRepos);
}
// Sort completions with custom logic
const sortedCompletions = this.sortCompletions(completions, path);
const sortedCompletions = this.sortCompletions(directoryCompletions, path);
// Limit to 20 results for performance
return sortedCompletions.slice(0, 20);
@ -109,18 +112,11 @@ export class AutocompleteManager {
if (aStartsWith && !bStartsWith) return -1;
if (!aStartsWith && bStartsWith) return 1;
// 3. Directories before files
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1;
}
// 3. Git repositories before regular directories
if (a.isRepository && !b.isRepository) return -1;
if (!a.isRepository && b.isRepository) return 1;
// 4. Git repositories before regular directories
if (a.type === 'directory' && b.type === 'directory') {
if (a.isRepository && !b.isRepository) return -1;
if (!a.isRepository && b.isRepository) return 1;
}
// 5. Alphabetical order
// 4. Alphabetical order
return a.name.localeCompare(b.name);
});
}

View file

@ -93,7 +93,7 @@ export class NotificationStatus extends LitElement {
private renderIcon() {
return html`
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/>
</svg>
`;

View file

@ -192,14 +192,6 @@ export class QuickStartEditor extends LitElement {
<div class="flex items-center justify-between mb-2">
<h3 class="text-xs font-medium text-text-muted">Commands shown in the new session form for quick access.</h3>
<div class="flex gap-2">
<button
id="quick-start-reset-button"
@click=${this.handleResetToDefaults}
class="text-primary hover:text-primary-hover text-[10px] transition-colors duration-200"
title="Reset to default commands"
>
Reset to Defaults
</button>
<button
id="quick-start-cancel-button"
@click=${this.handleCancel}
@ -267,29 +259,40 @@ export class QuickStartEditor extends LitElement {
)}
</div>
<button
id="quick-start-add-command-button"
@click=${this.handleAddCommand}
class="bg-bg-secondary hover:bg-hover text-text-muted hover:text-primary px-3 py-1.5 rounded-md transition-colors duration-200 text-xs font-medium flex items-center gap-1.5 ml-auto"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v12m6-6H6" />
</svg>
Add
</button>
<!-- Bottom actions -->
<div class="flex justify-end gap-4 mt-4 pt-3 border-t border-border/30">
<div class="flex justify-between items-center mt-4">
<button
id="quick-start-delete-all-button"
@click=${() => {
this.editableCommands = [];
this.requestUpdate();
}}
class="text-error hover:text-error-hover text-[10px] transition-colors duration-200"
id="quick-start-reset-button"
@click=${this.handleResetToDefaults}
class="text-primary hover:text-primary-hover text-[10px] transition-colors duration-200"
title="Reset to default commands"
>
Delete All
Reset to Defaults
</button>
<div class="flex gap-4 items-center">
<button
id="quick-start-delete-all-button"
@click=${() => {
this.editableCommands = [];
this.requestUpdate();
}}
class="text-error hover:text-error-hover text-xs transition-colors duration-200"
>
Delete All
</button>
<button
id="quick-start-add-command-button"
@click=${this.handleAddCommand}
class="bg-bg-secondary hover:bg-hover text-text-muted hover:text-primary px-3 py-1.5 rounded-md transition-colors duration-200 text-xs font-medium flex items-center gap-1.5"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v12m6-6H6" />
</svg>
Add
</button>
</div>
</div>
</div>
`;

View file

@ -65,7 +65,7 @@ describe('SessionCreateForm', () => {
describe('initialization', () => {
it('should create component with default state', () => {
expect(element).toBeDefined();
expect(element.workingDir).toBe('~/');
expect(element.workingDir).toBe('~/Documents');
expect(element.command).toBe('zsh');
expect(element.sessionName).toBe('');
expect(element.isCreating).toBe(false);

View file

@ -15,6 +15,7 @@ import { html, LitElement, type PropertyValues } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import './file-browser.js';
import './quick-start-editor.js';
import { DEFAULT_REPOSITORY_BASE_PATH } from '../../shared/constants.js';
import type { Session } from '../../shared/types.js';
import { TitleMode } from '../../shared/types.js';
import type { QuickStartCommand } from '../../types/config.js';
@ -48,7 +49,7 @@ export class SessionCreateForm extends LitElement {
return this;
}
@property({ type: String }) workingDir = '~/';
@property({ type: String }) workingDir = DEFAULT_REPOSITORY_BASE_PATH;
@property({ type: String }) command = 'zsh';
@property({ type: String }) sessionName = '';
@property({ type: Boolean }) disabled = false;
@ -156,19 +157,19 @@ export class SessionCreateForm extends LitElement {
const formData = loadSessionFormData();
// Get repository base path from server config to use as default working dir
let appRepoBasePath = '~/';
let appRepoBasePath = DEFAULT_REPOSITORY_BASE_PATH;
if (this.serverConfigService) {
try {
appRepoBasePath = await this.serverConfigService.getRepositoryBasePath();
} catch (error) {
logger.error('Failed to get repository base path from server:', error);
appRepoBasePath = '~/';
appRepoBasePath = DEFAULT_REPOSITORY_BASE_PATH;
}
}
// Always set values, using saved values or defaults
// Priority: savedWorkingDir > appRepoBasePath > default
this.workingDir = formData.workingDir || appRepoBasePath || '~/';
this.workingDir = formData.workingDir || appRepoBasePath || DEFAULT_REPOSITORY_BASE_PATH;
this.command = formData.command || 'zsh';
// For spawn window, use saved value or default to false
@ -284,7 +285,7 @@ export class SessionCreateForm extends LitElement {
if (changedProperties.has('visible')) {
if (this.visible) {
// Reset to defaults first to ensure clean state
this.workingDir = '~/';
this.workingDir = DEFAULT_REPOSITORY_BASE_PATH;
this.command = 'zsh';
this.sessionName = '';
this.spawnWindow = false;
@ -873,7 +874,7 @@ export class SessionCreateForm extends LitElement {
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"
/>
</svg>
<span class="form-label text-text-muted uppercase text-[9px] sm:text-[10px] lg:text-xs tracking-wider">Options</span>
<span class="form-label mb-0 text-text-muted uppercase text-[9px] sm:text-[10px] lg:text-xs tracking-wider">Options</span>
</button>
${

View file

@ -1,5 +1,6 @@
import { html, LitElement, type PropertyValues } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { DEFAULT_REPOSITORY_BASE_PATH } from '../../shared/constants.js';
import type { AuthClient } from '../services/auth-client.js';
import {
type NotificationPreferences,
@ -55,7 +56,7 @@ export class UnifiedSettings extends LitElement {
// App settings state
@state() private appPreferences: AppPreferences = DEFAULT_APP_PREFERENCES;
@state() private repositoryBasePath = '~/';
@state() private repositoryBasePath = DEFAULT_REPOSITORY_BASE_PATH;
@state() private mediaState: MediaQueryState = responsiveObserver.getCurrentState();
@state() private isServerConfigured = false;
@state() private repositoryCount = 0;
@ -150,6 +151,15 @@ export class UnifiedSettings extends LitElement {
);
}
updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
// When dialog becomes visible, refresh the config to ensure sync
if (changedProperties.has('visible') && this.visible) {
this.loadAppPreferences();
}
}
private async loadAppPreferences() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
@ -157,13 +167,16 @@ export class UnifiedSettings extends LitElement {
this.appPreferences = { ...DEFAULT_APP_PREFERENCES, ...JSON.parse(stored) };
}
// Fetch server configuration
// Fetch server configuration - force refresh when dialog opens
if (this.serverConfigService) {
try {
const serverConfig = await this.serverConfigService.loadConfig();
const serverConfig = await this.serverConfigService.loadConfig(this.visible);
this.isServerConfigured = serverConfig.serverConfigured ?? false;
// Always use server's repository base path
this.repositoryBasePath = serverConfig.repositoryBasePath || '~/';
this.repositoryBasePath = serverConfig.repositoryBasePath || DEFAULT_REPOSITORY_BASE_PATH;
logger.debug('Loaded repository base path:', this.repositoryBasePath);
// Force update to ensure UI reflects the loaded value
this.requestUpdate();
} catch (error) {
logger.warn('Failed to fetch server config', error);
}

View file

@ -104,7 +104,7 @@ describe('ServerConfigService', () => {
const config = await service.loadConfig();
expect(config).toEqual({
repositoryBasePath: '~/',
repositoryBasePath: '~/Documents',
serverConfigured: false,
quickStartCommands: [],
});
@ -119,7 +119,7 @@ describe('ServerConfigService', () => {
const config = await service.loadConfig();
expect(config).toEqual({
repositoryBasePath: '~/',
repositoryBasePath: '~/Documents',
serverConfigured: false,
quickStartCommands: [],
});
@ -241,7 +241,7 @@ describe('ServerConfigService', () => {
} as Response);
const path = await service.getRepositoryBasePath();
expect(path).toBe('~/');
expect(path).toBe('~/Documents');
});
it('isServerConfigured should return server configured status', async () => {

View file

@ -6,6 +6,7 @@
* - Repository base path
* - Server configuration status
*/
import { DEFAULT_REPOSITORY_BASE_PATH } from '../../shared/constants.js';
import type { QuickStartCommand } from '../../types/config.js';
import { createLogger } from '../utils/logger.js';
import type { AuthClient } from './auth-client.js';
@ -87,7 +88,7 @@ export class ServerConfigService {
logger.error('Failed to load server config:', error);
// Return default config on error
return {
repositoryBasePath: '~/',
repositoryBasePath: DEFAULT_REPOSITORY_BASE_PATH,
serverConfigured: false,
quickStartCommands: [],
};
@ -136,7 +137,7 @@ export class ServerConfigService {
*/
async getRepositoryBasePath(): Promise<string> {
const config = await this.loadConfig();
return config.repositoryBasePath || '~/';
return config.repositoryBasePath || DEFAULT_REPOSITORY_BASE_PATH;
}
/**

View file

@ -72,7 +72,7 @@ describe('Config Routes', () => {
expect(response.status).toBe(200);
expect(response.body).toEqual({
repositoryBasePath: '~/',
repositoryBasePath: '~/Documents',
serverConfigured: true,
quickStartCommands: defaultConfig.quickStartCommands,
});

View file

@ -1,4 +1,5 @@
import { Router } from 'express';
import { DEFAULT_REPOSITORY_BASE_PATH } from '../../shared/constants.js';
import type { QuickStartCommand } from '../../types/config.js';
import type { ConfigService } from '../services/config-service.js';
import { createLogger } from '../utils/logger.js';
@ -29,7 +30,8 @@ export function createConfigRoutes(options: ConfigRouteOptions): Router {
router.get('/config', (_req, res) => {
try {
const vibeTunnelConfig = configService.getConfig();
const repositoryBasePath = vibeTunnelConfig.repositoryBasePath || '~/';
const repositoryBasePath =
vibeTunnelConfig.repositoryBasePath || DEFAULT_REPOSITORY_BASE_PATH;
const config: AppConfig = {
repositoryBasePath: repositoryBasePath,

View file

@ -34,6 +34,16 @@ export function createRepositoryRoutes(): Router {
logger.debug(`[GET /repositories/discover] Discovering repositories in: ${basePath}`);
const expandedPath = resolvePath(basePath);
logger.debug(`[GET /repositories/discover] Expanded path: ${expandedPath}`);
// Check if the path exists
try {
await fs.access(expandedPath, fs.constants.R_OK);
logger.debug(`[GET /repositories/discover] Path exists and is readable: ${expandedPath}`);
} catch (error) {
logger.error(`[GET /repositories/discover] Cannot access path: ${expandedPath}`, error);
}
const repositories = await discoverRepositories({
basePath: expandedPath,
maxDepth,
@ -69,6 +79,8 @@ async function discoverRepositories(
const { basePath, maxDepth = 3 } = options;
const repositories: DiscoveredRepository[] = [];
logger.debug(`Starting repository discovery in ${basePath} with maxDepth=${maxDepth}`);
async function scanDirectory(dirPath: string, depth: number): Promise<void> {
if (depth > maxDepth) {
return;
@ -78,6 +90,23 @@ async function discoverRepositories(
// Check if directory is accessible
await fs.access(dirPath, fs.constants.R_OK);
// First check if the current directory itself is a git repository
// Only check at depth 0 to match Mac app behavior
if (depth === 0) {
const currentGitPath = path.join(dirPath, '.git');
try {
await fs.access(currentGitPath, fs.constants.F_OK);
// Current directory is a git repository
const repository = await createDiscoveredRepository(dirPath);
repositories.push(repository);
logger.debug(`Found git repository at base path: ${dirPath}`);
// Don't scan subdirectories of a git repository
return;
} catch {
// Current directory is not a git repository, continue scanning
}
}
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
@ -88,20 +117,22 @@ async function discoverRepositories(
const fullPath = path.join(dirPath, entry.name);
// Check if this is a git repository
// Check if this subdirectory is a git repository
const gitPath = path.join(fullPath, '.git');
try {
await fs.stat(gitPath);
await fs.access(gitPath, fs.constants.F_OK);
// If .git exists (either as a file or directory), this is a git repository
const repository = await createDiscoveredRepository(fullPath);
repositories.push(repository);
logger.debug(`Found git repository: ${fullPath}`);
// Don't scan subdirectories of a git repository
} catch {
// .git doesn't exist, scan subdirectories
await scanDirectory(fullPath, depth + 1);
}
}
} catch (error) {
logger.debug(`Cannot access directory ${dirPath}: ${error}`);
logger.debug(`Cannot access directory ${dirPath}:`, error);
}
}

View file

@ -479,10 +479,16 @@ export function createSessionRoutes(config: SessionRoutesConfig): Router {
return res.status(404).json({ error: 'Session not found' });
}
await ptyManager.killSession(sessionId, 'SIGTERM');
logger.log(chalk.yellow(`local session ${sessionId} killed`));
res.json({ success: true, message: 'Session killed' });
// If session is already exited, clean it up instead of trying to kill it
if (session.status === 'exited') {
ptyManager.cleanupSession(sessionId);
logger.log(chalk.yellow(`local session ${sessionId} cleaned up`));
res.json({ success: true, message: 'Session cleaned up' });
} else {
await ptyManager.killSession(sessionId, 'SIGTERM');
logger.log(chalk.yellow(`local session ${sessionId} killed`));
res.json({ success: true, message: 'Session killed' });
}
} catch (error) {
logger.error('error killing session:', error);
if (error instanceof PtyError) {

View file

@ -0,0 +1,9 @@
/**
* Shared constants used across both client and server
*/
// Default paths
export const DEFAULT_REPOSITORY_BASE_PATH = '~/Documents';
// Server configuration
export const DEFAULT_SERVER_PORT = 4020;

View file

@ -224,7 +224,7 @@ describe('AutocompleteManager', () => {
expect(result[1].name).toBe('project');
});
it('should prioritize directories over files', async () => {
it('should filter out files and only show directories', async () => {
const mockCompletions = [
{
name: 'readme.md',
@ -238,6 +238,12 @@ describe('AutocompleteManager', () => {
type: 'directory' as const,
suggestion: '~/Documents/',
},
{
name: 'Projects',
path: '~/Projects',
type: 'directory' as const,
suggestion: '~/Projects/',
},
];
mockFetch.mockResolvedValueOnce({
@ -247,8 +253,11 @@ describe('AutocompleteManager', () => {
const result = await manager.fetchCompletions('~/');
// Should only have directories, no files
expect(result).toHaveLength(2);
expect(result[0].type).toBe('directory');
expect(result[1].type).toBe('file');
expect(result[1].type).toBe('directory');
expect(result.some((item) => item.type === 'file')).toBe(false);
});
it('should prioritize git repositories over regular directories', async () => {

View file

@ -1,3 +1,5 @@
import { DEFAULT_REPOSITORY_BASE_PATH } from '../shared/constants.js';
export interface QuickStartCommand {
name?: string; // Optional display name (can include emoji), if empty uses command
command: string; // The actual command to execute
@ -21,5 +23,5 @@ export const DEFAULT_QUICK_START_COMMANDS: QuickStartCommand[] = [
export const DEFAULT_CONFIG: VibeTunnelConfig = {
version: 1,
quickStartCommands: DEFAULT_QUICK_START_COMMANDS,
repositoryBasePath: '~/',
repositoryBasePath: DEFAULT_REPOSITORY_BASE_PATH,
};