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/" static let tmpDirectory = "/tmp/"
// MARK: - Default Paths
static let defaultRepositoryBasePath = "~/Documents"
// MARK: - Common Repository Base Paths // MARK: - Common Repository Base Paths
static let projectsPath = "~/Projects" static let projectsPath = "~/Projects"

View file

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

View file

@ -60,7 +60,8 @@ enum NetworkUtility {
// Prefer addresses that look like local network addresses // Prefer addresses that look like local network addresses
if ipAddress.hasPrefix("192.168.") || if ipAddress.hasPrefix("192.168.") ||
ipAddress.hasPrefix("10.") || ipAddress.hasPrefix("10.") ||
ipAddress.hasPrefix("172.") { ipAddress.hasPrefix("172.")
{
return ipAddress return ipAddress
} }
} }

View file

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

View file

@ -19,7 +19,9 @@ enum TooltipProvider {
ngrokService: NgrokService, ngrokService: NgrokService,
tailscaleService: TailscaleService, tailscaleService: TailscaleService,
sessionMonitor: SessionMonitor sessionMonitor: SessionMonitor
) -> String { )
-> String
{
var tooltipParts: [String] = [] var tooltipParts: [String] = []
// Server status // Server status

View file

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

View file

@ -12,26 +12,13 @@ struct QuickStartSettingsSection: View {
var body: some View { var body: some View {
Section { Section {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
// Header with Add button // Header without Add button
HStack { VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 4) { Text("Quick Start Commands")
Text("Quick Start Commands") .font(.headline)
.font(.headline) Text("Commands shown in the new session form for quick access.")
Text("Commands shown in the new session form for quick access.") .font(.caption)
.font(.caption) .foregroundStyle(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
Button(action: {
editingCommandId = nil
showingNewCommand = true
}, label: {
Label("Add", systemImage: "plus")
})
.buttonStyle(.bordered)
.disabled(showingNewCommand)
} }
// Commands list // Commands list
@ -103,6 +90,8 @@ struct QuickStartSettingsSection: View {
} }
.buttonStyle(.link) .buttonStyle(.link)
Spacer()
if !configManager.quickStartCommands.isEmpty { if !configManager.quickStartCommands.isEmpty {
Button("Delete All") { Button("Delete All") {
deleteAllCommands() deleteAllCommands()
@ -111,7 +100,14 @@ struct QuickStartSettingsSection: View {
.foregroundColor(.red) .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 data = await response.json();
const completions: AutocompleteItem[] = data.completions || []; 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 // Also search through discovered repositories if user is typing a partial name
const isSearchingByName = const isSearchingByName =
!path.includes('/') || !path.includes('/') ||
@ -72,14 +75,14 @@ export class AutocompleteManager {
})); }));
// Merge with filesystem completions, avoiding duplicates // 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)); const uniqueRepos = matchingRepos.filter((repo) => !existingPaths.has(repo.suggestion));
completions.push(...uniqueRepos); directoryCompletions.push(...uniqueRepos);
} }
// Sort completions with custom logic // Sort completions with custom logic
const sortedCompletions = this.sortCompletions(completions, path); const sortedCompletions = this.sortCompletions(directoryCompletions, path);
// Limit to 20 results for performance // Limit to 20 results for performance
return sortedCompletions.slice(0, 20); return sortedCompletions.slice(0, 20);
@ -109,18 +112,11 @@ export class AutocompleteManager {
if (aStartsWith && !bStartsWith) return -1; if (aStartsWith && !bStartsWith) return -1;
if (!aStartsWith && bStartsWith) return 1; if (!aStartsWith && bStartsWith) return 1;
// 3. Directories before files // 3. Git repositories before regular directories
if (a.type !== b.type) { if (a.isRepository && !b.isRepository) return -1;
return a.type === 'directory' ? -1 : 1; if (!a.isRepository && b.isRepository) return 1;
}
// 4. Git repositories before regular directories // 4. Alphabetical order
if (a.type === 'directory' && b.type === 'directory') {
if (a.isRepository && !b.isRepository) return -1;
if (!a.isRepository && b.isRepository) return 1;
}
// 5. Alphabetical order
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}); });
} }

View file

@ -93,7 +93,7 @@ export class NotificationStatus extends LitElement {
private renderIcon() { private renderIcon() {
return html` 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"/> <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> </svg>
`; `;

View file

@ -192,14 +192,6 @@ export class QuickStartEditor extends LitElement {
<div class="flex items-center justify-between mb-2"> <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> <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"> <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 <button
id="quick-start-cancel-button" id="quick-start-cancel-button"
@click=${this.handleCancel} @click=${this.handleCancel}
@ -267,29 +259,40 @@ export class QuickStartEditor extends LitElement {
)} )}
</div> </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 --> <!-- 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 <button
id="quick-start-delete-all-button" id="quick-start-reset-button"
@click=${() => { @click=${this.handleResetToDefaults}
this.editableCommands = []; class="text-primary hover:text-primary-hover text-[10px] transition-colors duration-200"
this.requestUpdate(); title="Reset to default commands"
}}
class="text-error hover:text-error-hover text-[10px] transition-colors duration-200"
> >
Delete All Reset to Defaults
</button> </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>
</div> </div>
`; `;

View file

@ -65,7 +65,7 @@ describe('SessionCreateForm', () => {
describe('initialization', () => { describe('initialization', () => {
it('should create component with default state', () => { it('should create component with default state', () => {
expect(element).toBeDefined(); expect(element).toBeDefined();
expect(element.workingDir).toBe('~/'); expect(element.workingDir).toBe('~/Documents');
expect(element.command).toBe('zsh'); expect(element.command).toBe('zsh');
expect(element.sessionName).toBe(''); expect(element.sessionName).toBe('');
expect(element.isCreating).toBe(false); 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 { customElement, property, state } from 'lit/decorators.js';
import './file-browser.js'; import './file-browser.js';
import './quick-start-editor.js'; import './quick-start-editor.js';
import { DEFAULT_REPOSITORY_BASE_PATH } from '../../shared/constants.js';
import type { Session } from '../../shared/types.js'; import type { Session } from '../../shared/types.js';
import { TitleMode } from '../../shared/types.js'; import { TitleMode } from '../../shared/types.js';
import type { QuickStartCommand } from '../../types/config.js'; import type { QuickStartCommand } from '../../types/config.js';
@ -48,7 +49,7 @@ export class SessionCreateForm extends LitElement {
return this; return this;
} }
@property({ type: String }) workingDir = '~/'; @property({ type: String }) workingDir = DEFAULT_REPOSITORY_BASE_PATH;
@property({ type: String }) command = 'zsh'; @property({ type: String }) command = 'zsh';
@property({ type: String }) sessionName = ''; @property({ type: String }) sessionName = '';
@property({ type: Boolean }) disabled = false; @property({ type: Boolean }) disabled = false;
@ -156,19 +157,19 @@ export class SessionCreateForm extends LitElement {
const formData = loadSessionFormData(); const formData = loadSessionFormData();
// Get repository base path from server config to use as default working dir // Get repository base path from server config to use as default working dir
let appRepoBasePath = '~/'; let appRepoBasePath = DEFAULT_REPOSITORY_BASE_PATH;
if (this.serverConfigService) { if (this.serverConfigService) {
try { try {
appRepoBasePath = await this.serverConfigService.getRepositoryBasePath(); appRepoBasePath = await this.serverConfigService.getRepositoryBasePath();
} catch (error) { } catch (error) {
logger.error('Failed to get repository base path from server:', 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 // Always set values, using saved values or defaults
// Priority: savedWorkingDir > appRepoBasePath > default // Priority: savedWorkingDir > appRepoBasePath > default
this.workingDir = formData.workingDir || appRepoBasePath || '~/'; this.workingDir = formData.workingDir || appRepoBasePath || DEFAULT_REPOSITORY_BASE_PATH;
this.command = formData.command || 'zsh'; this.command = formData.command || 'zsh';
// For spawn window, use saved value or default to false // For spawn window, use saved value or default to false
@ -284,7 +285,7 @@ export class SessionCreateForm extends LitElement {
if (changedProperties.has('visible')) { if (changedProperties.has('visible')) {
if (this.visible) { if (this.visible) {
// Reset to defaults first to ensure clean state // Reset to defaults first to ensure clean state
this.workingDir = '~/'; this.workingDir = DEFAULT_REPOSITORY_BASE_PATH;
this.command = 'zsh'; this.command = 'zsh';
this.sessionName = ''; this.sessionName = '';
this.spawnWindow = false; 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" 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> </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> </button>
${ ${

View file

@ -1,5 +1,6 @@
import { html, LitElement, type PropertyValues } from 'lit'; import { html, LitElement, type PropertyValues } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; 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 { AuthClient } from '../services/auth-client.js';
import { import {
type NotificationPreferences, type NotificationPreferences,
@ -55,7 +56,7 @@ export class UnifiedSettings extends LitElement {
// App settings state // App settings state
@state() private appPreferences: AppPreferences = DEFAULT_APP_PREFERENCES; @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 mediaState: MediaQueryState = responsiveObserver.getCurrentState();
@state() private isServerConfigured = false; @state() private isServerConfigured = false;
@state() private repositoryCount = 0; @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() { private async loadAppPreferences() {
try { try {
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
@ -157,13 +167,16 @@ export class UnifiedSettings extends LitElement {
this.appPreferences = { ...DEFAULT_APP_PREFERENCES, ...JSON.parse(stored) }; this.appPreferences = { ...DEFAULT_APP_PREFERENCES, ...JSON.parse(stored) };
} }
// Fetch server configuration // Fetch server configuration - force refresh when dialog opens
if (this.serverConfigService) { if (this.serverConfigService) {
try { try {
const serverConfig = await this.serverConfigService.loadConfig(); const serverConfig = await this.serverConfigService.loadConfig(this.visible);
this.isServerConfigured = serverConfig.serverConfigured ?? false; this.isServerConfigured = serverConfig.serverConfigured ?? false;
// Always use server's repository base path // 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) { } catch (error) {
logger.warn('Failed to fetch server config', error); logger.warn('Failed to fetch server config', error);
} }

View file

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

View file

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

View file

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

View file

@ -34,6 +34,16 @@ export function createRepositoryRoutes(): Router {
logger.debug(`[GET /repositories/discover] Discovering repositories in: ${basePath}`); logger.debug(`[GET /repositories/discover] Discovering repositories in: ${basePath}`);
const expandedPath = resolvePath(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({ const repositories = await discoverRepositories({
basePath: expandedPath, basePath: expandedPath,
maxDepth, maxDepth,
@ -69,6 +79,8 @@ async function discoverRepositories(
const { basePath, maxDepth = 3 } = options; const { basePath, maxDepth = 3 } = options;
const repositories: DiscoveredRepository[] = []; const repositories: DiscoveredRepository[] = [];
logger.debug(`Starting repository discovery in ${basePath} with maxDepth=${maxDepth}`);
async function scanDirectory(dirPath: string, depth: number): Promise<void> { async function scanDirectory(dirPath: string, depth: number): Promise<void> {
if (depth > maxDepth) { if (depth > maxDepth) {
return; return;
@ -78,6 +90,23 @@ async function discoverRepositories(
// Check if directory is accessible // Check if directory is accessible
await fs.access(dirPath, fs.constants.R_OK); 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 }); const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
@ -88,20 +117,22 @@ async function discoverRepositories(
const fullPath = path.join(dirPath, entry.name); 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'); const gitPath = path.join(fullPath, '.git');
try { 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 // If .git exists (either as a file or directory), this is a git repository
const repository = await createDiscoveredRepository(fullPath); const repository = await createDiscoveredRepository(fullPath);
repositories.push(repository); repositories.push(repository);
logger.debug(`Found git repository: ${fullPath}`);
// Don't scan subdirectories of a git repository
} catch { } catch {
// .git doesn't exist, scan subdirectories // .git doesn't exist, scan subdirectories
await scanDirectory(fullPath, depth + 1); await scanDirectory(fullPath, depth + 1);
} }
} }
} catch (error) { } 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' }); return res.status(404).json({ error: 'Session not found' });
} }
await ptyManager.killSession(sessionId, 'SIGTERM'); // If session is already exited, clean it up instead of trying to kill it
logger.log(chalk.yellow(`local session ${sessionId} killed`)); if (session.status === 'exited') {
ptyManager.cleanupSession(sessionId);
res.json({ success: true, message: 'Session killed' }); 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) { } catch (error) {
logger.error('error killing session:', error); logger.error('error killing session:', error);
if (error instanceof PtyError) { 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'); 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 = [ const mockCompletions = [
{ {
name: 'readme.md', name: 'readme.md',
@ -238,6 +238,12 @@ describe('AutocompleteManager', () => {
type: 'directory' as const, type: 'directory' as const,
suggestion: '~/Documents/', suggestion: '~/Documents/',
}, },
{
name: 'Projects',
path: '~/Projects',
type: 'directory' as const,
suggestion: '~/Projects/',
},
]; ];
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
@ -247,8 +253,11 @@ describe('AutocompleteManager', () => {
const result = await manager.fetchCompletions('~/'); const result = await manager.fetchCompletions('~/');
// Should only have directories, no files
expect(result).toHaveLength(2);
expect(result[0].type).toBe('directory'); 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 () => { 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 { export interface QuickStartCommand {
name?: string; // Optional display name (can include emoji), if empty uses command name?: string; // Optional display name (can include emoji), if empty uses command
command: string; // The actual command to execute command: string; // The actual command to execute
@ -21,5 +23,5 @@ export const DEFAULT_QUICK_START_COMMANDS: QuickStartCommand[] = [
export const DEFAULT_CONFIG: VibeTunnelConfig = { export const DEFAULT_CONFIG: VibeTunnelConfig = {
version: 1, version: 1,
quickStartCommands: DEFAULT_QUICK_START_COMMANDS, quickStartCommands: DEFAULT_QUICK_START_COMMANDS,
repositoryBasePath: '~/', repositoryBasePath: DEFAULT_REPOSITORY_BASE_PATH,
}; };