mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +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/"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,7 @@ 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)
|
||||||
|
|
@ -22,18 +21,6 @@ struct QuickStartSettingsSection: View {
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
editingCommandId = nil
|
|
||||||
showingNewCommand = true
|
|
||||||
}, label: {
|
|
||||||
Label("Add", systemImage: "plus")
|
|
||||||
})
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.disabled(showingNewCommand)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commands list
|
// Commands list
|
||||||
List {
|
List {
|
||||||
ForEach(configManager.quickStartCommands) { command in
|
ForEach(configManager.quickStartCommands) { command in
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
|
||||||
return a.type === 'directory' ? -1 : 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;
|
||||||
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);
|
return a.name.localeCompare(b.name);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
<!-- Bottom actions -->
|
||||||
|
<div class="flex justify-between items-center mt-4">
|
||||||
<button
|
<button
|
||||||
id="quick-start-add-command-button"
|
id="quick-start-reset-button"
|
||||||
@click=${this.handleAddCommand}
|
@click=${this.handleResetToDefaults}
|
||||||
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"
|
class="text-primary hover:text-primary-hover text-[10px] transition-colors duration-200"
|
||||||
|
title="Reset to default commands"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
Reset to Defaults
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v12m6-6H6" />
|
|
||||||
</svg>
|
|
||||||
Add
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Bottom actions -->
|
<div class="flex gap-4 items-center">
|
||||||
<div class="flex justify-end gap-4 mt-4 pt-3 border-t border-border/30">
|
|
||||||
<button
|
<button
|
||||||
id="quick-start-delete-all-button"
|
id="quick-start-delete-all-button"
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
this.editableCommands = [];
|
this.editableCommands = [];
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}}
|
}}
|
||||||
class="text-error hover:text-error-hover text-[10px] transition-colors duration-200"
|
class="text-error hover:text-error-hover text-xs transition-colors duration-200"
|
||||||
>
|
>
|
||||||
Delete All
|
Delete All
|
||||||
</button>
|
</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>
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
${
|
${
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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');
|
await ptyManager.killSession(sessionId, 'SIGTERM');
|
||||||
logger.log(chalk.yellow(`local session ${sessionId} killed`));
|
logger.log(chalk.yellow(`local session ${sessionId} killed`));
|
||||||
|
|
||||||
res.json({ success: true, message: 'Session 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) {
|
||||||
|
|
|
||||||
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');
|
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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue