mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-25 14:57:37 +00:00
Improve Quick Start editor UI consistency across web and macOS (#448)
This commit is contained in:
parent
bebf977d23
commit
102f6c5e33
24 changed files with 220 additions and 138 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
${
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ describe('Config Routes', () => {
|
|||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
repositoryBasePath: '~/',
|
||||
repositoryBasePath: '~/Documents',
|
||||
serverConfigured: true,
|
||||
quickStartCommands: defaultConfig.quickStartCommands,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
9
web/src/shared/constants.ts
Normal file
9
web/src/shared/constants.ts
Normal 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;
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue