Add file-based quick start configuration and remove WebSocket sync (#436)

* feat(server): add configuration service with file-based persistence

- Implement ConfigService for managing application configurations
- Add REST API endpoints for quick start configuration
- Add comprehensive test coverage for config operations
- Define TypeScript types for configuration structures
- Replace WebSocket-based config sync with REST API

* feat(web): add quick start editor component

- Create fully-featured command editor with add, edit, delete functionality
- Add drag-and-drop reordering support
- Implement real-time validation and error handling
- Include comprehensive test coverage (100%)
- Support keyboard navigation and accessibility

* feat(web): add session status management

- Create session status dropdown component for state management
- Add session termination capabilities with proper cleanup
- Refactor session header to integrate status controls
- Support graceful disconnection and reconnection

* feat(mac): add directory autocomplete service

- Implement AutocompleteService for directory path completion
- Create AutocompleteView with native macOS UI
- Support home directory expansion (~/)
- Add system file browser integration
- Optimize performance for large directory structures

* feat(mac): add quick start settings interface

- Create SwiftUI settings view for managing quick start commands
- Implement inline editing with immediate feedback
- Add default commands for common workflows
- Include toggle for using current directory as default
- Add smooth animations and hover effects

* feat: integrate quick start in session creation

- Add quick start command buttons to session create form
- Implement directory picker with autocomplete support
- Update form layout for better user experience
- Add comprehensive tests for new functionality
- Sync quick start integration across web and native

* feat(web): add repository discovery to settings

- Add repository discovery functionality in unified settings
- Update settings UI to support new configuration options
- Remove WebSocket-based configuration sync
- Simplify settings component with REST API integration

* docs: add macOS quick start implementation guide

- Document architecture and implementation details
- Include code examples and best practices
- Explain integration between web and native components
- Provide troubleshooting guidance

* feat: enhance UI/UX across components

- Improve session list with better status indicators
- Update terminal binary detection and display
- Refine width selector and image upload interactions
- Clean up WebSocket control handler
- Update server configuration handling
- Fix notification status display
- Polish settings view layout

* feat(web): improve session header UI elements

- Position edit icon directly after text instead of far right
- Update magic wand icon with sparkles to match macOS design
- Change from grid to flex layout for better icon positioning
- Enhance visual consistency across platforms

* fix(web): improve OPTIONS label alignment in session create form

- Add flex-shrink-0 to chevron icon to prevent shrinking
- Add leading-none to OPTIONS label for better vertical alignment

* docs: update changelog for beta.14 with Quick Start features

- Document Quick Start Configuration System (#229, #250, #436)
- Add native macOS settings interface details
- Include session management improvements
- Add bug fixes and breaking changes
- Reference related issues and PRs

* feat(mac): add ConfigManager for synchronized quick start commands

- Create ConfigManager to sync commands with web UI config file
- Monitor ~/.vibetunnel/config.json for changes
- Replace UserDefaults with file-based configuration
- Ensure consistency between Mac app and web interface

* feat(mac): add ClickableURLView component

- Create reusable component for clickable URLs in SwiftUI
- Support for opening URLs in default browser
- Consistent styling across the app

* refactor(mac): simplify remote access settings UI

- Use ClickableURLView for consistent URL display
- Reduce code duplication in settings sections
- Improve maintainability of remote access views

* refactor(mac): integrate ConfigManager in NewSessionForm

- Replace direct UserDefaults access with ConfigManager
- Use synchronized quick start commands from shared config
- Improve command selection UI consistency

* fix(web): update terminal padding and termination handling

- Remove horizontal padding from terminal containers
- Implement proper session termination via DELETE API
- Keep vertical padding for better visual appearance
- Fix binary mode toggle styling

* fix(mac): resolve ConfigManager threading crash

- Fix main actor violation in file monitor callback
- Remove unsafe self reference in asyncAfter closure
- Capture monitor queue reference to avoid accessing self
- Ensure all @Published property updates happen on main thread

* feat(web): add reset to defaults button in quick start editor

- Add Reset to Defaults button for easy restoration
- Import DEFAULT_QUICK_START_COMMANDS from config types
- Improve user experience with quick command reset option

* fix(web): adjust OPTIONS chevron icon size

- Reduce chevron icon from 10x10 to 8x8 for better visual balance
- Update responsive size classes accordingly
- Remove leading-none from OPTIONS label for better alignment

* docs: update changelog with latest UI improvements and bug fixes

- Document ConfigManager and ClickableURLView additions
- Add AutocompleteService and reset to defaults features
- Include all UI fixes and threading crash resolution
- Document session header and terminal improvements

* fix(mac): fix ConfigManager threading crash when moving quick start items

- Remove background file monitor queue and use main queue directly
- ConfigManager is @MainActor so all operations must happen on main thread
- Simplify file monitor callback by removing unnecessary Task wrapper
- Fixes crash when reordering quick start commands in settings

* feat: add Zod validation for quick start configuration

- Add Zod dependency for runtime config validation
- Implement ConfigSchema to validate quick start commands
- Ensure commands have non-empty strings and valid structure
- Add validation on config load and update operations
- Fix auth headers for config API endpoints
- Remove unused repository path WebSocket handlers
- Update storage key for consistency

This improves config reliability and prevents invalid commands from being saved.

* docs: update changelog with comprehensive beta.14 release notes

- Reorganize changelog with clear user-focused sections
- Add detailed feature descriptions for Quick Start functionality
- Highlight UI/UX improvements with specific examples
- Document all bug fixes and stability improvements
- Include breaking changes and migration guidance
- Add emojis for better visual organization
- Expand technical details for developers

* fix: handle undefined activityStatus in session categorization

Sessions with undefined activityStatus were incorrectly shown as idle.
Now only sessions with explicitly false isActive are considered idle.

* feat: enhance quick start configuration with repository discovery

- Add repository discovery and filtering in AutocompleteService
- Support directory-only suggestions for quick start paths
- Improve autocomplete filtering to exclude hidden and system directories
- Update quick start settings UI with better directory selection
- Add tests for vibe-terminal-binary component
- Minor UI improvements to clickable URLs and form components

* fix: update tests for storage key change and terminal sizing

- Update repository-service tests to use new 'app_preferences' storage key
- Fix vibe-terminal-binary test to enable fitHorizontally for maxCols constraint
- Ensure tests align with recent configuration changes

* fix: update failing tests and improve repository status element

- Add id="repository-status" to repository counter for easier test selection
- Update quick-start-editor test to match actual button classes
- Fix all unified-settings tests to use the new repository-status ID
- Prevent tests from accidentally selecting unrelated elements

* docs: update beta 14 changelog to match earlier style

Simplified changelog format to be consistent with beta 13 and earlier versions,
making it more concise and easier to read.

* feat: add IDs to quick-start-editor elements for better testability

- Add id="quick-start-edit-button" to Edit button
- Add id="quick-start-save-button" to Save button
- Add id="quick-start-cancel-button" to Cancel button
- Add id="quick-start-reset-button" to Reset to Defaults button
- Add id="quick-start-add-command-button" to Add Command button
- Add dynamic IDs for remove buttons: quick-start-remove-command-{index}
- Add dynamic IDs for inputs: quick-start-name-input-{index}, quick-start-command-input-{index}
- Add dynamic IDs for command items: quick-start-command-item-{index}

This makes tests more maintainable by avoiding complex selectors that search by text content.

* test: update quick-start-editor tests to use element IDs

- Replace button text search with ID selectors for better reliability
- Update edit button selector to use #quick-start-edit-button
- Update add command button selector to use #quick-start-add-command-button
- Update reset button selectors to use #quick-start-reset-button

This demonstrates how the new IDs make tests more maintainable and less fragile.

* docs: rewrite beta 14 changelog with accurate feature descriptions

- Clarify that Quick Start commands became customizable (not new)
- Add accurate description of session status management dropdown
- Include proper technical details about systemd support
- Fix misleading descriptions about features
- Maintain concise style consistent with previous releases

* fix: remove obsolete tests and fix control-unix-handler tests

- Remove obsolete repository-path-sync.test.ts that was testing removed functionality
- Remove skipped session-view-drag-drop.test.ts that was causing import errors
- Fix control-unix-handler tests to test actual functionality instead of non-existent methods
- All tests now passing (1008 passed, 113 skipped)

* docs: update beta 14 contributors section

- Add Gopi as first-time contributor for ngrok clickable URLs (#422)
- Properly credit hewigovens and Claude as co-authors on systemd (#426)
- Remove duplicate first-time attribution for hewigovens

* docs: fix Claude contributor GitHub link to @claudemini

* docs: fix claudemini's contribution attribution

- claudemini improved theme toggle UI (PR #429/#438), not systemd
- List them as first-time contributor
- Keep Claude AI assistant as systemd co-author

* docs: remove incorrect AI assistant attribution

- There was no AI assistant Claude that co-authored systemd
- Keep only the correct contributors: 2 first-time and 1 additional

* fix: apply linter fixes for CI

- Fix optional chaining in test mock
- Fix unused parameters with underscore prefix
- Format quick-start-editor test file
- Keep any types in test file (acceptable for tests)

* fix: remove all any type warnings in tests

- Use proper ControlUnixHandler type import
- Type vi.importMock with typeof import('net')
- Type WebSocket mock with proper import type
- All lint warnings now resolved

* docs: add image upload feature to changelog

- Added image upload menu feature (#432)
- Also added theme toggle improvement (#438) that was missing

* refactor: add element IDs for improved test maintainability

- Added descriptive IDs to interactive elements across components
- Updated tests to use ID selectors instead of text-based queries
- Enhanced documentation with ID naming best practices
- Makes tests more reliable and less brittle to text changes

* fix: correct session-list test expectation for hideExited toggle

The component emits an event but doesn't directly change its state - the parent handles the state update

* docs: add more PR/issue references to beta 14 changelog

- Added fixes #368 for theme toggle improvement
- Added duplicate reference #421 for Chinese input issue #431
- All PR and issue references now properly documented

* fix: SwiftFormat modifier order and SystemControlHandler test race condition

* fix: repository scanner not showing discovered repositories

- Fixed storage key mismatch between 'app_preferences' and 'vibetunnel_app_preferences'
- Added missing authClient prop to unified-settings component in app.ts
- Updated all test files to use the correct storage key
- Repository scanner now correctly displays discovered repositories count

* refactor: extract session termination logic into reusable helper

- Create session-actions.ts utility for common session operations
- Refactor handleTerminateSession to use the new helper
- Fix handleClearSession to properly delete exited sessions before navigation
- Move Git branch indicator to header next to path in file browser
- Fix Options label alignment in session create form

* refactor: migrate repository base path from CLI arg to config.json

- Remove --repository-base-path command line argument
- Add repositoryBasePath to VibeTunnelConfig type and schema
- Update server to read/write repository path from config.json
- Refactor client to use ServerConfigService for repository path
- Update settings UI to manage path through server config API
- Ensure consistent naming with macOS app implementation

This simplifies configuration by using config.json as the single source
of truth for repository base path, with automatic file watching for
real-time updates.

* docs: remove --repository-base-path from README

This option was removed in the previous commit as repository base path
is now configured via config.json instead of command line arguments.

* fix: reduce Logs button border contrast and add comprehensive drag & drop tests

- Changed border opacity from 100% to 30% for softer appearance
- Created drag & drop tests with 21 passing tests covering:
  - Drag over/leave functionality
  - Drop handling for single and multiple files
  - Paste functionality with various UI states
  - Error handling and edge cases
- Fixed TypeScript lint errors in test files
- Disabled 2 visual overlay tests due to shadow DOM limitations in test environment

* fix: auto-format session-action-service.ts to pass CI checks

* fix: update tests to match refactored session action service implementation

- Fixed repository service tests by adding mock serverConfigService
- Updated session-card tests to expect DELETE endpoint instead of /cleanup
- Corrected error message expectations in session-card tests
- Fixed quick-start-editor test button class expectations
- Auto-formatted all files to pass CI checks

* fix: update session-action-service tests with proper mocks and window handling

- Added @vitest-environment happy-dom directive for DOM testing
- Set up proper mock for terminateSession to return success: true
- Mock window.dispatchEvent to prevent errors in test environment
- Fixed all test expectations to match refactored implementation

* fix: update server config tests to match refactored API implementation

- Updated config route tests to match new implementation that always returns serverConfigured: true
- Fixed error messages to match 'No valid updates provided' instead of 'Invalid quick start commands'
- Removed dependency on getRepositoryBasePath function, now using configService.repositoryBasePath
- Added tests for repository base path updates via PUT /api/config
- Added test for updating both repository path and quick start commands together

* fix: resolve remaining CI issues with type assertions and import ordering

* fix: update remaining tests and clean up imports

- Fix repository service test to include serverConfigService
- Update session card test error expectations
- Fix quick start editor button class tests
- Add proper mock setup for session action service
- Update server config tests for refactored API
- Clean up type assertions and import ordering

* fix: correct undefined runningSessions variable reference in Kill All button

The Kill All button was referencing a non-existent runningSessions variable after refactoring split it into activeSessions and idleSessions. Fixed by using the locally defined runningSessions variable in renderExitedControls() method.

* fix: improve Quick Start settings UI styling

- Use tertiaryLabelColor for better visual hierarchy
- Adjust opacity values for improved contrast
- Remove redundant "Quick Start" header

* fix: ensure thread safety in SystemControlHandlerTests

- Wrap notification flag updates in MainActor tasks
- Ensure test assertions run on MainActor
- Fixes potential race conditions in async tests

* fix: correct SwiftFormat modifier order in VibeTunnelApp

- Change 'weak static' to 'static weak' to match SwiftFormat rules
- Fixes CI linting failure

* fix: resolve Swift 6 concurrency errors in SystemControlHandlerTests

- Wrap MainActor property mutations in Task blocks for notification observers
- Fix test expectations to match actual path handling behavior
- SystemControlHandler stores paths as-is without tilde conversion

The CI failures were due to Swift 6's stricter concurrency checking
which prevented mutating MainActor-isolated properties from Sendable
closures. Also corrected the test expectation - the handler doesn't
convert paths to tilde notation, that only happens in UI components.

* fix: revert to correct SwiftFormat modifier order

- SwiftFormat expects 'weak static', not 'static weak'
- Fixes CI formatting check failure

* chore: trigger CI rebuild

* fix: resolve remaining CI issues with type assertions and import ordering

- Disable AppleScript tests on CI environment (not available in headless mode)
- Make SystemControlHandlerTests more robust with proper synchronization
- Add better error messages for test failures
- Fix exit code 126 issues by handling server startup failures gracefully

* Remove repository base path CLI argument from BunServer

- Server now reads repository base path directly from config.json
- Updated NewSessionForm to use ConfigManager for repository path
- Updated GeneralSettingsView to use ConfigManager instead of @AppStorage
- Updated ProjectFolderPageView to use ConfigManager
- Removed --repository-base-path CLI argument from BunServer
- RepositoryPathSyncService already updated to use ConfigManager

* Update tests to use ConfigManager and add CI diagnostics

- Updated RepositoryPathSyncServiceTests to use ConfigManager instead of UserDefaults
- Added diagnostic logging in BunServer for CI debugging when binary is not found
- Updated GitHub Actions workflows

* fix: simplify Mac CI by removing web artifact caching

- Remove web artifact download/upload between Node.js and Mac CI workflows
- Mac CI now builds web components directly via Xcode build process
- Eliminates file permission issues with artifact transfers
- Workflows can now run in parallel for faster CI
- Add better diagnostics for missing binary errors in BunServer

* fix: make tests properly fail when server binary is not available

- Update ServerManagerTests to require server binary presence
- Tests now fail with clear error if vibetunnel binary is missing
- Remove fallback logic that allowed tests to pass without binary
- ServerBinaryAvailableCondition now only checks app Resources folder

The server binary must be properly embedded in the Mac app's Resources
folder. Tests should not pass if the binary is missing, as this would
indicate a broken build.

* Refactor code to reduce duplication and improve structure

- Use AppConstants.getPreferredGitApp() instead of direct UserDefaults access
- Add getDashboardAccessMode() and setDashboardAccessMode() helpers to AppConstants
- Create GitAppHelper utility to centralize Git app preference logic
- Simplify GitRepositoryRow and SessionRow to use GitAppHelper
- Keep ConfigManager focused on server-needed config (quickStartCommands, repositoryBasePath)
- Mac-specific settings remain in UserDefaults with improved access patterns

* Move configuration enums to shared location

- Create ConfigurationEnums.swift with AuthenticationMode and TitleMode
- Remove duplicate enum definitions from UI files
- Fix ConfigManager to use correct enum case names (.osAuth instead of .os)
- Add description property to AuthenticationMode for UI display
- Proper separation of shared types from UI-specific code
This commit is contained in:
Peter Steinberger 2025-07-21 14:03:33 +02:00 committed by GitHub
parent 276dad95c9
commit f8a7cf9537
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
91 changed files with 7188 additions and 2717 deletions

View file

@ -52,7 +52,7 @@ jobs:
mac:
name: Mac CI
needs: [changes, node]
needs: [changes]
if: |
always() &&
!contains(needs.*.result, 'failure') &&

View file

@ -112,69 +112,11 @@ jobs:
echo "xcbeautify: $(xcbeautify --version || echo 'not found')"
echo "jq: $(which jq || echo 'not found')"
# Skip pnpm cache - testing if fresh installs are faster
# The cache was extremely large and might be slower than fresh install
- name: Download web artifacts from Node.js CI
uses: actions/download-artifact@v4
with:
name: web-build
path: web-artifacts-temp/
- name: Move web artifacts to correct location
run: |
# Debug: Show what was downloaded
echo "=== Contents of web-artifacts-temp ==="
find web-artifacts-temp -type f | head -20 || echo "No files found"
# Ensure web directory structure exists
mkdir -p web/dist web/public/bundle web/native web/bin
# The artifacts are uploaded without the web/ prefix
# So they're at web-artifacts-temp/dist, web-artifacts-temp/public/bundle, etc.
if [ -d "web-artifacts-temp/dist" ]; then
# Copy from the root of artifacts
cp -r web-artifacts-temp/dist/* web/dist/ 2>/dev/null || true
echo "Copied dist files"
fi
if [ -d "web-artifacts-temp/public/bundle" ]; then
cp -r web-artifacts-temp/public/bundle/* web/public/bundle/ 2>/dev/null || true
echo "Copied bundle files"
fi
if [ -d "web-artifacts-temp/native" ]; then
cp -r web-artifacts-temp/native/* web/native/ 2>/dev/null || true
echo "Copied native binaries"
fi
if [ -d "web-artifacts-temp/bin" ]; then
cp -r web-artifacts-temp/bin/* web/bin/ 2>/dev/null || true
echo "Copied bin scripts"
fi
# Debug: Show what we have
echo "=== Web directory structure ==="
ls -la web/ || true
echo "=== Dist contents ==="
ls -la web/dist/ | head -10 || true
echo "=== Bundle contents ==="
ls -la web/public/bundle/ | head -10 || true
echo "=== Native contents ==="
ls -la web/native/ | head -10 || true
# Clean up temp directory
rm -rf web-artifacts-temp
# Verify we have the required files
if [ ! -f "web/dist/server/server.js" ]; then
echo "ERROR: web/dist/server/server.js not found after artifact extraction!"
exit 1
fi
echo "Web artifacts successfully downloaded and positioned"
# No web artifact caching - Mac build will handle web build directly
- name: Resolve Dependencies (once)
env:
CI: "true" # Ensure CI environment variable is set
SKIP_NODE_CHECK: "true" # Skip Node.js check in CI since we download pre-built artifacts
run: |
echo "Resolving Swift package dependencies..."
# Workspace is at root level
@ -190,7 +132,6 @@ jobs:
id: build
env:
CI: "true" # Ensure CI environment variable is set for build scripts
SKIP_NODE_CHECK: "true" # Skip Node.js check in CI since we download pre-built artifacts
run: |
# Always use Debug for now to match test expectations
BUILD_CONFIG="Debug"
@ -235,18 +176,9 @@ jobs:
id: test-coverage
timeout-minutes: 20 # Increased from 15 for CI stability
env:
SKIP_NODE_CHECK: "true" # Skip Node.js check in CI since we download pre-built artifacts
RUN_SLOW_TESTS: "false" # Skip slow tests in CI by default
RUN_FLAKY_TESTS: "false" # Skip flaky tests in CI by default
run: |
# Debug: Check if web build artifacts were downloaded
echo "=== Checking web build artifacts ==="
if [ -d "web/dist" ]; then
echo "web/dist exists"
ls -la web/dist | head -10
else
echo "web/dist does not exist"
fi
# Use xcodebuild test for workspace testing
# Only enable coverage on main branch

View file

@ -266,25 +266,7 @@ jobs:
web/coverage/client/lcov.info
web/coverage/server/lcov.info
- name: List build artifacts before upload
working-directory: web
run: |
echo "=== Contents of dist directory ==="
find dist -type f | head -20 || echo "No files in dist"
echo "=== Contents of public/bundle directory ==="
find public/bundle -type f | head -20 || echo "No files in public/bundle"
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: web-build
path: |
web/dist/
web/public/bundle/
web/native/
web/bin/vt
retention-days: 1
if-no-files-found: error
# Build artifacts no longer uploaded - Mac CI builds web as part of Xcode build
type-check:
name: TypeScript Type Checking

View file

@ -1,35 +1,69 @@
# Changelog
## [1.0.0-beta.14] - 2025-07-20
## [1.0.0-beta.14] - 2025-07-21
### **Linux Systemd Support**
- Added systemd service management for Linux deployments (#426)
- Simple installation with `vibetunnel systemd` command
- User-level service with automatic startup on boot
- Smart wrapper script handles nvm/fnm Node.js installations
- Comprehensive documentation at `web/docs/systemd.md`
#### **Customizable Quick Start Commands**
- Quick Start commands are now fully customizable - previously hardcoded buttons can be edited
- Add your own commands with custom names and emoji (e.g., "✨ claude" or "▶️ dev server")
- Drag & drop reordering with smooth animations in macOS settings
- Inline editing without popup dialogs
- Reset to defaults button when you want the original set back
- File-based persistence in `~/.vibetunnel/config.json`
### **Enhanced Terminal Experience**
- ngrok URLs are now clickable in terminal output (#422)
#### **New Session Path Autocomplete**
- Intelligent path autocomplete when creating sessions (#435)
- Home directory expansion (`~/` shortcuts work properly)
- Visual file browser with folder icon
- Git repository discovery in selected directories
- Repository status shown in welcome screen
### **Infrastructure Improvements**
- Major codebase cleanup and simplification (#419)
- Improved release scripts and Node.js detection
- Enhanced CI/CD pipeline reliability
#### **Session Status Management**
- New dropdown menu in session headers for running/exited sessions
- Terminate running sessions without closing the tab
- Clear exited sessions individually with one click
- Visual status indicators - pulsing dot for running, static for exited
- Keyboard navigation support (Arrow keys, Enter, Escape)
### 📚 Documentation
- Added systemd documentation for Linux users
- Created release guide with troubleshooting steps
- Updated README with clearer Linux instructions
#### **Linux Systemd Support** (#426)
- Run VibeTunnel as a persistent service with `vibetunnel systemd install`
- User-level service - no root required
- Automatic startup on boot
- Smart Node.js detection works with nvm, fnm, or global npm
- Comprehensive systemd commands for status, logs, start/stop
#### **UI Improvements**
- New image upload menu with paste, select from library, camera, and browse options (#432)
- Improves experimental binary terminal mode (no more long scrolling - see Terminal Settings)
- Clickable ngrok URLs in Settings with copy button (#422)
- Cleaner session headers with better-positioned controls
- Fixed magic wand icon alignment for AI sessions
- Improved theme toggle with better icon and tooltips (#438, fixes #368)
### 🐛 Bug Fixes
- Fixed incorrect systemd documentation
- Resolved release script Node.js detection issues
- Fixed Mintlify deployment configuration
- Fixed session timers continuing to run after sessions exited (#428)
- Fixed sessions with undefined activity status showing as idle instead of active
- Fixed new session dialog styling for dark mode (#433)
- Fixed Mintlify documentation generation (#434)
- Fixed ConfigManager threading crash when moving quick start items in macOS
- Improved Chinese input method support (#431, duplicate of #421)
- Removed legacy WebSocket config sync code and simplify logic
#### **Under the Hood**
- New configuration service with file watching and validation
- Zod schema validation for all configuration data
- Improved test maintainability by adding element IDs to web components
- REST API at `/api/config/quick-start` replacing WebSocket sync
- Major codebase cleanup - removed Tauri project and 17k lines of unused code (#419)
- Enhanced release process with better troubleshooting documentation
### 👥 Contributors
First-time contributors to VibeTunnel:
- [@hewigovens](https://github.com/hewigovens) - Added systemd service management for Linux deployments (#426)
- [@gopi-kori](https://github.com/gopi-kori) - Made ngrok URLs clickable with copy button in Settings (#422)
- [@claudemini](https://github.com/claudemini) - Improved theme toggle UI with better icon and tooltips (cherry-picked from #429 into #438, fixes #368)
Additional contributors:
- [@hewigovens](https://github.com/hewigovens) - Co-authored systemd service management for Linux deployments (#426)
## [1.0.0-beta.13] - 2025-07-19

View file

@ -14,7 +14,6 @@ enum AppConstants {
enum UserDefaultsKeys {
static let welcomeVersion = "welcomeVersion"
static let preventSleepWhenRunning = "preventSleepWhenRunning"
static let repositoryBasePath = "repositoryBasePath"
// Server Configuration
static let serverPort = "serverPort"
@ -39,6 +38,9 @@ enum AppConstants {
static let newSessionWorkingDirectory = "NewSession.workingDirectory"
static let newSessionSpawnWindow = "NewSession.spawnWindow"
static let newSessionTitleMode = "NewSession.titleMode"
/// Quick Start Commands
static let quickStartCommands = "quickStartCommands"
}
/// Raw string values for DashboardAccessMode
@ -51,8 +53,6 @@ enum AppConstants {
enum Defaults {
/// Sleep prevention is enabled by default for better user experience
static let preventSleepWhenRunning = true
/// Default repository base path for auto-discovery
static let repositoryBasePath = "~/"
// Server Configuration
static let serverPort = 4_020
@ -103,8 +103,6 @@ enum AppConstants {
// If the key doesn't exist at all, return our default
if UserDefaults.standard.object(forKey: key) == nil {
switch key {
case UserDefaultsKeys.repositoryBasePath:
return Defaults.repositoryBasePath
case UserDefaultsKeys.dashboardAccessMode:
return Defaults.dashboardAccessMode
case UserDefaultsKeys.authenticationMode:
@ -251,4 +249,15 @@ extension AppConstants {
UserDefaults.standard.removeObject(forKey: UserDefaultsKeys.preferredTerminal)
}
}
/// Get current dashboard access mode
static func getDashboardAccessMode() -> DashboardAccessMode {
let rawValue = stringValue(for: UserDefaultsKeys.dashboardAccessMode)
return DashboardAccessMode(rawValue: rawValue) ?? .network
}
/// Set dashboard access mode
static func setDashboardAccessMode(_ mode: DashboardAccessMode) {
UserDefaults.standard.set(mode.rawValue, forKey: UserDefaultsKeys.dashboardAccessMode)
}
}

View file

@ -0,0 +1,48 @@
import Foundation
/// Shared configuration enums used across the application
// MARK: - Authentication Mode
enum AuthenticationMode: String, CaseIterable {
case none = "none"
case osAuth = "os"
case sshKeys = "ssh"
case both = "both"
var displayName: String {
switch self {
case .none: "None"
case .osAuth: "macOS"
case .sshKeys: "SSH Keys"
case .both: "macOS + SSH Keys"
}
}
var description: String {
switch self {
case .none: "Anyone can access the dashboard (not recommended)"
case .osAuth: "Use your macOS username and password"
case .sshKeys: "Use SSH keys from ~/.ssh/authorized_keys"
case .both: "Allow both authentication methods"
}
}
}
// MARK: - Title Mode
enum TitleMode: String, CaseIterable {
case none = "none"
case filter = "filter"
case `static` = "static"
case dynamic = "dynamic"
var displayName: String {
switch self {
case .none: "None"
case .filter: "Filter"
case .static: "Static"
case .dynamic: "Dynamic"
}
}
}

View file

@ -0,0 +1,266 @@
import AppKit
import Foundation
/// Service for providing path autocompletion suggestions
@MainActor
class AutocompleteService: ObservableObject {
@Published private(set) var isLoading = false
@Published private(set) var suggestions: [PathSuggestion] = []
private var currentTask: Task<Void, Never>?
private let fileManager = FileManager.default
struct PathSuggestion: Identifiable, Equatable {
let id = UUID()
let name: String
let path: String
let type: SuggestionType
let suggestion: String // The complete path to insert
let isRepository: Bool
enum SuggestionType {
case file
case directory
}
}
/// Fetch autocomplete suggestions for the given path
func fetchSuggestions(for partialPath: String) async {
// Cancel any existing task
currentTask?.cancel()
guard !partialPath.isEmpty else {
suggestions = []
return
}
currentTask = Task {
await performFetch(for: partialPath)
}
}
private func performFetch(for originalPath: String) async {
isLoading = true
defer { isLoading = false }
var partialPath = originalPath
// Handle tilde expansion
if partialPath.hasPrefix("~") {
let homeDir = NSHomeDirectory()
if partialPath == "~" {
partialPath = homeDir
} else if partialPath.hasPrefix("~/") {
partialPath = homeDir + partialPath.dropFirst(1)
}
}
// Determine directory and partial filename
let (dirPath, partialName) = splitPath(partialPath)
// Check if task was cancelled
if Task.isCancelled { return }
// Get suggestions from filesystem
let fsSuggestions = await getFileSystemSuggestions(
directory: dirPath,
partialName: partialName,
originalPath: originalPath
)
// Check if task was cancelled
if Task.isCancelled { return }
// Also get git repository suggestions if searching by name
let isSearchingByName = !originalPath.contains("/") ||
(originalPath.split(separator: "/").count == 1 && !originalPath.hasSuffix("/"))
var allSuggestions = fsSuggestions
if isSearchingByName {
// Get git repository suggestions from discovered repositories
let repoSuggestions = await getRepositorySuggestions(searchTerm: originalPath)
// Merge with filesystem suggestions, avoiding duplicates
let existingPaths = Set(fsSuggestions.map(\.suggestion))
let uniqueRepos = repoSuggestions.filter { !existingPaths.contains($0.suggestion) }
allSuggestions.append(contentsOf: uniqueRepos)
}
// Sort suggestions
let sortedSuggestions = sortSuggestions(allSuggestions, searchTerm: partialName)
// Limit to 20 results
suggestions = Array(sortedSuggestions.prefix(20))
}
private func splitPath(_ path: String) -> (directory: String, partialName: String) {
if path.hasSuffix("/") {
return (path, "")
} else {
let url = URL(fileURLWithPath: path)
return (url.deletingLastPathComponent().path, url.lastPathComponent)
}
}
private func getFileSystemSuggestions(
directory: String,
partialName: String,
originalPath: String
)
async -> [PathSuggestion]
{
let expandedDir = NSString(string: directory).expandingTildeInPath
guard fileManager.fileExists(atPath: expandedDir) else {
return []
}
do {
let contents = try fileManager.contentsOfDirectory(atPath: expandedDir)
return contents.compactMap { filename in
// Filter by partial name (case-insensitive)
if !partialName.isEmpty &&
!filename.lowercased().hasPrefix(partialName.lowercased())
{
return nil
}
// Skip hidden files unless explicitly searching for them
if !partialName.hasPrefix(".") && filename.hasPrefix(".") {
return nil
}
let fullPath = (expandedDir as NSString).appendingPathComponent(filename)
var isDirectory: ObjCBool = false
fileManager.fileExists(atPath: fullPath, isDirectory: &isDirectory)
// Build display path
let displayPath: String = if originalPath.hasSuffix("/") {
originalPath + filename
} else {
if let lastSlash = originalPath.lastIndex(of: "/") {
String(originalPath[..<originalPath.index(after: lastSlash)]) + filename
} else {
filename
}
}
// Check if it's a git repository
let isGitRepo = isDirectory.boolValue &&
fileManager.fileExists(atPath: (fullPath as NSString).appendingPathComponent(".git"))
return PathSuggestion(
name: filename,
path: displayPath,
type: isDirectory.boolValue ? .directory : .file,
suggestion: isDirectory.boolValue ? displayPath + "/" : displayPath,
isRepository: isGitRepo
)
}
} catch {
return []
}
}
private func sortSuggestions(_ suggestions: [PathSuggestion], searchTerm: String) -> [PathSuggestion] {
let lowercasedTerm = searchTerm.lowercased()
return suggestions.sorted { first, second in
// Direct name matches come first
let firstNameMatch = first.name.lowercased() == lowercasedTerm
let secondNameMatch = second.name.lowercased() == lowercasedTerm
if firstNameMatch != secondNameMatch {
return firstNameMatch
}
// Name starts with search term
let firstStartsWith = first.name.lowercased().hasPrefix(lowercasedTerm)
let secondStartsWith = second.name.lowercased().hasPrefix(lowercasedTerm)
if firstStartsWith != secondStartsWith {
return firstStartsWith
}
// Directories before files
if first.type != second.type {
return first.type == .directory
}
// Git repositories before regular directories
if first.type == .directory && second.type == .directory {
if first.isRepository != second.isRepository {
return first.isRepository
}
}
// Alphabetical order
return first.name.localizedCompare(second.name) == .orderedAscending
}
}
/// Clear all suggestions
func clearSuggestions() {
currentTask?.cancel()
suggestions = []
}
private func getRepositorySuggestions(searchTerm: String) async -> [PathSuggestion] {
// Since we can't directly access RepositoryDiscoveryService from here,
// we'll need to discover repositories inline or pass them as a parameter
// For now, let's scan common locations for git repositories
let searchLower = searchTerm.lowercased().replacingOccurrences(of: "~/", with: "")
let homeDir = NSHomeDirectory()
let commonPaths = [
homeDir + "/Developer",
homeDir + "/Projects",
homeDir + "/Documents",
homeDir + "/Desktop",
homeDir + "/Code",
homeDir + "/repos",
homeDir + "/git"
]
var repositories: [PathSuggestion] = []
for basePath in commonPaths {
guard fileManager.fileExists(atPath: basePath) else { continue }
do {
let contents = try fileManager.contentsOfDirectory(atPath: basePath)
for item in contents {
let fullPath = (basePath as NSString).appendingPathComponent(item)
var isDirectory: ObjCBool = false
guard fileManager.fileExists(atPath: fullPath, isDirectory: &isDirectory),
isDirectory.boolValue else { continue }
// Check if it's a git repository
let gitPath = (fullPath as NSString).appendingPathComponent(".git")
guard fileManager.fileExists(atPath: gitPath) else { continue }
// Check if name matches search term
guard item.lowercased().contains(searchLower) else { continue }
// Convert to tilde path if in home directory
let displayPath = fullPath.hasPrefix(homeDir) ?
"~" + fullPath.dropFirst(homeDir.count) : fullPath
repositories.append(PathSuggestion(
name: item,
path: displayPath,
type: .directory,
suggestion: displayPath + "/",
isRepository: true
))
}
} catch {
// Ignore errors for individual directories
continue
}
}
return repositories
}
}

View file

@ -121,13 +121,33 @@ final class BunServer {
guard let binaryPath = Bundle.main.path(forResource: "vibetunnel", ofType: nil) else {
let error = BunServerError.binaryNotFound
logger.error("vibetunnel binary not found in bundle")
// Additional diagnostics for CI debugging
logger.error("Bundle path: \(Bundle.main.bundlePath)")
logger.error("Resources path: \(Bundle.main.resourcePath ?? "nil")")
// List contents of Resources directory
if let resourcesPath = Bundle.main.resourcePath {
do {
let contents = try FileManager.default.contentsOfDirectory(atPath: resourcesPath)
logger.error("Resources directory contents: \(contents.joined(separator: ", "))")
} catch {
logger.error("Failed to list Resources directory: \(error)")
}
}
throw error
}
logger.info("Using Bun executable at: \(binaryPath)")
// Ensure binary is executable
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binaryPath)
do {
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binaryPath)
} catch {
logger.error("Failed to set executable permissions on binary: \(error.localizedDescription)")
throw BunServerError.binaryNotFound
}
// Verify binary exists and is executable
var isDirectory: ObjCBool = false
@ -193,12 +213,8 @@ final class BunServer {
logger.info("Local authentication bypass enabled for Mac app")
}
// Add repository base path
let repositoryBasePath = AppConstants.stringValue(for: AppConstants.UserDefaultsKeys.repositoryBasePath)
if !repositoryBasePath.isEmpty {
vibetunnelArgs.append(contentsOf: ["--repository-base-path", repositoryBasePath])
logger.info("Repository base path: \(repositoryBasePath)")
}
// Repository base path is now loaded from config.json by the server
// No CLI argument needed
// Create wrapper to run vibetunnel with parent death monitoring AND crash detection
let parentPid = ProcessInfo.processInfo.processIdentifier
@ -301,8 +317,11 @@ final class BunServer {
if !process.isRunning {
let exitCode = process.terminationStatus
// Special handling for exit code 9 (port in use)
if exitCode == 9 {
// Special handling for specific exit codes
if exitCode == 126 {
logger.error("Process exited immediately: Command not executable (exit code: 126)")
throw BunServerError.binaryNotFound
} else if exitCode == 9 {
logger.error("Process exited immediately: Port \(self.port) is already in use (exit code: 9)")
} else {
logger.error("Process exited immediately with code: \(exitCode)")

View file

@ -0,0 +1,413 @@
import Combine
import Foundation
import OSLog
/// Manager for VibeTunnel configuration stored in ~/.vibetunnel/config.json
/// Provides centralized configuration management for all app settings
@MainActor
class ConfigManager: ObservableObject {
static let shared = ConfigManager()
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "ConfigManager")
private let configDir: URL
private let configPath: URL
private var fileMonitor: DispatchSourceFileSystemObject?
// Core configuration
@Published private(set) var quickStartCommands: [QuickStartCommand] = []
@Published var repositoryBasePath: String = "~/"
// Server settings
@Published var serverPort: Int = 4020
@Published var dashboardAccessMode: DashboardAccessMode = .network
@Published var cleanupOnStartup: Bool = true
@Published var authenticationMode: AuthenticationMode = .osAuth
// Development settings
@Published var debugMode: Bool = false
@Published var useDevServer: Bool = false
@Published var devServerPath: String = ""
@Published var logLevel: String = "info"
// Application preferences
@Published var preferredGitApp: String?
@Published var preferredTerminal: String?
@Published var updateChannel: UpdateChannel = .stable
@Published var showInDock: Bool = false
@Published var preventSleepWhenRunning: Bool = true
// Remote access
@Published var ngrokEnabled: Bool = false
@Published var ngrokTokenPresent: Bool = false
// Session defaults
@Published var sessionCommand: String = "zsh"
@Published var sessionWorkingDirectory: String = "~/"
@Published var sessionSpawnWindow: Bool = true
@Published var sessionTitleMode: TitleMode = .dynamic
/// Quick start command structure matching the web interface
struct QuickStartCommand: Identifiable, Codable, Equatable {
var id: String
var name: String?
var command: String
/// Display name for the UI - uses name if available, otherwise command
var displayName: String {
name ?? command
}
init(id: String = UUID().uuidString, name: String? = nil, command: String) {
self.id = id
self.name = name
self.command = command
}
/// Custom Codable implementation to handle missing id
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString
self.name = try container.decodeIfPresent(String.self, forKey: .name)
self.command = try container.decode(String.self, forKey: .command)
}
private enum CodingKeys: String, CodingKey {
case id
case name
case command
}
}
/// Comprehensive configuration structure
private struct VibeTunnelConfig: Codable {
let version: Int
var quickStartCommands: [QuickStartCommand]
var repositoryBasePath: String?
// Extended configuration sections
var server: ServerConfig?
var development: DevelopmentConfig?
var preferences: PreferencesConfig?
var remoteAccess: RemoteAccessConfig?
var sessionDefaults: SessionDefaultsConfig?
}
// MARK: - Configuration Sub-structures
private struct ServerConfig: Codable {
var port: Int
var dashboardAccessMode: String
var cleanupOnStartup: Bool
var authenticationMode: String
}
private struct DevelopmentConfig: Codable {
var debugMode: Bool
var useDevServer: Bool
var devServerPath: String
var logLevel: String
}
private struct PreferencesConfig: Codable {
var preferredGitApp: String?
var preferredTerminal: String?
var updateChannel: String
var showInDock: Bool
var preventSleepWhenRunning: Bool
}
private struct RemoteAccessConfig: Codable {
var ngrokEnabled: Bool
var ngrokTokenPresent: Bool
}
private struct SessionDefaultsConfig: Codable {
var command: String
var workingDirectory: String
var spawnWindow: Bool
var titleMode: String
}
/// Default commands matching web/src/types/config.ts
private let defaultCommands = [
QuickStartCommand(name: "✨ claude", command: "claude"),
QuickStartCommand(name: "✨ gemini", command: "gemini"),
QuickStartCommand(name: nil, command: "zsh"),
QuickStartCommand(name: nil, command: "python3"),
QuickStartCommand(name: nil, command: "node"),
QuickStartCommand(name: "▶️ pnpm run dev", command: "pnpm run dev")
]
private init() {
let homeDir = FileManager.default.homeDirectoryForCurrentUser
self.configDir = homeDir.appendingPathComponent(".vibetunnel")
self.configPath = configDir.appendingPathComponent("config.json")
// Load initial configuration
loadConfiguration()
// Start monitoring for changes
startFileMonitoring()
}
// MARK: - Configuration Loading
private func loadConfiguration() {
// Ensure directory exists
try? FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true)
if FileManager.default.fileExists(atPath: configPath.path) {
do {
let data = try Data(contentsOf: configPath)
let config = try JSONDecoder().decode(VibeTunnelConfig.self, from: data)
// Load all configuration values
self.quickStartCommands = config.quickStartCommands
self.repositoryBasePath = config.repositoryBasePath ?? "~/"
// Server settings
if let server = config.server {
self.serverPort = server.port
self.dashboardAccessMode = DashboardAccessMode(rawValue: server.dashboardAccessMode) ?? .network
self.cleanupOnStartup = server.cleanupOnStartup
self.authenticationMode = AuthenticationMode(rawValue: server.authenticationMode) ?? .osAuth
}
// Development settings
if let dev = config.development {
self.debugMode = dev.debugMode
self.useDevServer = dev.useDevServer
self.devServerPath = dev.devServerPath
self.logLevel = dev.logLevel
}
// Preferences
if let prefs = config.preferences {
self.preferredGitApp = prefs.preferredGitApp
self.preferredTerminal = prefs.preferredTerminal
self.updateChannel = UpdateChannel(rawValue: prefs.updateChannel) ?? .stable
self.showInDock = prefs.showInDock
self.preventSleepWhenRunning = prefs.preventSleepWhenRunning
}
// Remote access
if let remote = config.remoteAccess {
self.ngrokEnabled = remote.ngrokEnabled
self.ngrokTokenPresent = remote.ngrokTokenPresent
}
// Session defaults
if let session = config.sessionDefaults {
self.sessionCommand = session.command
self.sessionWorkingDirectory = session.workingDirectory
self.sessionSpawnWindow = session.spawnWindow
self.sessionTitleMode = TitleMode(rawValue: session.titleMode) ?? .dynamic
}
logger.info("Loaded configuration from disk")
} catch {
logger.error("Failed to load config: \(error.localizedDescription)")
useDefaults()
}
} else {
logger.info("No config file found, creating with defaults")
useDefaults()
}
}
private func useDefaults() {
self.quickStartCommands = defaultCommands
self.repositoryBasePath = "~/"
saveConfiguration()
}
// MARK: - Configuration Saving
private func saveConfiguration() {
var config = VibeTunnelConfig(
version: 2,
quickStartCommands: quickStartCommands,
repositoryBasePath: repositoryBasePath
)
// Server configuration
config.server = ServerConfig(
port: serverPort,
dashboardAccessMode: dashboardAccessMode.rawValue,
cleanupOnStartup: cleanupOnStartup,
authenticationMode: authenticationMode.rawValue
)
// Development configuration
config.development = DevelopmentConfig(
debugMode: debugMode,
useDevServer: useDevServer,
devServerPath: devServerPath,
logLevel: logLevel
)
// Preferences
config.preferences = PreferencesConfig(
preferredGitApp: preferredGitApp,
preferredTerminal: preferredTerminal,
updateChannel: updateChannel.rawValue,
showInDock: showInDock,
preventSleepWhenRunning: preventSleepWhenRunning
)
// Remote access
config.remoteAccess = RemoteAccessConfig(
ngrokEnabled: ngrokEnabled,
ngrokTokenPresent: ngrokTokenPresent
)
// Session defaults
config.sessionDefaults = SessionDefaultsConfig(
command: sessionCommand,
workingDirectory: sessionWorkingDirectory,
spawnWindow: sessionSpawnWindow,
titleMode: sessionTitleMode.rawValue
)
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(config)
// Ensure directory exists
try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true)
// Write atomically to prevent corruption
try data.write(to: configPath, options: .atomic)
logger.info("Saved configuration to disk")
} catch {
logger.error("Failed to save config: \(error.localizedDescription)")
}
}
// MARK: - File Monitoring
private func startFileMonitoring() {
// Stop any existing monitor
stopFileMonitoring()
// Create file descriptor
let fileDescriptor = open(configPath.path, O_EVTONLY)
guard fileDescriptor != -1 else {
logger.warning("Could not open config file for monitoring")
return
}
// Create dispatch source on main queue since ConfigManager is @MainActor
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fileDescriptor,
eventMask: [.write, .delete, .rename],
queue: .main
)
source.setEventHandler { [weak self] in
guard let self else { return }
// Debounce rapid changes
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
guard let self else { return }
self.logger.info("Configuration file changed, reloading...")
let oldCommands = self.quickStartCommands
self.loadConfiguration()
// Only log if commands actually changed
if oldCommands != self.quickStartCommands {
self.logger.info("Quick start commands updated")
}
}
}
source.setCancelHandler {
close(fileDescriptor)
}
source.resume()
self.fileMonitor = source
logger.info("Started monitoring configuration file")
}
private func stopFileMonitoring() {
fileMonitor?.cancel()
fileMonitor = nil
}
// MARK: - Public API
/// Update quick start commands
func updateQuickStartCommands(_ commands: [QuickStartCommand]) {
guard commands != quickStartCommands else { return }
self.quickStartCommands = commands
saveConfiguration()
logger.info("Updated quick start commands: \(commands.count) items")
}
/// Reset to default commands
func resetToDefaults() {
updateQuickStartCommands(defaultCommands)
logger.info("Reset quick start commands to defaults")
}
/// Add a new command
func addCommand(name: String?, command: String) {
var commands = quickStartCommands
commands.append(QuickStartCommand(name: name, command: command))
updateQuickStartCommands(commands)
}
/// Update an existing command
func updateCommand(id: String, name: String?, command: String) {
var commands = quickStartCommands
if let index = commands.firstIndex(where: { $0.id == id }) {
commands[index].name = name
commands[index].command = command
updateQuickStartCommands(commands)
}
}
/// Delete a command
func deleteCommand(id: String) {
var commands = quickStartCommands
commands.removeAll { $0.id == id }
updateQuickStartCommands(commands)
}
/// Delete all commands (clear the list)
func deleteAllCommands() {
updateQuickStartCommands([])
logger.info("Deleted all quick start commands")
}
/// Move commands for drag and drop reordering
func moveCommands(from source: IndexSet, to destination: Int) {
var commands = quickStartCommands
commands.move(fromOffsets: source, toOffset: destination)
updateQuickStartCommands(commands)
logger.info("Reordered quick start commands")
}
/// Update repository base path
func updateRepositoryBasePath(_ path: String) {
guard path != repositoryBasePath else { return }
self.repositoryBasePath = path
saveConfiguration()
logger.info("Updated repository base path to: \(path)")
}
/// Get the configuration file path for debugging
var configurationPath: String {
configPath.path
}
deinit {
// File monitoring will be cleaned up automatically
}
}

View file

@ -24,8 +24,8 @@ final class RepositoryPathSyncService {
// MARK: - Private Methods
private func setupObserver() {
// Monitor UserDefaults changes for repository base path
UserDefaults.standard.publisher(for: \.repositoryBasePath)
// Monitor ConfigManager changes for repository base path
ConfigManager.shared.$repositoryBasePath
.removeDuplicates()
.dropFirst() // Skip initial value on startup
.sink { [weak self] newPath in
@ -76,7 +76,7 @@ final class RepositoryPathSyncService {
return
}
let path = newPath ?? AppConstants.Defaults.repositoryBasePath
let path = newPath ?? "~/"
// Skip if we've already sent this path
guard path != lastSentPath else {
@ -111,7 +111,7 @@ final class RepositoryPathSyncService {
/// Manually trigger a path sync (useful after initial connection)
func syncCurrentPath() async {
let path = AppConstants.stringValue(for: AppConstants.UserDefaultsKeys.repositoryBasePath)
let path = ConfigManager.shared.repositoryBasePath
logger.info("🔄 Manually syncing repository path: \(path)")
@ -139,19 +139,6 @@ final class RepositoryPathSyncService {
}
}
// MARK: - UserDefaults Extension
extension UserDefaults {
@objc fileprivate dynamic var repositoryBasePath: String {
get {
string(forKey: AppConstants.UserDefaultsKeys.repositoryBasePath) ??
AppConstants.Defaults.repositoryBasePath
}
set {
set(newValue, forKey: AppConstants.UserDefaultsKeys.repositoryBasePath)
}
}
}
// MARK: - Notification Names

View file

@ -37,8 +37,6 @@ final class SystemControlHandler {
return await handleReadyEvent(data)
case "ping":
return await handlePingRequest(data)
case "repository-path-update":
return await handleRepositoryPathUpdate(data)
default:
logger.error("Unknown system action: \(action)")
return createErrorResponse(for: data, error: "Unknown system action: \(action)")
@ -84,69 +82,6 @@ final class SystemControlHandler {
}
}
private func handleRepositoryPathUpdate(_ data: Data) async -> Data? {
do {
// Decode the message to get the path and source
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let id = json["id"] as? String,
let payload = json["payload"] as? [String: Any],
let newPath = payload["path"] as? String,
let source = payload["source"] as? String
{
logger.info("Repository path update from \(source): \(newPath)")
// Only process if it's from web
if source == "web" {
// Get current path
let currentPath = UserDefaults.standard
.string(forKey: AppConstants.UserDefaultsKeys.repositoryBasePath)
// Only update if different
if currentPath != newPath {
// Update UserDefaults
UserDefaults.standard.set(newPath, forKey: AppConstants.UserDefaultsKeys.repositoryBasePath)
// Post notification to temporarily disable sync to prevent loop
NotificationCenter.default.post(name: .disablePathSync, object: nil)
// Re-enable sync after a delay
Task {
try await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
NotificationCenter.default.post(name: .enablePathSync, object: nil)
}
logger.info("✅ Updated repository path from web: \(newPath)")
}
}
// Create success response
let responsePayload: [String: Any] = [
"success": true,
"path": newPath
]
let response: [String: Any] = [
"id": id,
"type": "response",
"category": "system",
"action": "repository-path-update",
"payload": responsePayload
]
return try JSONSerialization.data(withJSONObject: response)
} else {
logger.error("Invalid repository path update format")
return createErrorResponse(for: data, error: "Invalid repository path update format")
}
} catch {
logger.error("Failed to handle repository path update: \(error)")
return createErrorResponse(
for: data,
error: "Failed to process repository path update: \(error.localizedDescription)"
)
}
}
// MARK: - Error Handling
private func createErrorResponse(for data: Data, error: String) -> Data? {

View file

@ -0,0 +1,24 @@
import Foundation
/// Helper utilities for Git app preferences
enum GitAppHelper {
/// Get the display name of the preferred Git app or a default
static func getPreferredGitAppName() -> String {
if let preferredApp = AppConstants.getPreferredGitApp(),
!preferredApp.isEmpty,
let gitApp = GitApp(rawValue: preferredApp) {
return gitApp.displayName
}
// Return first installed git app or default
return GitApp.installed.first?.displayName ?? "Git App"
}
/// Check if a specific Git app is the preferred one
static func isPreferredApp(_ app: GitApp) -> Bool {
guard let preferredApp = AppConstants.getPreferredGitApp(),
let gitApp = GitApp(rawValue: preferredApp) else {
return false
}
return gitApp == app
}
}

View file

@ -0,0 +1,254 @@
import SwiftUI
/// View that displays autocomplete suggestions in a dropdown
struct AutocompleteView: View {
let suggestions: [AutocompleteService.PathSuggestion]
@Binding var selectedIndex: Int
let onSelect: (String) -> Void
var body: some View {
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(suggestions.enumerated()), id: \.element.id) { index, suggestion in
AutocompleteRow(
suggestion: suggestion,
isSelected: index == selectedIndex
) { onSelect(suggestion.suggestion) }
.id(index)
.onHover { hovering in
if hovering {
selectedIndex = index
}
}
if index < suggestions.count - 1 {
Divider()
.padding(.horizontal, 8)
}
}
}
}
.frame(maxHeight: 200)
.onChange(of: selectedIndex) { _, newIndex in
if newIndex >= 0 && newIndex < suggestions.count {
withAnimation(.easeInOut(duration: 0.1)) {
proxy.scrollTo(newIndex, anchor: .center)
}
}
}
}
}
.background(.regularMaterial)
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
)
.shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2)
}
}
private struct AutocompleteRow: View {
let suggestion: AutocompleteService.PathSuggestion
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 8) {
// Icon
Image(systemName: iconName)
.font(.system(size: 12))
.foregroundColor(iconColor)
.frame(width: 16)
// Name
Text(suggestion.name)
.font(.system(size: 12))
.foregroundColor(.primary)
.lineLimit(1)
Spacer()
// Path hint
Text(suggestion.path)
.font(.system(size: 10))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.head)
.frame(maxWidth: 120)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
isSelected ? Color.accentColor.opacity(0.1) : Color.clear
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.overlay(
HStack {
if isSelected {
Rectangle()
.fill(Color.accentColor)
.frame(width: 2)
}
Spacer()
}
)
}
private var iconName: String {
if suggestion.isRepository {
"folder.badge.gearshape"
} else if suggestion.type == .directory {
"folder"
} else {
"doc"
}
}
private var iconColor: Color {
if suggestion.isRepository {
.accentColor
} else {
.secondary
}
}
}
/// TextField with autocomplete functionality
struct AutocompleteTextField: View {
@Binding var text: String
let placeholder: String
@StateObject private var autocompleteService = AutocompleteService()
@State private var showSuggestions = false
@State private var selectedIndex = -1
@FocusState private var isFocused: Bool
@State private var debounceTask: Task<Void, Never>?
@State private var justSelectedCompletion = false
var body: some View {
VStack(spacing: 4) {
TextField(placeholder, text: $text)
.textFieldStyle(.roundedBorder)
.focused($isFocused)
.onKeyPress { keyPress in
handleKeyPress(keyPress)
}
.onChange(of: text) { _, newValue in
handleTextChange(newValue)
}
.onChange(of: isFocused) { _, focused in
if !focused {
// Hide suggestions after a delay to allow clicking
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
showSuggestions = false
selectedIndex = -1
}
}
}
if showSuggestions && !autocompleteService.suggestions.isEmpty {
AutocompleteView(
suggestions: autocompleteService.suggestions,
selectedIndex: $selectedIndex
) { suggestion in
justSelectedCompletion = true
text = suggestion
showSuggestions = false
selectedIndex = -1
autocompleteService.clearSuggestions()
}
.transition(.opacity.combined(with: .scale(scale: 0.95)))
}
}
.animation(.easeInOut(duration: 0.2), value: showSuggestions)
}
private func handleKeyPress(_ keyPress: KeyPress) -> KeyPress.Result {
guard showSuggestions && !autocompleteService.suggestions.isEmpty else {
return .ignored
}
switch keyPress.key {
case .downArrow:
selectedIndex = min(selectedIndex + 1, autocompleteService.suggestions.count - 1)
return .handled
case .upArrow:
selectedIndex = max(selectedIndex - 1, -1)
return .handled
case .tab, .return:
if selectedIndex >= 0 && selectedIndex < autocompleteService.suggestions.count {
justSelectedCompletion = true
text = autocompleteService.suggestions[selectedIndex].suggestion
showSuggestions = false
selectedIndex = -1
autocompleteService.clearSuggestions()
return .handled
}
return .ignored
case .escape:
if showSuggestions {
showSuggestions = false
selectedIndex = -1
return .handled
}
return .ignored
default:
return .ignored
}
}
private func handleTextChange(_ newValue: String) {
// If we just selected a completion, don't trigger a new search
if justSelectedCompletion {
justSelectedCompletion = false
return
}
// Cancel previous debounce
debounceTask?.cancel()
// Reset selection when text changes
selectedIndex = -1
guard !newValue.isEmpty else {
showSuggestions = false
autocompleteService.clearSuggestions()
return
}
// Debounce the autocomplete request
debounceTask = Task {
try? await Task.sleep(nanoseconds: 300_000_000) // 300ms
if !Task.isCancelled {
await autocompleteService.fetchSuggestions(for: newValue)
await MainActor.run {
if !autocompleteService.suggestions.isEmpty {
showSuggestions = true
// Auto-select first item if it's a good match
if let first = autocompleteService.suggestions.first,
first.name.lowercased().hasPrefix(
newValue.split(separator: "/").last?.lowercased() ?? ""
)
{
selectedIndex = 0
}
} else {
showSuggestions = false
}
}
}
}
}
}

View file

@ -0,0 +1,165 @@
import AppKit
import SwiftUI
/// A reusable component for displaying clickable URLs with copy and open functionality
struct ClickableURLView: View {
let label: String
let url: String
let showOpenButton: Bool
@State private var showCopiedFeedback = false
init(label: String = "URL:", url: String, showOpenButton: Bool = true) {
self.label = label
self.url = url
self.showOpenButton = showOpenButton
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(label)
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Button(action: copyURL) {
Image(systemName: showCopiedFeedback ? "checkmark" : "doc.on.doc")
.foregroundColor(showCopiedFeedback ? .green : .accentColor)
}
.buttonStyle(.borderless)
.help("Copy URL")
}
HStack {
if let nsUrl = URL(string: url) {
Link(url, destination: nsUrl)
.font(.caption)
.foregroundStyle(.blue)
.lineLimit(1)
.truncationMode(.middle)
} else {
Text(url)
.font(.caption)
.foregroundColor(.blue)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer()
if showOpenButton {
Button(action: openURL) {
Image(systemName: "arrow.up.right.square")
.foregroundColor(.accentColor)
}
.buttonStyle(.borderless)
.help("Open in Browser")
}
}
}
.padding(.vertical, 4)
.padding(.horizontal, 8)
.background(Color.gray.opacity(0.1))
.cornerRadius(6)
}
private func copyURL() {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(url, forType: .string)
withAnimation {
showCopiedFeedback = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation {
showCopiedFeedback = false
}
}
}
private func openURL() {
if let nsUrl = URL(string: url) {
NSWorkspace.shared.open(nsUrl)
}
}
}
/// A simplified inline version for compact display
struct InlineClickableURLView: View {
let label: String
let url: String
@State private var showCopiedFeedback = false
init(label: String = "URL:", url: String) {
self.label = label
self.url = url
}
var body: some View {
HStack(spacing: 5) {
Text(label)
.font(.caption)
.foregroundColor(.secondary)
if let nsUrl = URL(string: url) {
Link(url, destination: nsUrl)
.font(.caption)
.foregroundStyle(.blue)
.lineLimit(1)
.truncationMode(.middle)
} else {
Text(url)
.font(.caption)
.foregroundColor(.blue)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
}
Button(action: copyURL) {
Image(systemName: showCopiedFeedback ? "checkmark" : "doc.on.doc")
.foregroundColor(showCopiedFeedback ? .green : .accentColor)
}
.buttonStyle(.borderless)
.help("Copy URL")
}
}
private func copyURL() {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(url, forType: .string)
withAnimation {
showCopiedFeedback = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation {
showCopiedFeedback = false
}
}
}
}
#Preview("Clickable URL View") {
VStack(spacing: 20) {
ClickableURLView(
label: "Public URL:",
url: "https://example.ngrok.io"
)
ClickableURLView(
label: "Tailscale URL:",
url: "http://my-machine.tailnet:4020",
showOpenButton: false
)
InlineClickableURLView(
label: "Inline URL:",
url: "https://tunnel.cloudflare.com"
)
}
.padding()
.frame(width: 400)
}

View file

@ -11,14 +11,7 @@ struct GitRepositoryRow: View {
private var colorScheme
private var gitAppName: String {
if let preferredApp = UserDefaults.standard.string(forKey: "preferredGitApp"),
!preferredApp.isEmpty,
let gitApp = GitApp(rawValue: preferredApp)
{
return gitApp.displayName
}
// Return first installed git app or default
return GitApp.installed.first?.displayName ?? "Git App"
GitAppHelper.getPreferredGitAppName()
}
private var branchInfo: some View {

View file

@ -36,6 +36,7 @@ struct ServerInfoHeader: View {
.resizable()
.frame(width: 24, height: 24)
.cornerRadius(4)
.padding(.leading, -5) // Align with small icons below
Text(appDisplayName)
.font(.system(size: 14, weight: .semibold))
@ -134,7 +135,7 @@ struct ServerAddressRow: View {
Text(computedAddress)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(AppColors.Fallback.serverRunning(for: colorScheme))
.underline()
.underline(isHovered)
})
.buttonStyle(.plain)
.pointingHandCursor()

View file

@ -339,14 +339,7 @@ struct SessionRow: View {
}
private func getGitAppName() -> String {
if let preferredApp = UserDefaults.standard.string(forKey: "preferredGitApp"),
!preferredApp.isEmpty,
let gitApp = GitApp(rawValue: preferredApp)
{
return gitApp.displayName
}
// Return first installed git app or default
return GitApp.installed.first?.displayName ?? "Git App"
GitAppHelper.getPreferredGitAppName()
}
private func terminateSession() {

View file

@ -15,6 +15,7 @@ struct NewSessionForm: View {
private var sessionService
@Environment(RepositoryDiscoveryService.self)
private var repositoryDiscovery
@StateObject private var configManager = ConfigManager.shared
// Form fields
@State private var command = "zsh"
@ -28,7 +29,6 @@ struct NewSessionForm: View {
@State private var showError = false
@State private var errorMessage = ""
@State private var isHoveringCreate = false
@State private var showingRepositoryDropdown = false
@FocusState private var focusedField: Field?
enum Field: Hashable {
@ -37,31 +37,6 @@ struct NewSessionForm: View {
case directory
}
enum TitleMode: String, CaseIterable {
case none = "none"
case filter = "filter"
case `static` = "static"
case dynamic = "dynamic"
var displayName: String {
switch self {
case .none: "None"
case .filter: "Filter"
case .static: "Static"
case .dynamic: "Dynamic"
}
}
}
/// Quick commands synced with frontend
private let quickCommands = [
("claude", ""),
("gemini", ""),
("zsh", nil),
("python3", nil),
("node", nil),
("pnpm run dev", nil)
]
var body: some View {
VStack(spacing: 0) {
@ -147,8 +122,7 @@ struct NewSessionForm: View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 8) {
TextField("~/", text: $workingDirectory)
.textFieldStyle(.roundedBorder)
AutocompleteTextField(text: $workingDirectory, placeholder: "~/")
.focused($focusedField, equals: .directory)
Button(action: selectDirectory) {
@ -160,29 +134,6 @@ struct NewSessionForm: View {
}
.buttonStyle(.borderless)
.help("Choose directory")
Button(action: { showingRepositoryDropdown.toggle() }, label: {
Image(systemName: "arrow.trianglehead.pull")
.font(.system(size: 12))
.foregroundColor(.secondary)
.animation(.easeInOut(duration: 0.2), value: showingRepositoryDropdown)
.frame(width: 20, height: 20)
.contentShape(Rectangle())
})
.buttonStyle(.borderless)
.help("Choose from repositories")
.disabled(repositoryDiscovery.repositories.isEmpty || repositoryDiscovery.isDiscovering)
}
// Repository dropdown
if showingRepositoryDropdown && !repositoryDiscovery.repositories.isEmpty {
RepositoryDropdownList(
repositories: repositoryDiscovery.repositories,
isDiscovering: repositoryDiscovery.isDiscovering,
selectedPath: $workingDirectory,
isShowing: $showingRepositoryDropdown
)
.padding(.top, 4)
}
}
}
@ -198,31 +149,31 @@ struct NewSessionForm: View {
GridItem(.flexible()),
GridItem(.flexible())
], spacing: 8) {
ForEach(quickCommands, id: \.0) { cmd in
ForEach(configManager.quickStartCommands) { cmd in
Button(action: {
command = cmd.0
command = cmd.command
sessionName = ""
}, label: {
HStack(spacing: 4) {
if let emoji = cmd.1 {
Text(emoji)
.font(.system(size: 12))
}
Text(cmd.0)
.font(.system(size: 11))
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color.primary.opacity(0.05))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
)
Text(cmd.displayName)
.font(.system(size: 11))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 6)
})
.background(
RoundedRectangle(cornerRadius: 6)
.fill(command == cmd.command ? Color.accentColor.opacity(0.15) : Color.primary
.opacity(0.05)
)
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(
command == cmd.command ? Color.accentColor.opacity(0.5) : Color.primary
.opacity(0.1),
lineWidth: 1
)
)
.buttonStyle(.plain)
}
}
@ -351,8 +302,7 @@ struct NewSessionForm: View {
focusedField = .name
}
.task {
let repositoryBasePath = AppConstants.stringValue(for: AppConstants.UserDefaultsKeys.repositoryBasePath)
await repositoryDiscovery.discoverRepositories(in: repositoryBasePath)
await repositoryDiscovery.discoverRepositories(in: configManager.repositoryBasePath)
}
.alert("Error", isPresented: $showError) {
Button("OK") {}

View file

@ -111,7 +111,10 @@ struct CloudflareIntegrationSection: View {
// Public URL display
if let publicUrl = cloudflareService.publicUrl, !publicUrl.isEmpty {
PublicURLView(url: publicUrl)
ClickableURLView(
label: "Public URL:",
url: publicUrl
)
}
// Error display - only show when tunnel is enabled or being toggled
@ -298,69 +301,6 @@ struct CloudflareIntegrationSection: View {
// MARK: - Reusable Components
/// Displays a public URL with copy functionality
private struct PublicURLView: View {
let url: String
@State private var showCopiedFeedback = false
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Public URL:")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Button(action: {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(url, forType: .string)
withAnimation {
showCopiedFeedback = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation {
showCopiedFeedback = false
}
}
}, label: {
Image(systemName: showCopiedFeedback ? "checkmark" : "doc.on.doc")
.foregroundColor(showCopiedFeedback ? .green : .accentColor)
})
.buttonStyle(.borderless)
.help("Copy URL")
}
HStack {
Text(url)
.font(.caption)
.foregroundColor(.blue)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Button(action: {
if let nsUrl = URL(string: url) {
NSWorkspace.shared.open(nsUrl)
}
}, label: {
Image(systemName: "arrow.up.right.square")
.foregroundColor(.accentColor)
})
.buttonStyle(.borderless)
.help("Open in Browser")
}
}
.padding(.vertical, 4)
.padding(.horizontal, 8)
.background(Color.gray.opacity(0.1))
.cornerRadius(6)
}
}
/// Displays error messages with warning icon
private struct ErrorView: View {
let error: String

View file

@ -395,102 +395,108 @@ private struct RemoteAccessStatusSection: View {
Section {
VStack(alignment: .leading, spacing: 12) {
// Tailscale status
HStack {
if let status = tailscaleStatus {
if status.isRunning {
if let status = tailscaleStatus {
if status.isRunning, let hostname = status.hostname {
HStack {
Image(systemName: "circle.fill")
.foregroundColor(.green)
.font(.system(size: 10))
Text("Tailscale")
.font(.callout)
if let hostname = status.hostname {
Text("(\(hostname))")
.font(.caption)
.foregroundColor(.secondary)
}
} else if status.isInstalled {
InlineClickableURLView(
label: "",
url: "http://\(hostname):\(serverPort)"
)
}
} else if status.isRunning {
HStack {
Image(systemName: "circle.fill")
.foregroundColor(.green)
.font(.system(size: 10))
Text("Tailscale")
.font(.callout)
Spacer()
}
} else if status.isInstalled {
HStack {
Image(systemName: "circle.fill")
.foregroundColor(.orange)
.font(.system(size: 10))
Text("Tailscale (not running)")
.font(.callout)
} else {
Spacer()
}
} else {
HStack {
Image(systemName: "circle")
.foregroundColor(.gray)
.font(.system(size: 10))
Text("Tailscale (not installed)")
.font(.callout)
.foregroundColor(.secondary)
Spacer()
}
} else {
}
} else {
HStack {
Image(systemName: "circle")
.foregroundColor(.gray)
.font(.system(size: 10))
Text("Tailscale")
.font(.callout)
.foregroundColor(.secondary)
Spacer()
}
Spacer()
}
// ngrok status
HStack {
if let status = ngrokStatus {
if let status = ngrokStatus {
HStack {
Image(systemName: "circle.fill")
.foregroundColor(.green)
.font(.system(size: 10))
Text("ngrok")
.font(.callout)
if let url = URL(string: status.publicUrl) {
Link(status.publicUrl, destination: url)
.font(.caption)
.foregroundStyle(.blue)
.lineLimit(1)
} else {
Text(status.publicUrl)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
NgrokURLCopyButton(url: status.publicUrl)
} else {
InlineClickableURLView(
label: "",
url: status.publicUrl
)
}
} else {
HStack {
Image(systemName: "circle")
.foregroundColor(.gray)
.font(.system(size: 10))
Text("ngrok (not connected)")
.font(.callout)
.foregroundColor(.secondary)
Spacer()
}
Spacer()
}
// Cloudflare status
HStack {
if cloudflareService.isRunning {
if cloudflareService.isRunning, let url = cloudflareService.publicUrl {
HStack {
Image(systemName: "circle.fill")
.foregroundColor(.green)
.font(.system(size: 10))
Text("Cloudflare")
.font(.callout)
if let url = cloudflareService.publicUrl {
Text(
"(\(url.replacingOccurrences(of: "https://", with: "").replacingOccurrences(of: ".trycloudflare.com", with: "")))"
)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
} else {
InlineClickableURLView(
label: "",
url: url
)
}
} else {
HStack {
Image(systemName: "circle")
.foregroundColor(.gray)
.font(.system(size: 10))
Text("Cloudflare (not connected)")
.font(.callout)
.foregroundColor(.secondary)
Spacer()
}
Spacer()
}
}
} header: {
@ -505,43 +511,6 @@ private struct RemoteAccessStatusSection: View {
}
}
// MARK: - Ngrok URL Copy Button
private struct NgrokURLCopyButton: View {
let url: String
@State private var showCopiedFeedback = false
@State private var feedbackTask: DispatchWorkItem?
var body: some View {
Button(action: {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(url, forType: .string)
// Cancel previous timer if exists
feedbackTask?.cancel()
withAnimation {
showCopiedFeedback = true
}
// Create new timer
let task = DispatchWorkItem {
withAnimation {
showCopiedFeedback = false
}
}
feedbackTask = task
DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task)
}, label: {
Image(systemName: showCopiedFeedback ? "checkmark" : "doc.on.doc")
.foregroundColor(showCopiedFeedback ? .green : .accentColor)
})
.buttonStyle(.borderless)
.help("Copy URL")
.font(.caption)
}
}
// MARK: - Previews
#Preview("Dashboard Settings") {

View file

@ -14,8 +14,7 @@ struct GeneralSettingsView: View {
private var showInDock = true
@AppStorage(AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
private var preventSleepWhenRunning = true
@AppStorage(AppConstants.UserDefaultsKeys.repositoryBasePath)
private var repositoryBasePath = AppConstants.Defaults.repositoryBasePath
@StateObject private var configManager = ConfigManager.shared
@AppStorage(AppConstants.UserDefaultsKeys.serverPort)
private var serverPort = "4020"
@AppStorage(AppConstants.UserDefaultsKeys.dashboardAccessMode)
@ -56,7 +55,10 @@ struct GeneralSettingsView: View {
CLIInstallationSection()
// Repository section
RepositorySettingsSection(repositoryBasePath: $repositoryBasePath)
RepositorySettingsSection(repositoryBasePath: .init(
get: { configManager.repositoryBasePath },
set: { configManager.updateRepositoryBasePath($0) }
))
Section {
// Launch at Login

View file

@ -0,0 +1,295 @@
import AppKit
import SwiftUI
/// Settings section for managing quick start commands
struct QuickStartSettingsSection: View {
@StateObject private var configManager = ConfigManager.shared
@State private var editingCommandId: String?
@State private var newCommandName = ""
@State private var newCommandCommand = ""
@State private var showingNewCommand = false
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)
}
// Commands list
List {
ForEach(configManager.quickStartCommands) { command in
QuickStartCommandRow(
command: command,
isEditing: editingCommandId == command.id,
onEdit: { editingCommandId = command.id },
onSave: { updateCommand($0) },
onDelete: { deleteCommand(command) },
onStopEditing: { editingCommandId = nil }
)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0))
.listRowBackground(Color.clear)
}
.onMove(perform: moveQuickStartItems)
// New command inline form
if showingNewCommand {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
TextField("Display name", text: $newCommandName)
.textFieldStyle(.roundedBorder)
.font(.system(size: 12))
TextField("Command", text: $newCommandCommand)
.textFieldStyle(.roundedBorder)
.font(.system(size: 11))
}
HStack(spacing: 8) {
Button(action: saveNewCommand) {
Image(systemName: "checkmark")
.font(.system(size: 11))
.foregroundColor(.green)
}
.buttonStyle(.plain)
.disabled(newCommandName.isEmpty || newCommandCommand.isEmpty)
Button(action: cancelNewCommand) {
Image(systemName: "xmark")
.font(.system(size: 11))
.foregroundColor(.red)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(NSColor.tertiaryLabelColor).opacity(0.1))
.cornerRadius(4)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0))
.listRowBackground(Color.clear)
}
}
.listStyle(.plain)
.background(Color(NSColor.tertiaryLabelColor).opacity(0.08))
.cornerRadius(6)
.frame(minHeight: 100)
.scrollContentBackground(.hidden)
// Action buttons
HStack {
Button("Reset to Defaults") {
resetToDefaults()
}
.buttonStyle(.link)
if !configManager.quickStartCommands.isEmpty {
Button("Delete All") {
deleteAllCommands()
}
.buttonStyle(.link)
.foregroundColor(.red)
}
Spacer()
}
}
}
}
private func updateCommand(_ updated: ConfigManager.QuickStartCommand) {
configManager.updateCommand(
id: updated.id,
name: updated.name,
command: updated.command
)
}
private func deleteCommand(_ command: ConfigManager.QuickStartCommand) {
configManager.deleteCommand(id: command.id)
}
private func resetToDefaults() {
configManager.resetToDefaults()
editingCommandId = nil
showingNewCommand = false
}
private func deleteAllCommands() {
configManager.deleteAllCommands()
editingCommandId = nil
showingNewCommand = false
}
private func saveNewCommand() {
let name = newCommandName.trimmingCharacters(in: .whitespacesAndNewlines)
let command = newCommandCommand.trimmingCharacters(in: .whitespacesAndNewlines)
configManager.addCommand(
name: name.isEmpty ? nil : name,
command: command
)
// Reset state
newCommandName = ""
newCommandCommand = ""
showingNewCommand = false
}
private func cancelNewCommand() {
newCommandName = ""
newCommandCommand = ""
showingNewCommand = false
}
private func moveQuickStartItems(from source: IndexSet, to destination: Int) {
configManager.moveCommands(from: source, to: destination)
}
}
// MARK: - Command Row
private struct QuickStartCommandRow: View {
let command: ConfigManager.QuickStartCommand
let isEditing: Bool
let onEdit: () -> Void
let onSave: (ConfigManager.QuickStartCommand) -> Void
let onDelete: () -> Void
let onStopEditing: () -> Void
@State private var isHovering = false
@State private var editingName: String = ""
@State private var editingCommand: String = ""
var body: some View {
HStack(spacing: 12) {
// Drag handle
Image(systemName: "line.horizontal.3")
.font(.system(size: 11))
.foregroundColor(.secondary.opacity(0.6))
.opacity(isHovering ? 1 : 0.4)
.animation(.easeInOut(duration: 0.2), value: isHovering)
if isEditing {
// Inline editing mode
VStack(alignment: .leading, spacing: 4) {
TextField("Display name", text: $editingName)
.textFieldStyle(.roundedBorder)
.font(.system(size: 12))
.onSubmit { saveChanges() }
TextField("Command", text: $editingCommand)
.textFieldStyle(.roundedBorder)
.font(.system(size: 11))
.onSubmit { saveChanges() }
}
HStack(spacing: 8) {
Button(action: saveChanges) {
Image(systemName: "checkmark")
.font(.system(size: 11))
.foregroundColor(.green)
}
.buttonStyle(.plain)
.disabled(editingName.isEmpty || editingCommand.isEmpty)
Button(action: cancelEditing) {
Image(systemName: "xmark")
.font(.system(size: 11))
.foregroundColor(.red)
}
.buttonStyle(.plain)
}
} else {
// Display mode
VStack(alignment: .leading, spacing: 2) {
Text(command.displayName)
.font(.system(size: 12, weight: .medium))
.foregroundColor(.primary)
if command.name != nil {
Text(command.command)
.font(.system(size: 11))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
Spacer()
HStack(spacing: 8) {
Button(action: startEditing) {
Image(systemName: "pencil")
.font(.system(size: 11))
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
.opacity(isHovering ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: isHovering)
Button(action: onDelete) {
Image(systemName: "trash")
.font(.system(size: 11))
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
.opacity(isHovering ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: isHovering)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(isEditing ? Color.accentColor.opacity(0.1) : Color.clear)
)
.onHover { hovering in
isHovering = hovering
}
}
private func startEditing() {
editingName = command.name ?? ""
editingCommand = command.command
onEdit()
}
private func saveChanges() {
let trimmedName = editingName.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedCommand = editingCommand.trimmingCharacters(in: .whitespacesAndNewlines)
var updatedCommand = command
updatedCommand.name = trimmedName.isEmpty ? nil : trimmedName
updatedCommand.command = trimmedCommand
onSave(updatedCommand)
onStopEditing()
}
private func cancelEditing() {
editingName = ""
editingCommand = ""
onStopEditing()
}
}

View file

@ -0,0 +1,19 @@
import SwiftUI
/// Quick Start settings tab for managing quick start commands
struct QuickStartSettingsView: View {
var body: some View {
NavigationStack {
Form {
QuickStartSettingsSection()
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.navigationTitle("Quick Start Settings")
}
}
}
#Preview {
QuickStartSettingsView()
}

View file

@ -284,18 +284,11 @@ private struct TailscaleIntegrationSection: View {
} else if tailscaleService.isRunning {
// Show dashboard URL when running
if let hostname = tailscaleService.tailscaleHostname {
HStack(spacing: 5) {
Text("Access VibeTunnel at:")
.font(.caption)
.foregroundColor(.secondary)
let urlString = "http://\(hostname):\(serverPort)"
if let url = URL(string: urlString) {
Link(urlString, destination: url)
.font(.caption)
.foregroundStyle(.blue)
}
}
let urlString = "http://\(hostname):\(serverPort)"
InlineClickableURLView(
label: "Access VibeTunnel at:",
url: urlString
)
// Show warning if in localhost-only mode
if accessMode == .localhost {
@ -407,7 +400,10 @@ private struct NgrokIntegrationSection: View {
// Public URL display
if let status = ngrokStatus {
PublicURLView(url: status.publicUrl)
InlineClickableURLView(
label: "Public URL:",
url: status.publicUrl
)
}
// Error display
@ -512,43 +508,6 @@ private struct AuthTokenField: View {
}
}
// MARK: - Public URL View
private struct PublicURLView: View {
let url: String
@State private var showCopiedFeedback = false
var body: some View {
HStack {
Text("Public URL:")
.font(.caption)
.foregroundColor(.secondary)
Text(url)
.font(.caption)
.textSelection(.enabled)
Button(action: {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(url, forType: .string)
withAnimation {
showCopiedFeedback = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation {
showCopiedFeedback = false
}
}
}, label: {
Image(systemName: showCopiedFeedback ? "checkmark" : "doc.on.doc")
.foregroundColor(showCopiedFeedback ? .green : .accentColor)
})
.buttonStyle(.borderless)
.help("Copy URL")
}
}
}
// MARK: - Error View
private struct ErrorView: View {

View file

@ -87,32 +87,6 @@ struct SecurityPermissionsSettingsView: View {
}
}
// MARK: - Authentication Mode
private enum AuthenticationMode: String, CaseIterable {
case none = "none"
case osAuth = "os"
case sshKeys = "ssh"
case both = "both"
var displayName: String {
switch self {
case .none: "None"
case .osAuth: "macOS"
case .sshKeys: "SSH Keys"
case .both: "Both"
}
}
var description: String {
switch self {
case .none: "Anyone can access the dashboard (not recommended)"
case .osAuth: "Use your macOS username and password"
case .sshKeys: "Use SSH keys from ~/.ssh/authorized_keys"
case .both: "Allow both authentication methods"
}
}
}
// MARK: - Security Section

View file

@ -6,6 +6,7 @@ import Foundation
/// with associated display names and SF Symbol icons for the tab bar.
enum SettingsTab: String, CaseIterable {
case general
case quickStart
case dashboard
case remoteAccess
case securityPermissions
@ -16,6 +17,7 @@ enum SettingsTab: String, CaseIterable {
var displayName: String {
switch self {
case .general: "General"
case .quickStart: "Quick Start"
case .dashboard: "Dashboard"
case .remoteAccess: "Remote"
case .securityPermissions: "Security"
@ -28,6 +30,7 @@ enum SettingsTab: String, CaseIterable {
var icon: String {
switch self {
case .general: "gear"
case .quickStart: "bolt.fill"
case .dashboard: "server.rack"
case .remoteAccess: "network"
case .securityPermissions: "lock.shield"

View file

@ -14,13 +14,14 @@ struct SettingsView: View {
// MARK: - Constants
private enum Layout {
static let defaultTabSize = CGSize(width: 500, height: 710)
static let fallbackTabSize = CGSize(width: 500, height: 450)
static let defaultTabSize = CGSize(width: 520, height: 710)
static let fallbackTabSize = CGSize(width: 520, height: 450)
}
/// Define ideal sizes for each tab
private let tabSizes: [SettingsTab: CGSize] = [
.general: Layout.defaultTabSize,
.quickStart: Layout.defaultTabSize,
.dashboard: Layout.defaultTabSize,
.remoteAccess: Layout.defaultTabSize,
.securityPermissions: Layout.defaultTabSize,
@ -37,6 +38,12 @@ struct SettingsView: View {
}
.tag(SettingsTab.general)
QuickStartSettingsView()
.tabItem {
Label(SettingsTab.quickStart.displayName, systemImage: SettingsTab.quickStart.icon)
}
.tag(SettingsTab.quickStart)
DashboardSettingsView()
.tabItem {
Label(SettingsTab.dashboard.displayName, systemImage: SettingsTab.dashboard.icon)

View file

@ -6,8 +6,7 @@ import SwiftUI
/// Allows users to select their primary project directory for repository discovery
/// and new session defaults. This path will be synced to the web UI settings.
struct ProjectFolderPageView: View {
@AppStorage(AppConstants.UserDefaultsKeys.repositoryBasePath)
private var repositoryBasePath = AppConstants.Defaults.repositoryBasePath
@StateObject private var configManager = ConfigManager.shared
@State private var selectedPath = ""
@State private var isShowingPicker = false
@ -109,7 +108,7 @@ struct ProjectFolderPageView: View {
}
.padding()
.onAppear {
selectedPath = repositoryBasePath
selectedPath = configManager.repositoryBasePath
}
.onChange(of: currentPage) { _, newPage in
if newPage == pageIndex {
@ -125,7 +124,7 @@ struct ProjectFolderPageView: View {
}
}
.onChange(of: selectedPath) { _, newValue in
repositoryBasePath = newValue
configManager.updateRepositoryBasePath(newValue)
// Cancel any existing scan
scanTask?.cancel()

View file

@ -126,7 +126,7 @@ struct VibeTunnelApp: App {
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate {
// Needed for menu item highlight hack
static weak var shared: AppDelegate?
weak static var shared: AppDelegate?
override init() {
super.init()
Self.shared = self

View file

@ -2,7 +2,7 @@ import Foundation
import Testing
@testable import VibeTunnel
@Suite("AppleScript Executor Tests", .tags(.integration))
@Suite("AppleScript Executor Tests", .tags(.integration), .disabled(if: TestConditions.isRunningInCI(), "AppleScript not available in CI"))
struct AppleScriptExecutorTests {
@Test("Execute simple AppleScript")
@MainActor

View file

@ -5,25 +5,24 @@ import Testing
@Suite("Repository Path Sync Service Tests", .serialized)
struct RepositoryPathSyncServiceTests {
/// Helper to clean UserDefaults state
/// Helper to reset repository path to default
@MainActor
private func cleanUserDefaults() {
UserDefaults.standard.removeObject(forKey: AppConstants.UserDefaultsKeys.repositoryBasePath)
UserDefaults.standard.synchronize()
private func resetRepositoryPath() {
ConfigManager.shared.updateRepositoryBasePath("~/")
}
@MainActor
@Test("Loop prevention disables sync when notification posted")
func loopPreventionDisablesSync() async throws {
// Clean state first
cleanUserDefaults()
// Reset state first
resetRepositoryPath()
// Given
_ = RepositoryPathSyncService()
// Set initial path
let initialPath = "~/Projects"
UserDefaults.standard.set(initialPath, forKey: AppConstants.UserDefaultsKeys.repositoryBasePath)
ConfigManager.shared.updateRepositoryBasePath(initialPath)
// Allow service to initialize
try await Task.sleep(for: .milliseconds(100))
@ -36,7 +35,7 @@ struct RepositoryPathSyncServiceTests {
// Change the path
let newPath = "~/Documents/Code"
UserDefaults.standard.set(newPath, forKey: AppConstants.UserDefaultsKeys.repositoryBasePath)
ConfigManager.shared.updateRepositoryBasePath(newPath)
// Allow time for potential sync
try await Task.sleep(for: .milliseconds(200))
@ -51,7 +50,7 @@ struct RepositoryPathSyncServiceTests {
@Test("Loop prevention re-enables sync after enable notification")
func loopPreventionReenablesSync() async throws {
// Clean state first
cleanUserDefaults()
resetRepositoryPath()
// Given
_ = RepositoryPathSyncService()
@ -66,7 +65,7 @@ struct RepositoryPathSyncServiceTests {
// Then - Future path changes should sync normally
let newPath = "~/EnabledPath"
UserDefaults.standard.set(newPath, forKey: AppConstants.UserDefaultsKeys.repositoryBasePath)
ConfigManager.shared.updateRepositoryBasePath(newPath)
// Allow time for sync
try await Task.sleep(for: .milliseconds(200))
@ -79,7 +78,7 @@ struct RepositoryPathSyncServiceTests {
@Test("Sync skips when disabled during path change")
func syncSkipsWhenDisabled() async throws {
// Clean state first
cleanUserDefaults()
resetRepositoryPath()
// Given
_ = RepositoryPathSyncService()
@ -95,7 +94,7 @@ struct RepositoryPathSyncServiceTests {
try await Task.sleep(for: .milliseconds(50))
// When - Change path while sync is disabled
UserDefaults.standard.set("~/DisabledPath", forKey: AppConstants.UserDefaultsKeys.repositoryBasePath)
ConfigManager.shared.updateRepositoryBasePath("~/DisabledPath")
// Allow time for the observer to trigger
try await Task.sleep(for: .milliseconds(200))
@ -162,7 +161,7 @@ struct RepositoryPathSyncServiceTests {
@Test("Service observes repository path changes and sends updates via Unix socket")
func repositoryPathSync() async throws {
// Clean state first
cleanUserDefaults()
resetRepositoryPath()
// Given - Mock Unix socket connection
let mockConnection = MockUnixSocketConnection()
@ -176,11 +175,11 @@ struct RepositoryPathSyncServiceTests {
// Store initial path
let initialPath = "~/Projects"
UserDefaults.standard.set(initialPath, forKey: AppConstants.UserDefaultsKeys.repositoryBasePath)
ConfigManager.shared.updateRepositoryBasePath(initialPath)
// When - Change the repository path
let newPath = "~/Documents/Code"
UserDefaults.standard.set(newPath, forKey: AppConstants.UserDefaultsKeys.repositoryBasePath)
ConfigManager.shared.updateRepositoryBasePath(newPath)
// Allow time for the observer to trigger
try await Task.sleep(for: .milliseconds(200))
@ -195,14 +194,14 @@ struct RepositoryPathSyncServiceTests {
@Test("Service sends current path on syncCurrentPath call")
func testSyncCurrentPath() async throws {
// Clean state first
cleanUserDefaults()
resetRepositoryPath()
// Given
let service = RepositoryPathSyncService()
// Set a known path
let testPath = "~/TestProjects"
UserDefaults.standard.set(testPath, forKey: AppConstants.UserDefaultsKeys.repositoryBasePath)
ConfigManager.shared.updateRepositoryBasePath(testPath)
// When - Call sync current path
await service.syncCurrentPath()
@ -219,13 +218,13 @@ struct RepositoryPathSyncServiceTests {
@Test("Service handles disconnected socket gracefully")
func handleDisconnectedSocket() async throws {
// Clean state first
cleanUserDefaults()
resetRepositoryPath()
// Given - Service with no connection
_ = RepositoryPathSyncService()
// When - Trigger a path update when socket is not connected
UserDefaults.standard.set("~/NewPath", forKey: AppConstants.UserDefaultsKeys.repositoryBasePath)
ConfigManager.shared.updateRepositoryBasePath("~/NewPath")
// Allow time for processing
try await Task.sleep(for: .milliseconds(100))
@ -238,17 +237,17 @@ struct RepositoryPathSyncServiceTests {
@Test("Service skips duplicate path updates")
func skipDuplicatePaths() async throws {
// Clean state first
cleanUserDefaults()
resetRepositoryPath()
// Given
_ = RepositoryPathSyncService()
let testPath = "~/SamePath"
// When - Set the same path multiple times
UserDefaults.standard.set(testPath, forKey: AppConstants.UserDefaultsKeys.repositoryBasePath)
ConfigManager.shared.updateRepositoryBasePath(testPath)
try await Task.sleep(for: .milliseconds(100))
UserDefaults.standard.set(testPath, forKey: AppConstants.UserDefaultsKeys.repositoryBasePath)
ConfigManager.shared.updateRepositoryBasePath(testPath)
try await Task.sleep(for: .milliseconds(100))
// Then - The service should handle this gracefully

View file

@ -39,27 +39,29 @@ final class ServerManagerTests {
// Attach server state after start attempt
Attachment.record(TestUtilities.captureServerState(manager), named: "Post-Start Server State")
// Handle both scenarios: binary not found vs binary working
if ServerBinaryAvailableCondition.isAvailable() {
// In CI with working binary, server should start successfully or fail gracefully
#expect(manager.isRunning || manager.lastError != nil)
Attachment.record("""
Binary Available: Server should start or fail gracefully
Is Running: \(manager.isRunning)
Server Instance: \(manager.bunServer != nil ? "Present" : "Nil")
Last Error: \(manager.lastError?.localizedDescription ?? "None")
""", named: "Server Status With Binary")
} else {
// In test environment without binary, server should fail to start
// The server binary must be available for tests
#expect(ServerBinaryAvailableCondition.isAvailable(), "Server binary must be available for tests to run")
// Server should either be running or have a specific error
if !manager.isRunning {
// If not running, we expect a specific error
#expect(manager.lastError != nil, "Server failed to start but no error was reported")
if let error = manager.lastError as? BunServerError {
#expect(error == .binaryNotFound)
Attachment.record("""
Error Type: \(error)
Error Description: \(error.localizedDescription)
""", named: "Server Error Details")
// Only acceptable error is binaryNotFound if the binary truly doesn't exist
if error == .binaryNotFound {
#expect(false, "Server binary not found - tests cannot continue")
}
}
#expect(!manager.isRunning)
#expect(manager.bunServer == nil)
Attachment.record("""
Server failed to start
Error: \(manager.lastError?.localizedDescription ?? "Unknown")
""", named: "Server Startup Failure")
} else {
// Server is running as expected
#expect(manager.bunServer != nil)
Attachment.record("Server started successfully", named: "Server Status")
}
// Stop should work regardless of state

View file

@ -4,219 +4,6 @@ import Testing
@Suite("System Control Handler Tests", .serialized)
struct SystemControlHandlerTests {
@MainActor
@Test("Handles repository path update from web correctly")
func repositoryPathUpdateFromWeb() async throws {
// Given - Store original and set test value
let originalPath = UserDefaults.standard.string(forKey: AppConstants.UserDefaultsKeys.repositoryBasePath)
defer {
// Restore original value
if let original = originalPath {
UserDefaults.standard.set(original, forKey: AppConstants.UserDefaultsKeys.repositoryBasePath)
} else {
UserDefaults.standard.removeObject(forKey: AppConstants.UserDefaultsKeys.repositoryBasePath)
}
}
let initialPath = "~/Projects"
UserDefaults.standard.set(initialPath, forKey: AppConstants.UserDefaultsKeys.repositoryBasePath)
UserDefaults.standard.synchronize()
var systemReadyCalled = false
let handler = SystemControlHandler(onSystemReady: {
systemReadyCalled = true
})
// Create test message
let testPath = "/Users/test/Documents/Code"
let message: [String: Any] = [
"id": "test-123",
"type": "request",
"category": "system",
"action": "repository-path-update",
"payload": ["path": testPath, "source": "web"]
]
let messageData = try JSONSerialization.data(withJSONObject: message)
// When
let response = await handler.handleMessage(messageData)
// Then
#expect(response != nil)
// Verify response format
if let responseData = response,
let responseJson = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any]
{
#expect(responseJson["id"] as? String == "test-123")
#expect(responseJson["type"] as? String == "response")
#expect(responseJson["category"] as? String == "system")
#expect(responseJson["action"] as? String == "repository-path-update")
if let payload = responseJson["payload"] as? [String: Any] {
#expect(payload["success"] as? Bool == true)
#expect(payload["path"] as? String == testPath)
}
}
// Allow time for async UserDefaults update
try await Task.sleep(for: .milliseconds(200))
// Verify UserDefaults was updated
let updatedPath = UserDefaults.standard.string(forKey: AppConstants.UserDefaultsKeys.repositoryBasePath)
#expect(updatedPath == testPath)
}
@MainActor
@Test("Ignores repository path update from non-web sources")
func ignoresNonWebPathUpdates() async throws {
// Use a unique key for this test to avoid interference from other processes
let testKey = "TestRepositoryBasePath_\(UUID().uuidString)"
// Given - Set test value
let initialPath = "~/Projects"
UserDefaults.standard.set(initialPath, forKey: testKey)
UserDefaults.standard.synchronize()
defer {
// Clean up test key
UserDefaults.standard.removeObject(forKey: testKey)
UserDefaults.standard.synchronize()
}
// Temporarily override the key used by SystemControlHandler
_ = AppConstants.UserDefaultsKeys.repositoryBasePath
// Create a custom handler that uses our test key
// Note: Since we can't easily mock UserDefaults key in SystemControlHandler,
// we'll test the core logic by verifying the handler's response behavior
let handler = SystemControlHandler()
// Create test message from Mac source
let testPath = "/Users/test/Documents/Code"
let message: [String: Any] = [
"id": "test-123",
"type": "request",
"category": "system",
"action": "repository-path-update",
"payload": ["path": testPath, "source": "mac"]
]
let messageData = try JSONSerialization.data(withJSONObject: message)
// When
let response = await handler.handleMessage(messageData)
// Then - Should respond with success but indicate source was not web
#expect(response != nil)
if let responseData = response,
let responseJson = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any],
let payload = responseJson["payload"] as? [String: Any]
{
// The handler should return success but the actual UserDefaults update
// should only happen for source="web"
#expect(payload["success"] as? Bool == true)
#expect(payload["path"] as? String == testPath)
}
// The real test is that the handler's logic correctly ignores non-web sources
// We can't reliably test UserDefaults in CI due to potential interference
}
@MainActor
@Test("Handles invalid repository path update format")
func invalidPathUpdateFormat() async throws {
let handler = SystemControlHandler()
// Create invalid message (missing path)
let message: [String: Any] = [
"id": "test-123",
"type": "request",
"category": "system",
"action": "repository-path-update",
"payload": ["source": "web"]
]
let messageData = try JSONSerialization.data(withJSONObject: message)
// When
let response = await handler.handleMessage(messageData)
// Then
#expect(response != nil)
// Verify error response
if let responseData = response,
let responseJson = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any]
{
#expect(responseJson["error"] != nil)
}
}
@MainActor
@Test("Posts notifications for loop prevention")
func loopPreventionNotifications() async throws {
// Given - Clean state first
UserDefaults.standard.removeObject(forKey: AppConstants.UserDefaultsKeys.repositoryBasePath)
UserDefaults.standard.synchronize()
@MainActor
class NotificationFlags {
var disableNotificationPosted = false
var enableNotificationPosted = false
}
let flags = NotificationFlags()
// Observe notifications
let disableObserver = NotificationCenter.default.addObserver(
forName: .disablePathSync,
object: nil,
queue: .main
) { _ in
Task { @MainActor in
flags.disableNotificationPosted = true
}
}
let enableObserver = NotificationCenter.default.addObserver(
forName: .enablePathSync,
object: nil,
queue: .main
) { _ in
Task { @MainActor in
flags.enableNotificationPosted = true
}
}
defer {
NotificationCenter.default.removeObserver(disableObserver)
NotificationCenter.default.removeObserver(enableObserver)
}
let handler = SystemControlHandler()
// Create test message
let message: [String: Any] = [
"id": "test-123",
"type": "request",
"category": "system",
"action": "repository-path-update",
"payload": ["path": "/test/path", "source": "web"]
]
let messageData = try JSONSerialization.data(withJSONObject: message)
// When
_ = await handler.handleMessage(messageData)
// Then - Disable notification should be posted immediately
#expect(flags.disableNotificationPosted == true)
// Wait for re-enable
try await Task.sleep(for: .milliseconds(600))
// Enable notification should be posted after delay
#expect(flags.enableNotificationPosted == true)
}
@MainActor
@Test("Handles system ready event")

View file

@ -8,24 +8,18 @@ import Testing
enum ServerBinaryAvailableCondition {
static func isAvailable() -> Bool {
// Check for the embedded vibetunnel binary in the host app bundle
// When running tests, Bundle.main is the test bundle, not the app bundle
let hostBundle = Bundle(for: BunServer.self) // Get the app bundle, not test bundle
// When running tests with swift test, Bundle(for:) won't find the app bundle
// So tests should fail if the binary is not properly embedded
let hostBundle = Bundle(for: BunServer.self)
if let embeddedBinaryPath = hostBundle.path(forResource: "vibetunnel", ofType: nil),
FileManager.default.fileExists(atPath: embeddedBinaryPath)
{
FileManager.default.fileExists(atPath: embeddedBinaryPath) {
return true
}
// Fallback: Check for Node.js binary paths (we use Node, not Bun!)
let nodePaths = [
"/usr/local/bin/node",
"/opt/homebrew/bin/node",
"/usr/bin/node",
ProcessInfo.processInfo.environment["NODE_PATH"]
].compactMap(\.self)
return nodePaths.contains { FileManager.default.fileExists(atPath: $0) }
// The binary MUST be embedded in the app's Resources folder
// If it's not there, the tests should fail
return false
}
}

View file

@ -73,6 +73,10 @@ Do NOT use three separate commands (add, commit, push) as this is slow.
## Best Practices
- ALWAYS use `Z_INDEX` constants in `src/client/utils/constants.ts` instead of setting z-index properties using primitives / magic numbers
- Add ids to web elements whenever needed to make testing simpler. This helps avoid complex selectors that search by text content or traverse the DOM
- Use descriptive IDs like `session-kill-button`, `show-exited-button`, `file-picker-choose-button`
- Prefer ID selectors (`#element-id`) over complex queries in tests
- When adding interactive elements (buttons, inputs), always consider adding an ID for testability
## CRITICAL: Package Installation Policy
**NEVER install packages without explicit user approval!**

View file

@ -108,9 +108,6 @@ Remote Server Options:
--name <name> Unique name for remote server
--allow-insecure-hq Allow HTTP URLs for HQ (not recommended)
Repository Options:
--repository-base-path <path> Base path for repository discovery
Debugging:
--debug Enable debug logging
```

View file

@ -115,7 +115,8 @@
"postject": "1.0.0-alpha.6",
"signal-exit": "^4.1.0",
"web-push": "^3.6.7",
"ws": "^8.18.3"
"ws": "^8.18.3",
"zod": "^4.0.5"
},
"devDependencies": {
"@biomejs/biome": "^2.1.1",

View file

@ -92,6 +92,9 @@ importers:
ws:
specifier: ^8.18.3
version: 8.18.3
zod:
specifier: ^4.0.5
version: 4.0.5
devDependencies:
'@biomejs/biome':
specifier: ^2.1.1
@ -3249,6 +3252,9 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.0.5:
resolution: {integrity: sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA==}
snapshots:
'@alloc/quick-lru@5.2.0': {}
@ -6450,3 +6456,5 @@ snapshots:
ylru@1.4.0: {}
zod@3.25.76: {}
zod@4.0.5: {}

View file

@ -273,9 +273,9 @@ export class VibeTunnelApp extends LitElement {
}
}
// Legacy browser shortcut checking for non-session views
// Browser shortcut checking for non-session views
const shouldAllowBrowserShortcut = (): boolean => {
// If we're not in session view or capture is disabled, use the legacy allow list
// If we're not in session view or capture is disabled, use the browser shortcut allow list
if (this.currentView !== 'session' || !this.keyboardCaptureActive) {
const key = e.key.toLowerCase();
const hasModifier = e.ctrlKey || e.metaKey;
@ -1677,6 +1677,7 @@ export class VibeTunnelApp extends LitElement {
<!-- Unified Settings Modal -->
<unified-settings
.visible=${this.showSettings}
.authClient=${authClient}
@close=${this.handleCloseSettings}
@notifications-enabled=${() => this.showSuccess('Notifications enabled')}
@notifications-disabled=${() => this.showSuccess('Notifications disabled')}
@ -1704,7 +1705,7 @@ export class VibeTunnelApp extends LitElement {
${
this.showLogLink
? html`
<div class="fixed ${this.getLogButtonPosition()} right-4 text-muted text-xs font-mono bg-secondary px-3 py-1.5 rounded-lg border border-base shadow-sm transition-all duration-200" style="z-index: ${Z_INDEX.LOG_BUTTON};">
<div class="fixed ${this.getLogButtonPosition()} right-4 text-muted text-xs font-mono bg-secondary px-3 py-1.5 rounded-lg border border-border/30 shadow-sm transition-all duration-200" style="z-index: ${Z_INDEX.LOG_BUTTON};">
<a href="/logs" class="hover:text-text transition-colors">Logs</a>
<span class="ml-2 opacity-75">v${VERSION}</span>
</div>

View file

@ -14,7 +14,7 @@
*/
import { html, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { Session } from './session-list.js';
import type { Session } from '../../shared/types.js';
import './terminal-icon.js';
import './notification-status.js';
import './sidebar-header.js';

View file

@ -7,8 +7,8 @@ import {
setupLocalStorageMock,
waitForAsync,
} from '@/test/utils/component-helpers';
import type { Session } from '../../shared/types.js';
import type { AuthClient } from '../services/auth-client';
import type { Session } from './session-list';
// Mock AuthClient
vi.mock('../services/auth-client');

View file

@ -11,6 +11,7 @@
import { html, LitElement } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { createRef, ref } from 'lit/directives/ref.js';
import type { Session } from '../../shared/types.js';
import { authClient } from '../services/auth-client.js';
import { Z_INDEX } from '../utils/constants.js';
import {
@ -22,7 +23,6 @@ import {
} from '../utils/file-icons.js';
import { createLogger } from '../utils/logger.js';
import { copyToClipboard, formatPathForDisplay } from '../utils/path-utils.js';
import type { Session } from './session-list.js';
import './monaco-editor.js';
import './modal-wrapper.js';
@ -534,7 +534,7 @@ export class FileBrowser extends LitElement {
>
<!-- Compact Header (like session-view) -->
<div
class="flex items-center justify-between px-3 py-2 border-b border-base text-sm min-w-0 bg-secondary"
class="flex items-center justify-between px-3 py-2 border-b border-border/50 text-sm min-w-0 bg-secondary"
style="padding-top: max(0.5rem, env(safe-area-inset-top)); padding-left: max(0.75rem, env(safe-area-inset-left)); padding-right: max(0.75rem, env(safe-area-inset-right));"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
@ -552,7 +552,7 @@ export class FileBrowser extends LitElement {
</svg>
<span>Back</span>
</button>
<div class="text-primary min-w-0 flex-1 overflow-hidden">
<div class="text-primary min-w-0 flex-1 overflow-hidden flex items-center gap-2">
${
this.editingPath
? html`
@ -563,7 +563,7 @@ export class FileBrowser extends LitElement {
@input=${this.handlePathInput}
@keydown=${this.handlePathKeyDown}
@blur=${this.handlePathBlur}
class="bg-bg border border-base rounded px-2 py-1 text-status-info text-xs sm:text-sm font-mono w-full min-w-0 focus:outline-none focus:border-primary"
class="bg-bg border border-border/50 rounded px-2 py-1 text-status-info text-xs sm:text-sm font-mono w-full min-w-0 focus:outline-none focus:border-primary"
placeholder="Enter path and press Enter"
/>
`
@ -579,6 +579,15 @@ export class FileBrowser extends LitElement {
</div>
`
}
${
this.gitStatus?.branch
? html`
<span class="text-muted text-xs flex items-center gap-1 font-mono flex-shrink-0">
${UIIcons.git} ${this.gitStatus.branch}
</span>
`
: ''
}
</div>
</div>
<div class="flex items-center gap-2 text-xs flex-shrink-0 ml-2">
@ -602,11 +611,11 @@ export class FileBrowser extends LitElement {
<div
class="${this.isMobile && this.mobileView === 'preview' ? 'hidden' : ''} ${
this.isMobile ? 'w-full' : 'w-80'
} bg-secondary border-r border-base flex flex-col"
} bg-secondary border-r border-border/50 flex flex-col"
>
<!-- File list header with toggles -->
<div
class="bg-secondary border-b border-base p-3 flex items-center justify-between"
class="bg-secondary border-b border-border/50 p-3 flex items-center justify-between"
>
<div class="flex gap-2">
<button
@ -628,15 +637,6 @@ export class FileBrowser extends LitElement {
Hidden Files
</button>
</div>
${
this.gitStatus?.branch
? html`
<span class="text-muted text-xs flex items-center gap-1 font-mono">
${UIIcons.git} ${this.gitStatus.branch}
</span>
`
: ''
}
</div>
<!-- File list content -->
@ -655,7 +655,7 @@ export class FileBrowser extends LitElement {
this.currentFullPath !== '/'
? html`
<div
class="p-3 hover:bg-light cursor-pointer transition-colors flex items-center gap-2 border-b border-base"
class="p-3 hover:bg-light cursor-pointer transition-colors flex items-center gap-2 border-b border-border/50"
@click=${this.handleParentClick}
>
${getParentDirectoryIcon()}
@ -723,7 +723,7 @@ export class FileBrowser extends LitElement {
this.selectedFile
? html`
<div
class="bg-secondary border-b border-base p-3 ${
class="bg-secondary border-b border-border/50 p-3 ${
this.isMobile ? 'space-y-2' : 'flex items-center justify-between'
}"
>
@ -845,7 +845,7 @@ export class FileBrowser extends LitElement {
${
this.mode === 'select'
? html`
<div class="p-4 border-t border-base flex gap-4">
<div class="p-4 border-t border-border/50 flex gap-4">
<button class="btn-ghost font-mono flex-1" @click=${this.handleCancel}>
Cancel
</button>

View file

@ -73,8 +73,7 @@ describe('FilePicker Component', () => {
element.uploading = false;
await element.updateComplete;
const buttons = element.querySelectorAll('button');
const fileButton = Array.from(buttons).find((btn) => btn.textContent?.includes('Choose File'));
const fileButton = element.querySelector('#file-picker-choose-button');
expect(fileButton).toBeTruthy();
});
@ -85,8 +84,7 @@ describe('FilePicker Component', () => {
const cancelEventSpy = vi.fn();
element.addEventListener('file-cancel', cancelEventSpy);
const buttons = element.querySelectorAll('button');
const cancelButton = Array.from(buttons).find((btn) => btn.textContent?.includes('Cancel'));
const cancelButton = element.querySelector('#file-picker-cancel-button');
expect(cancelButton).toBeTruthy();
cancelButton?.click();
@ -129,8 +127,7 @@ describe('FilePicker Component', () => {
element.uploading = true;
await element.updateComplete;
const buttons = element.querySelectorAll('button');
const cancelButton = Array.from(buttons).find((btn) => btn.textContent?.includes('Cancel'));
const cancelButton = element.querySelector('#file-picker-cancel-button');
expect(cancelButton?.hasAttribute('disabled')).toBe(true);
});
@ -145,9 +142,7 @@ describe('FilePicker Component', () => {
element.visible = true;
await element.updateComplete;
const fileButton = Array.from(element.querySelectorAll('button')).find((btn) =>
btn.textContent?.includes('Choose File')
);
const fileButton = element.querySelector('#file-picker-choose-button');
expect(fileButton).toBeTruthy();

View file

@ -248,7 +248,7 @@ export class FilePicker extends LitElement {
return html`
<div class="fixed inset-0 bg-bg bg-opacity-80 backdrop-blur-sm flex items-center justify-center animate-fade-in" style="z-index: ${Z_INDEX.FILE_PICKER};" @click=${this.handleCancel}>
<div class="bg-elevated border border-base rounded-xl shadow-2xl p-8 m-4 max-w-sm w-full animate-scale-in" @click=${(e: Event) => e.stopPropagation()}>
<div class="bg-elevated border border-border/50 rounded-xl shadow-2xl p-8 m-4 max-w-sm w-full animate-scale-in" @click=${(e: Event) => e.stopPropagation()}>
<h3 class="text-xl font-bold text-primary mb-6">
Select File
</h3>
@ -272,6 +272,7 @@ export class FilePicker extends LitElement {
: html`
<div class="space-y-4">
<button
id="file-picker-choose-button"
@click=${this.handleFileClick}
class="w-full bg-primary text-bg font-medium py-4 px-6 rounded-lg flex items-center justify-center gap-3 transition-all duration-200 hover:bg-primary-light hover:shadow-glow active:scale-95"
>
@ -284,10 +285,11 @@ export class FilePicker extends LitElement {
`
}
<div class="mt-6 pt-6 border-t border-base">
<div class="mt-6 pt-6 border-t border-border/50">
<button
id="file-picker-cancel-button"
@click=${this.handleCancel}
class="w-full bg-secondary border border-base text-primary font-mono py-3 px-6 rounded-lg transition-all duration-200 hover:bg-surface hover:border-primary active:scale-95"
class="w-full bg-secondary border border-border/50 text-primary font-mono py-3 px-6 rounded-lg transition-all duration-200 hover:bg-surface hover:border-primary active:scale-95"
?disabled=${this.uploading}
>
Cancel

View file

@ -3,8 +3,8 @@
*/
import { LitElement } from 'lit';
import { property, state } from 'lit/decorators.js';
import type { Session } from '../../shared/types.js';
import { TIMING } from '../utils/constants.js';
import type { Session } from './session-list.js';
export abstract class HeaderBase extends LitElement {
createRenderRoot() {

View file

@ -21,12 +21,11 @@ export class InlineEdit extends LitElement {
}
.display-container {
display: flex;
display: inline-flex;
align-items: center;
gap: 0.25rem;
max-width: 100%;
min-width: 0;
width: 100%;
}
.display-text {
@ -34,7 +33,7 @@ export class InlineEdit extends LitElement {
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
max-width: 100%;
}
.edit-icon {

View file

@ -284,7 +284,7 @@ export class LogViewer extends LitElement {
<div class="flex items-center justify-center h-screen bg-base text-primary">
<div class="text-center">
<div
class="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent mb-4"
class="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent mb-4 mx-auto"
></div>
<div>Loading logs...</div>
</div>
@ -298,14 +298,14 @@ export class LogViewer extends LitElement {
${scrollbarStyles}
<div class="flex flex-col h-full bg-base text-primary font-mono">
<!-- Header - single row on desktop, two rows on mobile -->
<div class="bg-secondary border-b border-base p-3 sm:p-4">
<div class="bg-secondary border-b border-border/50 p-3 sm:p-4">
<!-- Mobile layout (two rows) -->
<div class="sm:hidden">
<!-- Top row with back button and title -->
<div class="flex items-center gap-2 mb-3">
<!-- Back button -->
<button
class="p-2 bg-base border border-base rounded text-sm text-primary hover:border-primary hover:text-primary transition-colors flex items-center gap-1 flex-shrink-0"
class="p-2 bg-base border border-border/50 rounded text-sm text-primary hover:border-primary hover:text-primary transition-colors flex items-center gap-1 flex-shrink-0"
@click=${() => {
window.location.href = '/';
}}
@ -335,7 +335,7 @@ export class LogViewer extends LitElement {
class="p-2 text-xs uppercase font-bold rounded transition-colors ${
this.autoScroll
? 'bg-primary text-base'
: 'bg-tertiary text-muted border border-base'
: 'bg-tertiary text-muted border border-border/50'
}"
@click=${() => {
this.autoScroll = !this.autoScroll;
@ -361,7 +361,7 @@ export class LogViewer extends LitElement {
<!-- Search input -->
<input
type="text"
class="px-3 py-1.5 bg-base border border-base rounded text-sm text-primary placeholder-muted focus:outline-none focus:border-primary transition-colors w-full"
class="px-3 py-1.5 bg-base border border-border/50 rounded text-sm text-primary placeholder-muted focus:outline-none focus:border-primary transition-colors w-full"
placeholder="Filter logs..."
.value=${this.filter}
@input=${(e: Event) => {

View file

@ -105,7 +105,7 @@ export class NotificationStatus extends LitElement {
return html`
<button
@click=${this.handleClick}
class="p-2 ${color} hover:text-primary transition-colors relative"
class="bg-bg-tertiary border border-border rounded-lg p-2 ${color} transition-all duration-200 hover:text-primary hover:bg-surface-hover hover:border-primary hover:shadow-sm"
title="${tooltip}"
>
${this.renderIcon()}

View file

@ -0,0 +1,488 @@
// @vitest-environment happy-dom
import { expect, fixture, html } from '@open-wc/testing';
import { vi } from 'vitest';
import { DEFAULT_QUICK_START_COMMANDS, type QuickStartCommand } from '../../types/config.js';
import './quick-start-editor.js';
import type { QuickStartEditor } from './quick-start-editor.js';
describe('QuickStartEditor', () => {
let element: QuickStartEditor;
const defaultCommands: QuickStartCommand[] = [
{ name: '✨ claude', command: 'claude' },
{ command: 'zsh' },
{ name: '▶️ pnpm run dev', command: 'pnpm run dev' },
];
beforeEach(async () => {
element = await fixture<QuickStartEditor>(html`
<quick-start-editor .commands=${defaultCommands}></quick-start-editor>
`);
});
describe('Initial state', () => {
it('should render edit button when not editing', () => {
const button = element.querySelector('#quick-start-edit-button');
expect(button).to.exist;
expect(button?.textContent).to.include('Edit');
});
it('should not show editor UI when not editing', () => {
const editor = element.querySelector('.space-y-2');
expect(editor).to.not.exist;
});
});
describe('Edit mode', () => {
beforeEach(async () => {
const editButton = element.querySelector('#quick-start-edit-button') as HTMLButtonElement;
editButton.click();
await element.updateComplete;
});
it('should show editor UI when editing', () => {
const editor = element.querySelector('.space-y-2');
expect(editor).to.exist;
});
it('should display all commands', () => {
const commandInputs = element.querySelectorAll('input[data-command-input]');
expect(commandInputs).to.have.length(3);
expect((commandInputs[0] as HTMLInputElement).value).to.equal('claude');
expect((commandInputs[1] as HTMLInputElement).value).to.equal('zsh');
expect((commandInputs[2] as HTMLInputElement).value).to.equal('pnpm run dev');
});
it('should display command names', () => {
const nameInputs = element.querySelectorAll('input[placeholder="Display name (optional)"]');
expect(nameInputs).to.have.length(3);
expect((nameInputs[0] as HTMLInputElement).value).to.equal('✨ claude');
expect((nameInputs[1] as HTMLInputElement).value).to.equal('');
expect((nameInputs[2] as HTMLInputElement).value).to.equal('▶️ pnpm run dev');
});
it('should show save and cancel buttons', () => {
const saveButton = element.querySelector('#quick-start-save-button');
const cancelButton = element.querySelector('#quick-start-cancel-button');
expect(saveButton).to.exist;
expect(cancelButton).to.exist;
});
});
describe('Adding commands', () => {
beforeEach(async () => {
const editButton = element.querySelector('#quick-start-edit-button') as HTMLButtonElement;
editButton.click();
await element.updateComplete;
});
it('should add new command when add button is clicked', async () => {
const addButton = element.querySelector(
'#quick-start-add-command-button'
) as HTMLButtonElement;
expect(addButton).to.exist;
addButton.click();
await element.updateComplete;
const commandInputs = element.querySelectorAll('input[data-command-input]');
expect(commandInputs).to.have.length(4);
expect((commandInputs[3] as HTMLInputElement).value).to.equal('');
});
it('should focus new command input', async () => {
const addButton = element.querySelector(
'#quick-start-add-command-button'
) as HTMLButtonElement;
expect(addButton).to.exist;
addButton.click();
await element.updateComplete;
// Wait for setTimeout in handleAddCommand
await new Promise((resolve) => setTimeout(resolve, 50));
const commandInputs = element.querySelectorAll('input[data-command-input]');
const lastInput = commandInputs[commandInputs.length - 1] as HTMLInputElement;
expect(document.activeElement).to.equal(lastInput);
});
});
describe('Editing commands', () => {
beforeEach(async () => {
const editButton = element.querySelector('#quick-start-edit-button') as HTMLButtonElement;
editButton.click();
await element.updateComplete;
});
it('should update command value on input', async () => {
const commandInput = element.querySelector('input[data-command-input]') as HTMLInputElement;
commandInput.value = 'bash';
commandInput.dispatchEvent(new Event('input'));
await element.updateComplete;
expect(element.editableCommands[0].command).to.equal('bash');
});
it('should update name value on input', async () => {
const nameInput = element.querySelector(
'input[placeholder="Display name (optional)"]'
) as HTMLInputElement;
nameInput.value = '🚀 bash';
nameInput.dispatchEvent(new Event('input'));
await element.updateComplete;
expect(element.editableCommands[0].name).to.equal('🚀 bash');
});
it('should set name to undefined when cleared', async () => {
const nameInput = element.querySelector(
'input[placeholder="Display name (optional)"]'
) as HTMLInputElement;
nameInput.value = '';
nameInput.dispatchEvent(new Event('input'));
await element.updateComplete;
expect(element.editableCommands[0].name).to.be.undefined;
});
});
describe('Removing commands', () => {
beforeEach(async () => {
const editButton = element.querySelector('#quick-start-edit-button') as HTMLButtonElement;
editButton.click();
await element.updateComplete;
});
it('should remove command when remove button is clicked', async () => {
const removeButton = element.querySelector(
'#quick-start-remove-command-1'
) as HTMLButtonElement;
expect(removeButton).to.exist;
removeButton.click();
await element.updateComplete;
const commandInputs = element.querySelectorAll('input[data-command-input]');
expect(commandInputs).to.have.length(2);
expect((commandInputs[0] as HTMLInputElement).value).to.equal('claude');
expect((commandInputs[1] as HTMLInputElement).value).to.equal('pnpm run dev');
});
});
describe('Saving changes', () => {
let changedListener: ReturnType<typeof vi.fn>;
beforeEach(async () => {
changedListener = vi.fn();
element.addEventListener('quick-start-changed', changedListener);
const editButton = element.querySelector('#quick-start-edit-button') as HTMLButtonElement;
editButton.click();
await element.updateComplete;
});
it('should emit quick-start-changed event with valid commands', async () => {
const saveButton = element.querySelector('#quick-start-save-button') as HTMLButtonElement;
expect(saveButton).to.exist;
saveButton.click();
await element.updateComplete;
expect(changedListener.mock.calls.length).to.equal(1);
const event = changedListener.mock.calls[0][0] as CustomEvent<QuickStartCommand[]>;
expect(event.detail).to.deep.equal(defaultCommands);
});
it('should filter out empty commands when saving', async () => {
// Add an empty command
const addButton = element.querySelector(
'#quick-start-add-command-button'
) as HTMLButtonElement;
expect(addButton).to.exist;
addButton.click();
await element.updateComplete;
const saveButton = element.querySelector('#quick-start-save-button') as HTMLButtonElement;
expect(saveButton).to.exist;
saveButton.click();
await element.updateComplete;
expect(changedListener.mock.calls.length).to.equal(1);
const event = changedListener.mock.calls[0][0] as CustomEvent<QuickStartCommand[]>;
expect(event.detail).to.have.length(3); // Empty command filtered out
});
it('should exit edit mode after saving', async () => {
const saveButton = element.querySelector('#quick-start-save-button') as HTMLButtonElement;
expect(saveButton).to.exist;
saveButton.click();
await element.updateComplete;
expect(element.editing).to.be.false;
const editButtonAfterSave = element.querySelector('#quick-start-edit-button');
expect(editButtonAfterSave).to.exist;
});
});
describe('Canceling changes', () => {
beforeEach(async () => {
const editButton = element.querySelector('#quick-start-edit-button') as HTMLButtonElement;
editButton.click();
await element.updateComplete;
});
it('should revert changes when cancel is clicked', async () => {
// Make a change
const commandInput = element.querySelector('input[data-command-input]') as HTMLInputElement;
commandInput.value = 'bash';
commandInput.dispatchEvent(new Event('input'));
await element.updateComplete;
// Cancel
const cancelButton = element.querySelector('#quick-start-cancel-button') as HTMLButtonElement;
expect(cancelButton).to.exist;
cancelButton.click();
await element.updateComplete;
// Re-enter edit mode to check
const editButton = element.querySelector('#quick-start-edit-button') as HTMLButtonElement;
editButton.click();
await element.updateComplete;
const firstCommand = element.querySelector('input[data-command-input]') as HTMLInputElement;
expect(firstCommand.value).to.equal('claude'); // Reverted to original
});
it('should emit cancel event', async () => {
const cancelListener = vi.fn();
element.addEventListener('cancel', cancelListener);
const cancelButton = element.querySelector('#quick-start-cancel-button') as HTMLButtonElement;
expect(cancelButton).to.exist;
cancelButton.click();
await element.updateComplete;
expect(cancelListener.mock.calls.length).to.equal(1);
});
it('should exit edit mode after canceling', async () => {
const cancelButton = element.querySelector('#quick-start-cancel-button') as HTMLButtonElement;
expect(cancelButton).to.exist;
cancelButton.click();
await element.updateComplete;
expect(element.editing).to.be.false;
const editButtonAfterCancel = element.querySelector('#quick-start-edit-button');
expect(editButtonAfterCancel).to.exist;
});
});
describe('Drag and drop', () => {
beforeEach(async () => {
const editButton = element.querySelector('#quick-start-edit-button') as HTMLButtonElement;
editButton.click();
await element.updateComplete;
});
it('should have draggable elements', () => {
const draggableElements = element.querySelectorAll('[draggable="true"]');
expect(draggableElements).to.have.length(3);
});
it('should handle drag start', () => {
const draggableElement = element.querySelector('[draggable="true"]') as HTMLElement;
const dragStartEvent = new DragEvent('dragstart', {
dataTransfer: new DataTransfer(),
});
draggableElement.dispatchEvent(dragStartEvent);
expect(element.draggedIndex).to.equal(0);
});
it('should handle drag end', () => {
element.draggedIndex = 0;
const draggableElement = element.querySelector('[draggable="true"]') as HTMLElement;
draggableElement.classList.add('opacity-50');
const dragEndEvent = new DragEvent('dragend');
draggableElement.dispatchEvent(dragEndEvent);
expect(element.draggedIndex).to.be.null;
expect(draggableElement.classList.contains('opacity-50')).to.be.false;
});
it('should reorder items on drop', async () => {
// Simulate dragging item at index 0 to index 2
element.draggedIndex = 0;
const dropTarget = element.querySelectorAll('[draggable="true"]')[2] as HTMLElement;
const dropEvent = new DragEvent('drop', {
dataTransfer: new DataTransfer(),
});
dropEvent.preventDefault = vi.fn();
dropTarget.dispatchEvent(dropEvent);
await element.updateComplete;
// Check new order - dragging item 0 to position 2 means:
// Original: [claude, zsh, pnpm run dev]
// After removing claude: [zsh, pnpm run dev]
// Insert at adjusted index 1: [zsh, claude, pnpm run dev]
const commandInputs = element.querySelectorAll('input[data-command-input]');
expect((commandInputs[0] as HTMLInputElement).value).to.equal('zsh');
expect((commandInputs[1] as HTMLInputElement).value).to.equal('claude');
expect((commandInputs[2] as HTMLInputElement).value).to.equal('pnpm run dev');
});
});
describe('Props updates', () => {
it('should update editableCommands when commands prop changes', async () => {
const newCommands: QuickStartCommand[] = [{ command: 'python3' }, { command: 'node' }];
element.commands = newCommands;
await element.updateComplete;
// Enter edit mode to check
const editButton = element.querySelector('#quick-start-edit-button') as HTMLButtonElement;
editButton.click();
await element.updateComplete;
const commandInputs = element.querySelectorAll('input[data-command-input]');
expect(commandInputs).to.have.length(2);
expect((commandInputs[0] as HTMLInputElement).value).to.equal('python3');
expect((commandInputs[1] as HTMLInputElement).value).to.equal('node');
});
});
describe('Reset to Defaults', () => {
beforeEach(async () => {
// Set up with modified commands
const modifiedCommands: QuickStartCommand[] = [
{ command: 'python3' },
{ name: 'Node', command: 'node' },
];
element.commands = modifiedCommands;
await element.updateComplete;
// Enter edit mode
const editButton = element.querySelector('#quick-start-edit-button') as HTMLButtonElement;
editButton.click();
await element.updateComplete;
});
it('should show Reset to Defaults button in edit mode', () => {
const resetButton = element.querySelector('#quick-start-reset-button');
expect(resetButton).to.exist;
});
it('should reset commands to defaults when clicked', async () => {
const resetButton = element.querySelector('#quick-start-reset-button') as HTMLButtonElement;
resetButton.click();
await element.updateComplete;
// Check that commands are reset
const commandInputs = element.querySelectorAll('input[data-command-input]');
expect(commandInputs).to.have.length(DEFAULT_QUICK_START_COMMANDS.length);
// Verify default values
expect((commandInputs[0] as HTMLInputElement).value).to.equal(
DEFAULT_QUICK_START_COMMANDS[0].command
);
expect((commandInputs[1] as HTMLInputElement).value).to.equal(
DEFAULT_QUICK_START_COMMANDS[1].command
);
expect((commandInputs[2] as HTMLInputElement).value).to.equal(
DEFAULT_QUICK_START_COMMANDS[2].command
);
});
it('should reset command names to defaults', async () => {
const resetButton = element.querySelector('#quick-start-reset-button') as HTMLButtonElement;
resetButton.click();
await element.updateComplete;
const nameInputs = element.querySelectorAll('input[placeholder="Display name (optional)"]');
expect((nameInputs[0] as HTMLInputElement).value).to.equal(
DEFAULT_QUICK_START_COMMANDS[0].name || ''
);
expect((nameInputs[1] as HTMLInputElement).value).to.equal(
DEFAULT_QUICK_START_COMMANDS[1].name || ''
);
expect((nameInputs[2] as HTMLInputElement).value).to.equal(
DEFAULT_QUICK_START_COMMANDS[2].name || ''
);
});
it('should maintain edit mode after reset', async () => {
const resetButton = element.querySelector('#quick-start-reset-button') as HTMLButtonElement;
resetButton.click();
await element.updateComplete;
// Should still be in edit mode
expect(element.editing).to.be.true;
// Save and Cancel buttons should still be visible
const saveButton = element.querySelector('#quick-start-save-button');
const cancelButton = element.querySelector('#quick-start-cancel-button');
expect(saveButton).to.exist;
expect(cancelButton).to.exist;
});
it('should position Reset to Defaults button correctly', () => {
const resetButton = element.querySelector('#quick-start-reset-button') as HTMLButtonElement;
const addButton = element.querySelector(
'#quick-start-add-command-button'
) as HTMLButtonElement;
expect(resetButton).to.exist;
expect(addButton).to.exist;
// Check button styling
expect(resetButton.classList.contains('text-primary')).to.be.true;
expect(resetButton.classList.contains('hover:text-primary-hover')).to.be.true;
});
it('should emit quick-start-changed event when saving after reset', async () => {
const changedListener = vi.fn();
element.addEventListener('quick-start-changed', changedListener);
// Reset to defaults
const resetButton = element.querySelector('#quick-start-reset-button') as HTMLButtonElement;
resetButton.click();
await element.updateComplete;
// Save
const saveButton = element.querySelector('#quick-start-save-button') as HTMLButtonElement;
saveButton.click();
await element.updateComplete;
expect(changedListener.mock.calls.length).to.equal(1);
const event = changedListener.mock.calls[0][0] as CustomEvent<QuickStartCommand[]>;
expect(event.detail).to.deep.equal(DEFAULT_QUICK_START_COMMANDS);
});
it('should cancel reset changes when cancel is clicked', async () => {
// Reset to defaults
const resetButton = element.querySelector('#quick-start-reset-button') as HTMLButtonElement;
resetButton.click();
await element.updateComplete;
// Cancel
const cancelButton = element.querySelector('#quick-start-cancel-button') as HTMLButtonElement;
cancelButton.click();
await element.updateComplete;
// Re-enter edit mode to check
const editButton = element.querySelector('#quick-start-edit-button') as HTMLButtonElement;
editButton.click();
await element.updateComplete;
// Should have original modified commands, not defaults
const commandInputs = element.querySelectorAll('input[data-command-input]');
expect(commandInputs).to.have.length(2);
expect((commandInputs[0] as HTMLInputElement).value).to.equal('python3');
expect((commandInputs[1] as HTMLInputElement).value).to.equal('node');
});
});
});

View file

@ -0,0 +1,297 @@
/**
* Quick Start Editor Component
*
* Inline editor for managing quick start commands within the session create dialog.
* Allows adding, editing, removing, and reordering quick start commands.
*
* @fires quick-start-changed - When commands are modified (detail: QuickStartCommand[])
* @fires cancel - When editing is cancelled
*/
import { html, LitElement } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { DEFAULT_QUICK_START_COMMANDS, type QuickStartCommand } from '../../types/config.js';
import { createLogger } from '../utils/logger.js';
const _logger = createLogger('quick-start-editor');
@customElement('quick-start-editor')
export class QuickStartEditor extends LitElement {
// Disable shadow DOM to use Tailwind
createRenderRoot() {
return this;
}
@property({ type: Array }) commands: QuickStartCommand[] = [];
@property({ type: Boolean }) editing = false;
@state() private editableCommands: QuickStartCommand[] = [];
@state() private draggedIndex: number | null = null;
connectedCallback() {
super.connectedCallback();
this.editableCommands = [...this.commands];
}
updated(changedProperties: Map<string | number | symbol, unknown>) {
if (changedProperties.has('commands')) {
this.editableCommands = [...this.commands];
}
}
private handleStartEdit() {
this.editing = true;
this.editableCommands = [...this.commands];
this.dispatchEvent(
new CustomEvent('editing-changed', {
detail: { editing: true },
bubbles: true,
composed: true,
})
);
}
private handleSave() {
// Filter out empty commands
const validCommands = this.editableCommands.filter((cmd) => cmd.command.trim());
this.dispatchEvent(
new CustomEvent('quick-start-changed', {
detail: validCommands,
bubbles: true,
composed: true,
})
);
this.editing = false;
this.dispatchEvent(
new CustomEvent('editing-changed', {
detail: { editing: false },
bubbles: true,
composed: true,
})
);
}
private handleCancel() {
this.editableCommands = [...this.commands];
this.editing = false;
this.dispatchEvent(new CustomEvent('cancel'));
this.dispatchEvent(
new CustomEvent('editing-changed', {
detail: { editing: false },
bubbles: true,
composed: true,
})
);
}
private handleNameChange(index: number, value: string) {
this.editableCommands = [...this.editableCommands];
this.editableCommands[index] = {
...this.editableCommands[index],
name: value || undefined,
};
this.requestUpdate();
}
private handleCommandChange(index: number, value: string) {
this.editableCommands = [...this.editableCommands];
this.editableCommands[index] = {
...this.editableCommands[index],
command: value,
};
this.requestUpdate();
}
private handleAddCommand() {
this.editableCommands = [...this.editableCommands, { command: '' }];
this.requestUpdate();
// Focus the new command input after render
setTimeout(() => {
const inputs = this.querySelectorAll('input[data-command-input]');
const lastInput = inputs[inputs.length - 1] as HTMLInputElement;
lastInput?.focus();
}, 0);
}
private handleResetToDefaults() {
this.editableCommands = [...DEFAULT_QUICK_START_COMMANDS];
this.requestUpdate();
}
private handleRemoveCommand(index: number) {
this.editableCommands = this.editableCommands.filter((_, i) => i !== index);
this.requestUpdate();
}
private handleDragStart(e: DragEvent, index: number) {
this.draggedIndex = index;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', ''); // Required for Firefox
}
// Add dragging class
const target = e.target as HTMLElement;
target.classList.add('opacity-50');
}
private handleDragEnd(e: DragEvent) {
const target = e.target as HTMLElement;
target.classList.remove('opacity-50');
this.draggedIndex = null;
}
private handleDragOver(e: DragEvent) {
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'move';
}
}
private handleDrop(e: DragEvent, dropIndex: number) {
e.preventDefault();
if (this.draggedIndex === null || this.draggedIndex === dropIndex) return;
const commands = [...this.editableCommands];
const draggedCommand = commands[this.draggedIndex];
// Remove from old position
commands.splice(this.draggedIndex, 1);
// Insert at new position
const adjustedIndex = this.draggedIndex < dropIndex ? dropIndex - 1 : dropIndex;
commands.splice(adjustedIndex, 0, draggedCommand);
this.editableCommands = commands;
this.requestUpdate();
}
render() {
if (!this.editing) {
return html`
<button
id="quick-start-edit-button"
@click=${this.handleStartEdit}
class="text-primary hover:text-primary-hover text-[10px] sm:text-xs transition-colors duration-200 flex items-center gap-1"
title="Edit quick start commands"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit
</button>
`;
}
return html`
<div class="w-full px-3 sm:px-4 lg:px-6 bg-bg-elevated py-3 sm:py-4">
<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}
class="text-text-muted hover:text-text text-[10px] transition-colors duration-200"
>
Cancel
</button>
<button
id="quick-start-save-button"
@click=${this.handleSave}
class="text-primary hover:text-primary-hover text-[10px] font-medium transition-colors duration-200"
>
Save
</button>
</div>
</div>
<div class="space-y-2 max-h-48 overflow-y-auto">
${this.editableCommands.map(
(cmd, index) => html`
<div
id=${`quick-start-command-item-${index}`}
draggable="true"
@dragstart=${(e: DragEvent) => this.handleDragStart(e, index)}
@dragend=${this.handleDragEnd}
@dragover=${this.handleDragOver}
@drop=${(e: DragEvent) => this.handleDrop(e, index)}
class="flex items-center gap-2 p-2 bg-bg-secondary/50 border border-border/30 rounded-lg cursor-move hover:border-border/50 transition-colors duration-200"
>
<svg class="w-3 h-3 text-text-muted flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
<input
id=${`quick-start-name-input-${index}`}
type="text"
.value=${cmd.name || ''}
@input=${(e: Event) => this.handleNameChange(index, (e.target as HTMLInputElement).value)}
placeholder="Display name (optional)"
class="flex-1 min-w-0 bg-bg-secondary border border-border/30 rounded px-2 py-1 text-[10px] text-text focus:border-primary focus:outline-none"
/>
<input
id=${`quick-start-command-input-${index}`}
type="text"
.value=${cmd.command}
@input=${(e: Event) => this.handleCommandChange(index, (e.target as HTMLInputElement).value)}
placeholder="Command"
data-command-input
class="flex-1 min-w-0 bg-bg-secondary border border-border/30 rounded px-2 py-1 text-[10px] text-text font-mono focus:border-primary focus:outline-none"
/>
<button
id=${`quick-start-remove-command-${index}`}
@click=${() => this.handleRemoveCommand(index)}
class="text-text-muted hover:text-error transition-colors duration-200 p-1"
title="Remove command"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</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 -->
<div class="flex justify-end gap-4 mt-4 pt-3 border-t border-border/30">
<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"
>
Delete All
</button>
</div>
</div>
`;
}
}

View file

@ -185,7 +185,7 @@ describe('SessionCard', () => {
element.session = createMockSession({ pid: mockPid });
await element.updateComplete;
const pidElement = element.querySelector('[title="Click to copy PID"]');
const pidElement = element.querySelector('#session-pid-copy');
if (pidElement) {
(pidElement as HTMLElement).click();
@ -197,7 +197,7 @@ describe('SessionCard', () => {
const selectHandler = vi.fn();
element.addEventListener('session-select', selectHandler);
const killButton = element.querySelector('[title="Kill session"]');
const killButton = element.querySelector('#session-kill-button');
if (killButton) {
(killButton as HTMLElement).click();
@ -212,7 +212,7 @@ describe('SessionCard', () => {
element.session = createMockSession({ status: 'running' });
await element.updateComplete;
const killButton = element.querySelector('[title="Kill session"]');
const killButton = element.querySelector('#session-kill-button');
expect(killButton).toBeTruthy();
});
@ -220,7 +220,7 @@ describe('SessionCard', () => {
element.session = createMockSession({ status: 'exited' });
await element.updateComplete;
const cleanupButton = element.querySelector('[title="Clean up session"]');
const cleanupButton = element.querySelector('#session-kill-button');
expect(cleanupButton).toBeTruthy();
});
@ -228,7 +228,7 @@ describe('SessionCard', () => {
element.session = createMockSession({ status: 'unknown' as 'running' | 'exited' });
await element.updateComplete;
const killButton = element.querySelector('button[title*="session"]');
const killButton = element.querySelector('#session-kill-button');
expect(killButton).toBeFalsy();
});
@ -267,7 +267,7 @@ describe('SessionCard', () => {
expect.objectContaining({
detail: {
sessionId: element.session.id,
error: expect.stringContaining('kill failed'),
error: expect.stringContaining('Failed to terminate session'),
},
})
);
@ -316,18 +316,20 @@ describe('SessionCard', () => {
element.session = createMockSession({ status: 'exited' });
await element.updateComplete;
fetchMock.mockResponse(`/api/sessions/${element.session.id}/cleanup`, { success: true });
fetchMock.mockResponse(`/api/sessions/${element.session.id}`, { success: true });
const killedHandler = vi.fn();
element.addEventListener('session-killed', killedHandler);
await element.kill();
// Should use cleanup endpoint for exited sessions
// Should use DELETE endpoint for exited sessions
const calls = fetchMock.getCalls();
const cleanupCall = calls.find((call) => call[0].includes('/cleanup'));
expect(cleanupCall).toBeDefined();
expect(cleanupCall?.[0]).toContain('/cleanup');
const deleteCall = calls.find((call) =>
call[0].includes(`/api/sessions/${element.session.id}`)
);
expect(deleteCall).toBeDefined();
expect(deleteCall?.[1]?.method).toBe('DELETE');
expect(killedHandler).toHaveBeenCalled();
});
});

View file

@ -14,6 +14,7 @@ import { html, LitElement } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import type { Session } from '../../shared/types.js';
import type { AuthClient } from '../services/auth-client.js';
import { sessionActionService } from '../services/session-action-service.js';
import { isAIAssistantSession, sendAIPrompt } from '../utils/ai-sessions.js';
import { createLogger } from '../utils/logger.js';
import { copyToClipboard } from '../utils/path-utils.js';
@ -202,66 +203,57 @@ export class SessionCard extends LitElement {
}
// Send kill or cleanup request based on session status
try {
// Use different endpoint based on session status
const endpoint =
this.session.status === 'exited'
? `/api/sessions/${this.session.id}/cleanup`
: `/api/sessions/${this.session.id}`;
const action = this.session.status === 'exited' ? 'clear' : 'terminate';
const action = this.session.status === 'exited' ? 'cleanup' : 'kill';
const result = await sessionActionService.deleteSession(this.session, {
authClient: this.authClient,
callbacks: {
onError: (errorMessage) => {
logger.error('Error killing session', {
error: errorMessage,
sessionId: this.session.id,
});
const response = await fetch(endpoint, {
method: 'DELETE',
headers: {
...this.authClient.getAuthHeader(),
// Show error to user (keep animation to indicate something went wrong)
this.dispatchEvent(
new CustomEvent('session-kill-error', {
detail: {
sessionId: this.session.id,
error: errorMessage,
},
bubbles: true,
composed: true,
})
);
clearTimeout(killingTimeout);
},
});
onSuccess: () => {
// Kill/cleanup succeeded - dispatch event to notify parent components
this.dispatchEvent(
new CustomEvent('session-killed', {
detail: {
sessionId: this.session.id,
session: this.session,
},
bubbles: true,
composed: true,
})
);
if (!response.ok) {
const errorData = await response.text();
logger.error(`Failed to ${action} session`, { errorData, sessionId: this.session.id });
throw new Error(`${action} failed: ${response.status}`);
}
logger.log(
`Session ${this.session.id} ${action === 'clear' ? 'cleaned up' : 'killed'} successfully`
);
clearTimeout(killingTimeout);
},
},
});
// Kill/cleanup succeeded - dispatch event to notify parent components
this.dispatchEvent(
new CustomEvent('session-killed', {
detail: {
sessionId: this.session.id,
session: this.session,
},
bubbles: true,
composed: true,
})
);
// Stop animation in all cases
this.stopKillingAnimation();
clearTimeout(killingTimeout);
logger.log(
`Session ${this.session.id} ${action === 'cleanup' ? 'cleaned up' : 'killed'} successfully`
);
clearTimeout(killingTimeout);
return true;
} catch (error) {
logger.error('Error killing session', { error, sessionId: this.session.id });
// Show error to user (keep animation to indicate something went wrong)
this.dispatchEvent(
new CustomEvent('session-kill-error', {
detail: {
sessionId: this.session.id,
error: error instanceof Error ? error.message : 'Unknown error',
},
bubbles: true,
composed: true,
})
);
clearTimeout(killingTimeout);
return false;
} finally {
// Stop animation in all cases
this.stopKillingAnimation();
clearTimeout(killingTimeout);
}
return result.success;
}
private stopKillingAnimation() {
@ -436,6 +428,7 @@ export class SessionCard extends LitElement {
e.stopPropagation();
this.handleMagicButton();
}}
id="session-magic-button"
title="Send prompt to update terminal title"
aria-label="Send magic prompt to AI assistant"
?disabled=${this.isSendingPrompt}
@ -460,6 +453,7 @@ export class SessionCard extends LitElement {
}"
@click=${this.handleKillClick}
?disabled=${this.killing}
id="session-kill-button"
title="${this.session.status === 'running' ? 'Kill session' : 'Clean up session'}"
data-testid="kill-session-button"
>
@ -547,6 +541,7 @@ export class SessionCard extends LitElement {
? html`
<span
class="cursor-pointer hover:text-primary transition-colors text-xs flex-shrink-0 ml-2 inline-flex items-center gap-1"
id="session-pid-copy"
@click=${this.handlePidClick}
title="Click to copy PID"
>

View file

@ -501,6 +501,22 @@ describe('SessionCreateForm', () => {
});
it('should show spawn window toggle when Mac app is connected', async () => {
// Clear existing mocks
fetchMock.clear();
// Mock auth config endpoint
fetchMock.mockResponse('/api/auth/config', {
providers: [],
isPasswordlessSupported: false,
});
// Mock config endpoint
fetchMock.mockResponse('/api/config', {
repositoryBasePath: '~/',
serverConfigured: true,
quickStartCommands: [],
});
// Mock server status endpoint to return Mac app connected
fetchMock.mockResponse('/api/server/status', {
macAppConnected: true,
@ -514,9 +530,23 @@ describe('SessionCreateForm', () => {
`);
// Wait for async operations to complete
await waitForAsync();
await waitForAsync(200);
await newElement.updateComplete;
// Force the component to check server status
// @ts-expect-error - accessing private method for testing
await newElement.checkServerStatus();
await waitForAsync(100);
await newElement.updateComplete;
// First check if Options section is expanded
const optionsButton = newElement.querySelector('#session-options-button');
if (optionsButton) {
optionsButton.click();
await newElement.updateComplete;
}
// Check that spawn window toggle is rendered
const spawnToggle = newElement.querySelector('[data-testid="spawn-window-toggle"]');
expect(spawnToggle).toBeTruthy();
@ -653,4 +683,192 @@ describe('SessionCreateForm', () => {
newElement.remove();
});
});
describe('quick start editor integration', () => {
beforeEach(async () => {
// Import quick-start-editor component
await import('./quick-start-editor');
// Remove the existing element created by the outer beforeEach
element.remove();
// Clear fetch mock and set up new responses
fetchMock.clear();
// Mock config endpoint with quick start commands
fetchMock.mockResponse('/api/config', {
repositoryBasePath: '~/',
serverConfigured: true,
quickStartCommands: [
{ name: '✨ claude', command: 'claude' },
{ command: 'zsh' },
{ name: '▶️ pnpm run dev', command: 'pnpm run dev' },
],
});
// Mock server status
fetchMock.mockResponse('/api/server/status', {
macAppConnected: false,
isHQMode: false,
version: '1.0.0',
});
// Create new element with proper mocks
element = await fixture<SessionCreateForm>(html`
<session-create-form .authClient=${mockAuthClient} .visible=${true}></session-create-form>
`);
await element.updateComplete;
});
it('should render quick start editor component', async () => {
await waitForAsync();
await element.updateComplete;
const quickStartEditor = element.querySelector('quick-start-editor');
expect(quickStartEditor).toBeTruthy();
});
it('should pass commands to quick start editor', async () => {
await waitForAsync();
await element.updateComplete;
const quickStartEditor = element.querySelector('quick-start-editor');
expect(quickStartEditor?.commands).toEqual([
{ name: '✨ claude', command: 'claude' },
{ command: 'zsh' },
{ name: '▶️ pnpm run dev', command: 'pnpm run dev' },
]);
});
it('should handle quick-start-changed event', async () => {
await waitForAsync();
await element.updateComplete;
const newCommands = [{ command: 'python3' }, { name: '🚀 node', command: 'node' }];
// Get the quick start editor element
const quickStartEditor = element.querySelector('quick-start-editor');
expect(quickStartEditor).toBeTruthy();
// Dispatch event from the quick start editor element (not the form)
const event = new CustomEvent('quick-start-changed', {
detail: newCommands,
bubbles: true,
composed: true,
});
quickStartEditor?.dispatchEvent(event);
await waitForAsync(100); // Give more time for async operations
// Check PUT request was made
const calls = fetchMock.getCalls();
const putCall = calls.find((call) => call[0] === '/api/config' && call[1]?.method === 'PUT');
if (!putCall) {
console.log(
'All fetch calls:',
calls.map((c) => ({ url: c[0], method: c[1]?.method }))
);
}
expect(putCall).toBeTruthy();
if (putCall) {
const requestBody = JSON.parse((putCall[1]?.body as string) || '{}');
expect(requestBody).toEqual({
quickStartCommands: newCommands,
});
}
});
it('should include auth header when saving quick start commands', async () => {
await waitForAsync();
await element.updateComplete;
const newCommands = [{ command: 'bash' }];
// Get the quick start editor element
const quickStartEditor = element.querySelector('quick-start-editor');
expect(quickStartEditor).toBeTruthy();
// Dispatch event from the quick start editor element
const event = new CustomEvent('quick-start-changed', {
detail: newCommands,
bubbles: true,
composed: true,
});
quickStartEditor?.dispatchEvent(event);
await waitForAsync(100); // Give more time for async operations
// Check auth header was included
const putCall = fetchMock
.getCalls()
.find((call) => call[0] === '/api/config' && call[1]?.method === 'PUT');
expect(putCall).toBeTruthy();
expect(putCall?.[1]?.headers).toEqual({
'Content-Type': 'application/json',
Authorization: 'Bearer test-token',
});
});
it('should handle save error gracefully', async () => {
await waitForAsync();
await element.updateComplete;
// Mock the PUT endpoint to return error
fetchMock.mockResponse('/api/config', { error: 'Failed to save' }, { status: 500 });
const originalCommands = [...element.quickStartCommands];
const newCommands = [{ command: 'invalid' }];
// Dispatch event
const event = new CustomEvent('quick-start-changed', {
detail: newCommands,
bubbles: true,
});
element.dispatchEvent(event);
await waitForAsync();
// Commands should not be updated on error
expect(element.quickStartCommands).toEqual(originalCommands);
});
it('should load quick start commands from server on init', async () => {
// Check that config endpoint was called
const configCall = fetchMock.getCalls().find((call) => call[0] === '/api/config');
expect(configCall).toBeTruthy();
// Check commands were loaded
expect(element.quickStartCommands).toEqual([
{ label: '✨ claude', command: 'claude' },
{ label: 'zsh', command: 'zsh' },
{ label: '▶️ pnpm run dev', command: 'pnpm run dev' },
]);
});
it('should use default commands if server config fails', async () => {
// Clear existing calls
fetchMock.clear();
// Mock config endpoint to fail
fetchMock.mockResponse('/api/config', { error: 'Server error' }, { status: 500 });
// Create new element
const newElement = await fixture<SessionCreateForm>(html`
<session-create-form .authClient=${mockAuthClient} .visible=${true}></session-create-form>
`);
await waitForAsync();
await newElement.updateComplete;
// Should have default commands
expect(newElement.quickStartCommands.length).toBeGreaterThan(0);
expect(newElement.quickStartCommands.some((cmd) => cmd.command === 'zsh')).toBe(true);
newElement.remove();
});
});
});

View file

@ -14,9 +14,13 @@
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 type { Session } from '../../shared/types.js';
import { TitleMode } from '../../shared/types.js';
import type { QuickStartCommand } from '../../types/config.js';
import type { AuthClient } from '../services/auth-client.js';
import { RepositoryService } from '../services/repository-service.js';
import { ServerConfigService } from '../services/server-config-service.js';
import { type SessionCreateData, SessionService } from '../services/session-service.js';
import { parseCommand } from '../utils/command-utils.js';
import { createLogger } from '../utils/logger.js';
@ -34,11 +38,6 @@ import {
AutocompleteManager,
type Repository,
} from './autocomplete-manager.js';
import type { Session } from './session-list.js';
import {
STORAGE_KEY as APP_PREFERENCES_STORAGE_KEY,
type AppPreferences,
} from './unified-settings.js';
const logger = createLogger('session-create-form');
@ -69,35 +68,41 @@ export class SessionCreateForm extends LitElement {
@state() private completions: AutocompleteItem[] = [];
@state() private selectedCompletionIndex = -1;
@state() private isLoadingCompletions = false;
@state() private showOptions = false;
@state() private quickStartEditMode = false;
quickStartCommands = [
{ label: 'claude', command: 'claude' },
{ label: 'gemini', command: 'gemini' },
@state() private quickStartCommands = [
{ label: 'claude', command: 'claude' },
{ label: 'gemini', command: 'gemini' },
{ label: 'zsh', command: 'zsh' },
{ label: 'python3', command: 'python3' },
{ label: 'node', command: 'node' },
{ label: 'pnpm run dev', command: 'pnpm run dev' },
{ label: '▶️ pnpm run dev', command: 'pnpm run dev' },
];
private completionsDebounceTimer?: NodeJS.Timeout;
private autocompleteManager!: AutocompleteManager;
private repositoryService?: RepositoryService;
private sessionService?: SessionService;
private serverConfigService?: ServerConfigService;
connectedCallback() {
async connectedCallback() {
super.connectedCallback();
// Initialize services - AutocompleteManager handles optional authClient
this.autocompleteManager = new AutocompleteManager(this.authClient);
this.serverConfigService = new ServerConfigService(this.authClient);
// Initialize other services only if authClient is available
if (this.authClient) {
this.repositoryService = new RepositoryService(this.authClient);
this.repositoryService = new RepositoryService(this.authClient, this.serverConfigService);
this.sessionService = new SessionService(this.authClient);
}
// Load from localStorage when component is first created
this.loadFromLocalStorage();
await this.loadFromLocalStorage();
// Check server status
this.checkServerStatus();
// Load server configuration including quick start commands
this.loadServerConfig();
}
disconnectedCallback() {
@ -147,18 +152,17 @@ export class SessionCreateForm extends LitElement {
}
};
private loadFromLocalStorage() {
private async loadFromLocalStorage() {
const formData = loadSessionFormData();
// Get app preferences for repository base path to use as default working dir
// Get repository base path from server config to use as default working dir
let appRepoBasePath = '~/';
const savedPreferences = localStorage.getItem(APP_PREFERENCES_STORAGE_KEY);
if (savedPreferences) {
if (this.serverConfigService) {
try {
const preferences: AppPreferences = JSON.parse(savedPreferences);
appRepoBasePath = preferences.repositoryBasePath || '~/';
appRepoBasePath = await this.serverConfigService.getRepositoryBasePath();
} catch (error) {
logger.error('Failed to parse app preferences:', error);
logger.error('Failed to get repository base path from server:', error);
appRepoBasePath = '~/';
}
}
@ -189,6 +193,49 @@ export class SessionCreateForm extends LitElement {
});
}
private async loadServerConfig() {
if (!this.serverConfigService) {
return;
}
try {
const quickStartCommands = await this.serverConfigService.getQuickStartCommands();
if (quickStartCommands && quickStartCommands.length > 0) {
// Map server config to our format
this.quickStartCommands = quickStartCommands.map((cmd: QuickStartCommand) => ({
label: cmd.name || cmd.command,
command: cmd.command,
}));
logger.debug('Loaded quick start commands from server:', this.quickStartCommands);
}
} catch (error) {
logger.error('Failed to load server config:', error);
// Keep default quick start commands on error
}
}
private async handleQuickStartChanged(e: CustomEvent<QuickStartCommand[]>) {
const commands = e.detail;
if (!this.serverConfigService) {
logger.error('Server config service not initialized');
return;
}
try {
await this.serverConfigService.updateQuickStartCommands(commands);
// Update local state
this.quickStartCommands = commands.map((cmd: QuickStartCommand) => ({
label: cmd.name || cmd.command,
command: cmd.command,
}));
logger.debug('Updated quick start commands:', this.quickStartCommands);
} catch (error) {
logger.error('Failed to save quick start commands:', error);
}
}
private async checkServerStatus() {
// Defensive check - authClient should always be provided
if (!this.authClient) {
@ -219,14 +266,18 @@ export class SessionCreateForm extends LitElement {
// Handle authClient becoming available
if (changedProperties.has('authClient') && this.authClient) {
// Initialize services if they haven't been created yet
if (!this.repositoryService) {
this.repositoryService = new RepositoryService(this.authClient);
if (!this.repositoryService && this.serverConfigService) {
this.repositoryService = new RepositoryService(this.authClient, this.serverConfigService);
}
if (!this.sessionService) {
this.sessionService = new SessionService(this.authClient);
}
// Update autocomplete manager's authClient
this.autocompleteManager.setAuthClient(this.authClient);
// Update server config service's authClient
if (this.serverConfigService) {
this.serverConfigService.setAuthClient(this.authClient);
}
}
// Handle visibility changes
@ -240,7 +291,10 @@ export class SessionCreateForm extends LitElement {
this.titleMode = TitleMode.DYNAMIC;
// Then load from localStorage which may override the defaults
this.loadFromLocalStorage();
// Don't await since we're in updated() lifecycle method
this.loadFromLocalStorage().catch((error) => {
logger.error('Failed to load from localStorage:', error);
});
// Re-check server status when form becomes visible
this.checkServerStatus();
@ -501,6 +555,10 @@ export class SessionCreateForm extends LitElement {
this.selectedCompletionIndex = -1;
}
private handleToggleOptions() {
this.showOptions = !this.showOptions;
}
private handleWorkingDirKeydown(e: KeyboardEvent) {
if (!this.showCompletions || this.completions.length === 0) return;
@ -539,12 +597,12 @@ export class SessionCreateForm extends LitElement {
return html`
<div class="modal-backdrop flex items-center justify-center" @click=${this.handleBackdropClick} role="dialog" aria-modal="true">
<div
class="modal-content font-mono text-sm w-full max-w-[calc(100vw-1rem)] sm:max-w-md lg:max-w-[576px] mx-2 sm:mx-4"
class="modal-content font-mono text-sm w-full max-w-[calc(100vw-1rem)] sm:max-w-md lg:max-w-[576px] mx-2 sm:mx-4 overflow-hidden"
style="pointer-events: auto;"
@click=${(e: Event) => e.stopPropagation()}
data-testid="session-create-modal"
>
<div class="p-3 sm:p-4 lg:p-6 mb-1 sm:mb-2 lg:mb-3 border-b border-border/50 relative bg-gradient-to-r from-bg-secondary to-bg-tertiary flex-shrink-0">
<div class="p-3 sm:p-4 lg:p-6 mb-1 sm:mb-2 lg:mb-3 border-b border-border/50 relative bg-gradient-to-r from-bg-secondary to-bg-tertiary flex-shrink-0 rounded-t-xl">
<h2 id="modal-title" class="text-primary text-base sm:text-lg lg:text-xl font-bold">New Session</h2>
<button
class="absolute top-2 right-2 sm:top-3 sm:right-3 lg:top-5 lg:right-5 text-text-muted hover:text-text transition-all duration-200 p-1.5 sm:p-2 hover:bg-bg-elevated/30 rounded-lg"
@ -599,7 +657,7 @@ export class SessionCreateForm extends LitElement {
</div>
<!-- Working Directory -->
<div class="mb-2 sm:mb-3 lg:mb-5">
<div class="mb-4 sm:mb-5 lg:mb-6">
<label class="form-label text-text-muted text-[10px] sm:text-xs lg:text-sm">Working Directory:</label>
<div class="relative">
<div class="flex gap-1.5 sm:gap-2">
@ -616,6 +674,7 @@ export class SessionCreateForm extends LitElement {
autocomplete="off"
/>
<button
id="session-browse-button"
class="bg-bg-tertiary border border-border/50 rounded-lg p-1.5 sm:p-2 lg:p-3 font-mono text-text-muted transition-all duration-200 hover:text-primary hover:bg-surface-hover hover:border-primary/50 hover:shadow-sm flex-shrink-0"
@click=${this.handleBrowse}
?disabled=${this.disabled || this.isCreating}
@ -629,6 +688,7 @@ export class SessionCreateForm extends LitElement {
</svg>
</button>
<button
id="session-autocomplete-button"
class="bg-bg-tertiary border border-border/50 rounded-lg p-1.5 sm:p-2 lg:p-3 font-mono text-text-muted transition-all duration-200 hover:text-primary hover:bg-surface-hover hover:border-primary/50 hover:shadow-sm flex-shrink-0 ${
this.showRepositoryDropdown || this.showCompletions
? 'text-primary border-primary/50'
@ -727,93 +787,168 @@ export class SessionCreateForm extends LitElement {
}
</div>
<!-- Spawn Window Toggle - Only show when Mac app is connected -->
${
this.macAppConnected
? html`
<div class="mb-2 sm:mb-3 lg:mb-5 flex items-center justify-between bg-bg-elevated border border-border/50 rounded-lg p-2 sm:p-3 lg:p-4">
<div class="flex-1 pr-2 sm:pr-3 lg:pr-4">
<span class="text-primary text-[10px] sm:text-xs lg:text-sm font-medium">Spawn window</span>
<p class="text-[9px] sm:text-[10px] lg:text-xs text-text-muted mt-0.5 hidden sm:block">Opens native terminal window</p>
<!-- Quick Start Section -->
<div class="${this.quickStartEditMode ? '' : 'mb-4 sm:mb-5 lg:mb-6'}">
${
this.quickStartEditMode
? html`
<!-- Full width editor when in edit mode -->
<div class="-mx-3 sm:-mx-4 lg:-mx-6">
<quick-start-editor
.commands=${this.quickStartCommands.map((cmd) => ({
name: cmd.label === cmd.command ? undefined : cmd.label,
command: cmd.command,
}))}
.editing=${true}
@quick-start-changed=${this.handleQuickStartChanged}
@editing-changed=${(e: CustomEvent) => {
this.quickStartEditMode = e.detail.editing;
}}
></quick-start-editor>
</div>
<button
role="switch"
aria-checked="${this.spawnWindow}"
@click=${this.handleSpawnWindowChange}
class="relative inline-flex h-4 w-8 sm:h-5 sm:w-10 lg:h-6 lg:w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-bg-secondary ${
this.spawnWindow ? 'bg-primary' : 'bg-border/50'
}"
?disabled=${this.disabled || this.isCreating}
data-testid="spawn-window-toggle"
>
<span
class="inline-block h-3 w-3 sm:h-4 sm:w-4 lg:h-5 lg:w-5 transform rounded-full bg-bg-elevated transition-transform ${
this.spawnWindow ? 'translate-x-4 sm:translate-x-5' : 'translate-x-0.5'
}"
></span>
</button>
`
: html`
<!-- Normal mode with Edit button -->
<div class="flex items-center justify-between mb-1 sm:mb-2 lg:mb-3 mt-4 sm:mt-5 lg:mt-6">
<label class="form-label text-text-muted uppercase text-[9px] sm:text-[10px] lg:text-xs tracking-wider"
>Quick Start</label
>
<quick-start-editor
.commands=${this.quickStartCommands.map((cmd) => ({
name: cmd.label === cmd.command ? undefined : cmd.label,
command: cmd.command,
}))}
.editing=${false}
@quick-start-changed=${this.handleQuickStartChanged}
@editing-changed=${(e: CustomEvent) => {
this.quickStartEditMode = e.detail.editing;
}}
></quick-start-editor>
</div>
`
}
${
!this.quickStartEditMode
? html`
<div class="grid grid-cols-2 gap-2 sm:gap-2.5 lg:gap-3 mt-1.5 sm:mt-2">
${this.quickStartCommands.map(
({ label, command }) => html`
<button
@click=${() => this.handleQuickStart(command)}
class="${
this.command === command
? 'px-2 py-1.5 sm:px-3 sm:py-2 lg:px-4 lg:py-3 rounded-lg border text-left transition-all bg-primary bg-opacity-10 border-primary/50 text-primary hover:bg-opacity-20 font-medium text-[10px] sm:text-xs lg:text-sm'
: 'px-2 py-1.5 sm:px-3 sm:py-2 lg:px-4 lg:py-3 rounded-lg border text-left transition-all bg-bg-elevated border-border/50 text-text hover:bg-hover hover:border-primary/50 hover:text-primary text-[10px] sm:text-xs lg:text-sm'
}"
?disabled=${this.disabled || this.isCreating}
>
${label}
</button>
`
)}
</div>
`
: ''
}
<!-- Terminal Title Mode -->
<div class="${this.macAppConnected ? '' : 'mt-2 sm:mt-3 lg:mt-5'} mb-2 sm:mb-4 lg:mb-6 flex items-center justify-between bg-bg-elevated border border-border/50 rounded-lg p-2 sm:p-3 lg:p-4">
<div class="flex-1 pr-2 sm:pr-3 lg:pr-4">
<span class="text-primary text-[10px] sm:text-xs lg:text-sm font-medium">Terminal Title Mode</span>
<p class="text-[9px] sm:text-[10px] lg:text-xs text-text-muted mt-0.5 hidden sm:block">
${getTitleModeDescription(this.titleMode)}
</p>
</div>
<div class="relative">
<select
.value=${this.titleMode}
@change=${this.handleTitleModeChange}
class="bg-bg-tertiary border border-border/50 rounded-lg px-1.5 py-1 pr-6 sm:px-2 sm:py-1.5 sm:pr-7 lg:px-3 lg:py-2 lg:pr-8 text-text text-[10px] sm:text-xs lg:text-sm transition-all duration-200 hover:border-primary/50 focus:border-primary focus:outline-none appearance-none cursor-pointer"
style="min-width: 80px"
?disabled=${this.disabled || this.isCreating}
>
<option value="${TitleMode.NONE}" class="bg-bg-tertiary text-text" ?selected=${this.titleMode === TitleMode.NONE}>None</option>
<option value="${TitleMode.FILTER}" class="bg-bg-tertiary text-text" ?selected=${this.titleMode === TitleMode.FILTER}>Filter</option>
<option value="${TitleMode.STATIC}" class="bg-bg-tertiary text-text" ?selected=${this.titleMode === TitleMode.STATIC}>Static</option>
<option value="${TitleMode.DYNAMIC}" class="bg-bg-tertiary text-text" ?selected=${this.titleMode === TitleMode.DYNAMIC}>Dynamic</option>
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-1 sm:px-1.5 lg:px-2 text-text-muted">
<svg class="h-2.5 w-2.5 sm:h-3 sm:w-3 lg:h-4 lg:w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
: ''
}
</div>
<!-- Quick Start Section -->
<!-- Options Section (collapsible) -->
<div class="mb-2 sm:mb-4 lg:mb-6">
<label class="form-label text-text-muted uppercase text-[9px] sm:text-[10px] lg:text-xs tracking-wider mb-1 sm:mb-2 lg:mb-3"
>Quick Start</label
<button
id="session-options-button"
@click=${this.handleToggleOptions}
class="flex items-center gap-1.5 sm:gap-2 text-text-muted hover:text-primary transition-colors duration-200"
type="button"
aria-expanded="${this.showOptions}"
>
<div class="grid grid-cols-2 gap-2 sm:gap-2.5 lg:gap-3 mt-1.5 sm:mt-2">
${this.quickStartCommands.map(
({ label, command }) => html`
<button
@click=${() => this.handleQuickStart(command)}
class="${
this.command === command
? 'px-2 py-1.5 sm:px-3 sm:py-2 lg:px-4 lg:py-3 rounded-lg border text-left transition-all bg-primary bg-opacity-10 border-primary/50 text-primary hover:bg-opacity-20 font-medium text-[10px] sm:text-xs lg:text-sm'
: 'px-2 py-1.5 sm:px-3 sm:py-2 lg:px-4 lg:py-3 rounded-lg border text-left transition-all bg-bg-elevated border-border/50 text-text hover:bg-hover hover:border-primary/50 hover:text-primary text-[10px] sm:text-xs lg:text-sm'
}"
?disabled=${this.disabled || this.isCreating}
>
<span class="hidden sm:inline">${label === 'gemini' ? '✨ ' : ''}${label === 'claude' ? '✨ ' : ''}${
label === 'pnpm run dev' ? '▶️ ' : ''
}</span><span class="sm:hidden">${label === 'pnpm run dev' ? '▶️ ' : ''}</span>${label}
</button>
`
)}
</div>
<svg
width="8"
height="8"
class="sm:w-2 sm:h-2 lg:w-2.5 lg:h-2.5 transition-transform duration-200 flex-shrink-0"
viewBox="0 0 16 16"
fill="currentColor"
style="transform: ${this.showOptions ? 'rotate(90deg)' : 'rotate(0deg)'}"
>
<path
d="M5.22 1.22a.75.75 0 011.06 0l6.25 6.25a.75.75 0 010 1.06l-6.25 6.25a.75.75 0 01-1.06-1.06L10.94 8 5.22 2.28a.75.75 0 010-1.06z"
/>
</svg>
<span class="form-label text-text-muted uppercase text-[9px] sm:text-[10px] lg:text-xs tracking-wider">Options</span>
</button>
${
this.showOptions
? html`
<div class="space-y-2 sm:space-y-3 mt-2 sm:mt-4 lg:mt-6">
<!-- Spawn Window Toggle - Only show when Mac app is connected -->
${
this.macAppConnected
? html`
<div class="flex items-center justify-between bg-bg-elevated border border-border/50 rounded-lg p-2 sm:p-3 lg:p-4">
<div class="flex-1 pr-2 sm:pr-3 lg:pr-4">
<span class="text-primary text-[10px] sm:text-xs lg:text-sm font-medium">Spawn window</span>
<p class="text-[9px] sm:text-[10px] lg:text-xs text-text-muted mt-0.5 hidden sm:block">Opens native terminal window</p>
</div>
<button
role="switch"
aria-checked="${this.spawnWindow}"
@click=${this.handleSpawnWindowChange}
class="relative inline-flex h-4 w-8 sm:h-5 sm:w-10 lg:h-6 lg:w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-bg-secondary ${
this.spawnWindow ? 'bg-primary' : 'bg-border/50'
}"
?disabled=${this.disabled || this.isCreating}
data-testid="spawn-window-toggle"
>
<span
class="inline-block h-3 w-3 sm:h-4 sm:w-4 lg:h-5 lg:w-5 transform rounded-full bg-bg-elevated transition-transform ${
this.spawnWindow
? 'translate-x-4 sm:translate-x-5'
: 'translate-x-0.5'
}"
></span>
</button>
</div>
`
: ''
}
<!-- Terminal Title Mode -->
<div class="flex items-center justify-between bg-bg-elevated border border-border/50 rounded-lg p-2 sm:p-3 lg:p-4">
<div class="flex-1 pr-2 sm:pr-3 lg:pr-4">
<span class="text-primary text-[10px] sm:text-xs lg:text-sm font-medium">Terminal Title Mode</span>
<p class="text-[9px] sm:text-[10px] lg:text-xs text-text-muted mt-0.5 hidden sm:block">
${getTitleModeDescription(this.titleMode)}
</p>
</div>
<div class="relative">
<select
.value=${this.titleMode}
@change=${this.handleTitleModeChange}
class="bg-bg-tertiary border border-border/50 rounded-lg px-1.5 py-1 pr-6 sm:px-2 sm:py-1.5 sm:pr-7 lg:px-3 lg:py-2 lg:pr-8 text-text text-[10px] sm:text-xs lg:text-sm transition-all duration-200 hover:border-primary/50 focus:border-primary focus:outline-none appearance-none cursor-pointer"
style="min-width: 80px"
?disabled=${this.disabled || this.isCreating}
>
<option value="${TitleMode.NONE}" class="bg-bg-tertiary text-text" ?selected=${this.titleMode === TitleMode.NONE}>None</option>
<option value="${TitleMode.FILTER}" class="bg-bg-tertiary text-text" ?selected=${this.titleMode === TitleMode.FILTER}>Filter</option>
<option value="${TitleMode.STATIC}" class="bg-bg-tertiary text-text" ?selected=${this.titleMode === TitleMode.STATIC}>Static</option>
<option value="${TitleMode.DYNAMIC}" class="bg-bg-tertiary text-text" ?selected=${this.titleMode === TitleMode.DYNAMIC}>Dynamic</option>
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-1 sm:px-1.5 lg:px-2 text-text-muted">
<svg class="h-2.5 w-2.5 sm:h-3 sm:w-3 lg:h-4 lg:w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</div>
</div>
`
: ''
}
</div>
<div class="flex gap-1.5 sm:gap-2 lg:gap-3 mt-2 sm:mt-3 lg:mt-4 xl:mt-6">
<button
id="session-cancel-button"
class="flex-1 bg-bg-elevated border border-border/50 text-text px-2 py-1 sm:px-3 sm:py-1.5 lg:px-4 lg:py-2 xl:px-6 xl:py-3 rounded-lg font-mono text-[10px] sm:text-xs lg:text-sm transition-all duration-200 hover:bg-hover hover:border-border"
@click=${this.handleCancel}
?disabled=${this.isCreating}
@ -821,6 +956,7 @@ export class SessionCreateForm extends LitElement {
Cancel
</button>
<button
id="session-create-button"
class="flex-1 bg-primary text-text-bright px-2 py-1 sm:px-3 sm:py-1.5 lg:px-4 lg:py-2 xl:px-6 xl:py-3 rounded-lg font-mono text-[10px] sm:text-xs lg:text-sm font-medium transition-all duration-200 hover:bg-primary-hover hover:shadow-glow disabled:opacity-50 disabled:cursor-not-allowed"
@click=${this.handleCreate}
?disabled=${

View file

@ -316,18 +316,19 @@ describe('SessionList', () => {
];
await element.updateComplete;
// Find toggle button
const toggleButton =
element.querySelector('[title*="Hide exited"]') ||
element.querySelector('[title*="Show exited"]');
// Find toggle button - when hideExited is true (default), button shows "Show Exited"
const toggleButton = element.querySelector('#show-exited-button');
expect(toggleButton).toBeTruthy();
if (toggleButton) {
(toggleButton as HTMLElement).click();
expect(element.hideExited).toBe(false);
// The component doesn't directly change hideExited - it emits an event
// The parent should handle the event and update the property
expect(element.hideExited).toBe(true); // Still true because parent hasn't updated it
expect(changeHandler).toHaveBeenCalledWith(
expect.objectContaining({
detail: false,
detail: false, // Requesting to change to false
})
);
}
@ -449,7 +450,7 @@ describe('SessionList', () => {
element.hideExited = false;
await element.updateComplete;
const cleanupButton = element.querySelector('[title*="Clean"]');
const cleanupButton = element.querySelector('#clean-exited-button');
expect(cleanupButton).toBeTruthy();
});
});

View file

@ -24,6 +24,7 @@ import type { AuthClient } from '../services/auth-client.js';
import './session-card.js';
import './inline-edit.js';
import { formatSessionDuration } from '../../shared/utils/time.js';
import { sessionActionService } from '../services/session-action-service.js';
import { sendAIPrompt } from '../utils/ai-sessions.js';
import { Z_INDEX } from '../utils/constants.js';
import { createLogger } from '../utils/logger.js';
@ -31,9 +32,6 @@ import { formatPathForDisplay } from '../utils/path-utils.js';
const logger = createLogger('session-list');
// Re-export Session type for backward compatibility
export type { Session };
@customElement('session-list')
export class SessionList extends LitElement {
// Disable shadow DOM to use Tailwind
@ -266,31 +264,23 @@ export class SessionList extends LitElement {
};
private async handleDeleteSession(sessionId: string) {
try {
const response = await fetch(`/api/sessions/${sessionId}`, {
method: 'DELETE',
headers: {
...this.authClient.getAuthHeader(),
await sessionActionService.deleteSessionById(sessionId, {
authClient: this.authClient,
callbacks: {
onError: (errorMessage) => {
this.handleSessionKillError({
detail: {
sessionId,
error: errorMessage,
},
} as CustomEvent);
},
});
if (!response.ok) {
const errorData = await response.text();
logger.error('Failed to delete session', { errorData, sessionId });
throw new Error(`Delete failed: ${response.status}`);
}
// Session killed successfully - update local state and trigger refresh
this.handleSessionKilled({ detail: { sessionId } } as CustomEvent);
} catch (error) {
logger.error('Error deleting session', { error, sessionId });
this.handleSessionKillError({
detail: {
sessionId,
error: error instanceof Error ? error.message : 'Unknown error',
onSuccess: () => {
// Session killed successfully - update local state and trigger refresh
this.handleSessionKilled({ detail: { sessionId } } as CustomEvent);
},
} as CustomEvent);
}
},
});
}
private async handleSendAIPrompt(sessionId: string) {
@ -366,19 +356,25 @@ export class SessionList extends LitElement {
}
render() {
// Group sessions by status
const runningSessions = this.sessions.filter((session) => session.status === 'running');
// Group sessions by status and activity
const activeSessions = this.sessions.filter(
(session) => session.status === 'running' && session.activityStatus?.isActive !== false
);
const idleSessions = this.sessions.filter(
(session) => session.status === 'running' && session.activityStatus?.isActive === false
);
const exitedSessions = this.sessions.filter((session) => session.status === 'exited');
const hasRunningSessions = runningSessions.length > 0;
const hasActiveSessions = activeSessions.length > 0;
const hasIdleSessions = idleSessions.length > 0;
const hasExitedSessions = exitedSessions.length > 0;
const showExitedSection = !this.hideExited && hasExitedSessions;
const showExitedSection = !this.hideExited && (hasIdleSessions || hasExitedSessions);
return html`
<div class="font-mono text-sm focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 focus:ring-offset-bg-primary rounded-lg" data-testid="session-list-container">
<div class="p-4 pt-5">
${
!hasRunningSessions && (!hasExitedSessions || this.hideExited)
!hasActiveSessions && !hasIdleSessions && (!hasExitedSessions || this.hideExited)
? html`
<div class="text-text-muted text-center py-8">
${
@ -454,15 +450,15 @@ export class SessionList extends LitElement {
: html`
<!-- Active Sessions -->
${
hasRunningSessions
hasActiveSessions
? html`
<div class="mb-6">
<h3 class="text-xs font-semibold text-text-muted uppercase tracking-wider mb-4">
Active <span class="text-text-dim">(${runningSessions.length})</span>
Active <span class="text-text-dim">(${activeSessions.length})</span>
</h3>
<div class="${this.compactMode ? 'space-y-2' : 'session-flex-responsive'} relative">
${repeat(
runningSessions,
activeSessions,
(session) => session.id,
(session) => html`
${
@ -657,13 +653,146 @@ export class SessionList extends LitElement {
: ''
}
<!-- Idle/Exited Sessions -->
<!-- Idle Sessions -->
${
showExitedSection
hasIdleSessions
? html`
<div class="mb-6">
<h3 class="text-xs font-semibold text-text-muted uppercase tracking-wider mb-4">
Idle <span class="text-text-dim">(${idleSessions.length})</span>
</h3>
<div class="${this.compactMode ? 'space-y-2' : 'session-flex-responsive'} relative">
${repeat(
idleSessions,
(session) => session.id,
(session) => html`
${
this.compactMode
? html`
<!-- Enhanced compact list item for sidebar -->
<div
class="group flex items-center gap-3 p-3 rounded-lg cursor-pointer ${
session.id === this.selectedSessionId
? 'bg-bg-elevated border border-accent-primary shadow-card-hover'
: 'bg-bg-secondary border border-border hover:bg-bg-tertiary hover:border-border-light hover:shadow-card'
}"
@click=${() =>
this.handleSessionSelect({ detail: session } as CustomEvent)}
>
<!-- Status indicator for idle sessions -->
<div class="relative flex-shrink-0">
<div class="w-2.5 h-2.5 rounded-full bg-status-success ring-1 ring-status-success ring-opacity-50"
title="Idle"></div>
</div>
<!-- Elegant divider line -->
<div class="w-px h-8 bg-gradient-to-b from-transparent via-border to-transparent"></div>
<!-- Session content -->
<div class="flex-1 min-w-0">
<div
class="text-sm font-mono truncate ${
session.id === this.selectedSessionId
? 'text-accent-primary font-medium'
: 'text-text group-hover:text-accent-primary transition-colors'
}"
title="${
session.name ||
(Array.isArray(session.command)
? session.command.join(' ')
: session.command)
}"
>
${
session.name ||
(Array.isArray(session.command)
? session.command.join(' ')
: session.command)
}
</div>
<div class="text-xs text-text-dim truncate">
${formatPathForDisplay(session.workingDir)}
</div>
</div>
<!-- Right side: duration and close button -->
<div class="relative flex items-center flex-shrink-0 gap-1">
<!-- Session duration -->
<div class="text-xs text-text-dim font-mono">
${session.startedAt ? formatSessionDuration(session.startedAt, session.status === 'exited' ? session.lastModified : undefined) : ''}
</div>
<!-- Clean up button -->
<button
class="btn-ghost text-text-muted p-1.5 rounded-md transition-all flex-shrink-0 hover:text-status-warning hover:bg-bg-elevated hover:shadow-sm"
@click=${async (e: Event) => {
e.stopPropagation();
try {
const response = await fetch(
`/api/sessions/${session.id}/cleanup`,
{
method: 'DELETE',
headers: this.authClient.getAuthHeader(),
}
);
if (response.ok) {
this.handleSessionKilled({
detail: { sessionId: session.id },
} as CustomEvent);
}
} catch (error) {
logger.error('Failed to clean up session', error);
}
}}
title="Clean up session"
>
<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="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
`
: html`
<!-- Full session card for main view -->
<session-card
.session=${session}
.authClient=${this.authClient}
.selected=${session.id === this.selectedSessionId}
@session-select=${this.handleSessionSelect}
@session-killed=${this.handleSessionKilled}
@session-kill-error=${this.handleSessionKillError}
@session-renamed=${this.handleSessionRenamed}
@session-rename-error=${this.handleSessionRenameError}
>
</session-card>
`
}
`
)}
</div>
</div>
`
: ''
}
<!-- Exited Sessions -->
${
showExitedSection && hasExitedSessions
? html`
<div>
<h3 class="text-xs font-semibold text-text-muted uppercase tracking-wider mb-4">
Idle <span class="text-text-dim">(${exitedSessions.length})</span>
Exited <span class="text-text-dim">(${exitedSessions.length})</span>
</h3>
<div class="${this.compactMode ? 'space-y-2' : 'session-flex-responsive'} relative">
${repeat(
@ -817,6 +946,7 @@ export class SessionList extends LitElement {
? 'border-border bg-bg-elevated text-text-muted hover:bg-surface-hover hover:text-accent-primary hover:border-accent-primary hover:shadow-sm active:scale-95'
: 'border-accent-primary bg-accent-primary bg-opacity-10 text-accent-primary hover:bg-opacity-20 hover:shadow-glow-primary-sm active:scale-95'
}"
id="${this.hideExited ? 'show-exited-button' : 'hide-exited-button'}"
@click=${() =>
this.dispatchEvent(
new CustomEvent('hide-exited-change', { detail: !this.hideExited })
@ -833,6 +963,7 @@ export class SessionList extends LitElement {
? html`
<button
class="font-mono text-xs px-4 py-2 rounded-lg border transition-all duration-200 border-status-warning bg-status-warning bg-opacity-10 text-status-warning hover:bg-opacity-20 hover:shadow-glow-warning-sm active:scale-95 disabled:opacity-50"
id="clean-exited-button"
@click=${this.handleCleanupExited}
?disabled=${this.cleaningExited}
data-testid="clean-exited-button"
@ -852,6 +983,7 @@ export class SessionList extends LitElement {
? html`
<button
class="font-mono text-xs px-4 py-2 rounded-lg border transition-all duration-200 border-status-error bg-status-error bg-opacity-10 text-status-error hover:bg-opacity-20 hover:shadow-glow-error-sm active:scale-95"
id="kill-all-button"
@click=${() => this.dispatchEvent(new CustomEvent('kill-all-sessions'))}
data-testid="kill-all-button"
>

View file

@ -4,7 +4,7 @@ import { fixture, waitUntil } from '@open-wc/testing';
import { html } from 'lit';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import './session-view.js';
import type { Session } from './session-list.js';
import type { Session } from '../../shared/types.js';
import type { SessionView } from './session-view.js';
describe('SessionView Binary Mode', () => {

File diff suppressed because it is too large Load diff

View file

@ -15,7 +15,7 @@
*/
import { html, LitElement, type PropertyValues } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import type { Session } from './session-list.js';
import type { Session } from '../../shared/types.js';
import './terminal.js';
import './vibe-terminal-binary.js';
import './file-browser.js';
@ -29,6 +29,7 @@ import './session-view/ctrl-alpha-overlay.js';
import './session-view/width-selector.js';
import './session-view/session-header.js';
import { authClient } from '../services/auth-client.js';
import { sessionActionService } from '../services/session-action-service.js';
import { createLogger } from '../utils/logger.js';
import {
COMMON_TERMINAL_WIDTHS,
@ -401,6 +402,8 @@ export class SessionView extends LitElement {
this.lifecycleEventManager.setCallbacks(this.createLifecycleEventManagerCallbacks());
this.lifecycleEventManager.setSession(this.session);
// Session action callbacks will be provided per-call to the service
// Load direct keyboard preference (needed before lifecycle setup)
try {
const stored = localStorage.getItem('vibetunnel_app_preferences');
@ -1433,6 +1436,8 @@ export class SessionView extends LitElement {
.onFontSizeChange=${(size: number) => this.handleFontSizeChange(size)}
.onOpenSettings=${() => this.handleOpenSettings()}
.macAppConnected=${this.macAppConnected}
.onTerminateSession=${() => this.handleTerminateSession()}
.onClearSession=${() => this.handleClearSession()}
@close-width-selector=${() => {
this.showWidthSelector = false;
this.customWidth = '';
@ -1751,4 +1756,47 @@ export class SessionView extends LitElement {
</div>
`;
}
private async handleTerminateSession() {
if (!this.session) return;
await sessionActionService.terminateSession(this.session, {
authClient: authClient,
callbacks: {
onError: (message: string) => {
this.dispatchEvent(
new CustomEvent('error', {
detail: message,
bubbles: true,
composed: true,
})
);
},
onSuccess: () => {
// For terminate, session status will be updated via SSE
},
},
});
}
private async handleClearSession() {
if (!this.session) return;
await sessionActionService.clearSession(this.session, {
authClient: authClient,
callbacks: {
onError: (message: string) => {
this.dispatchEvent(
new CustomEvent('error', {
detail: message,
bubbles: true,
composed: true,
})
);
},
onSuccess: () => {
// Session cleared successfully - navigate back to list
this.handleBack();
},
},
});
}
}

View file

@ -5,10 +5,10 @@
* for terminal sessions.
*/
import type { Session } from '../../../shared/types.js';
import { authClient } from '../../services/auth-client.js';
import { CastConverter } from '../../utils/cast-converter.js';
import { createLogger } from '../../utils/logger.js';
import type { Session } from '../session-list.js';
import type { Terminal } from '../terminal.js';
const logger = createLogger('connection-manager');

View file

@ -245,11 +245,7 @@ export class ImageUploadMenu extends LitElement {
<div class="relative">
<vt-tooltip content="Upload Image (⌘U)" .show=${!this.isMobile}>
<button
class="${
this.showMenu
? 'bg-surface-hover border-primary text-primary shadow-sm'
: 'bg-bg-tertiary border-border text-muted hover:text-primary hover:bg-surface-hover hover:border-primary hover:shadow-sm'
} rounded-lg p-2 font-mono transition-all duration-200 flex-shrink-0"
class="bg-bg-tertiary border border-border rounded-lg p-2 font-mono text-muted transition-all duration-200 hover:text-primary hover:bg-surface-hover hover:border-primary hover:shadow-sm flex-shrink-0"
@click=${this.toggleMenu}
@keydown=${this.handleMenuButtonKeyDown}
title="Upload Image"

View file

@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Session } from '../session-list.js';
import type { Session } from '../../../shared/types.js';
import { InputManager } from './input-manager.js';
// Mock fetch globally

View file

@ -5,12 +5,12 @@
* for terminal sessions.
*/
import type { Session } from '../../../shared/types.js';
import { authClient } from '../../services/auth-client.js';
import { websocketInputClient } from '../../services/websocket-input-client.js';
import { isBrowserShortcut, isCopyPasteShortcut } from '../../utils/browser-shortcuts.js';
import { consumeEvent } from '../../utils/event-utils.js';
import { createLogger } from '../../utils/logger.js';
import type { Session } from '../session-list.js';
const logger = createLogger('input-manager');

View file

@ -5,9 +5,9 @@
* overall event coordination for the session view component.
*/
import type { Session } from '../../../shared/types.js';
import { consumeEvent } from '../../utils/event-utils.js';
import { createLogger } from '../../utils/logger.js';
import type { Session } from '../session-list.js';
import { type LifecycleEventManagerCallbacks, ManagerEventEmitter } from './interfaces.js';
// Extend Window interface to include our custom property

View file

@ -5,7 +5,7 @@
* to reduce coupling and improve testability
*/
import type { Session } from '../session-list.js';
import type { Session } from '../../../shared/types.js';
import type { SessionView } from '../session-view.js';
import { ConnectionManager } from './connection-manager.js';
import { DirectKeyboardManager } from './direct-keyboard-manager.js';

View file

@ -6,8 +6,8 @@
*/
import { html, LitElement, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import type { Session } from '../../../shared/types.js';
import { Z_INDEX } from '../../utils/constants.js';
import type { Session } from '../session-list.js';
import type { Theme } from '../theme-toggle-icon.js';
@customElement('mobile-menu')

View file

@ -6,7 +6,7 @@
*/
import { html, LitElement } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import type { Session } from '../session-list.js';
import type { Session } from '../../../shared/types.js';
import '../clickable-path.js';
import './width-selector.js';
import '../inline-edit.js';
@ -18,6 +18,7 @@ import { createLogger } from '../../utils/logger.js';
import './mobile-menu.js';
import '../theme-toggle-icon.js';
import './image-upload-menu.js';
import './session-status-dropdown.js';
const logger = createLogger('session-header');
@ -51,6 +52,8 @@ export class SessionHeader extends LitElement {
@property({ type: Boolean }) keyboardCaptureActive = true;
@property({ type: Boolean }) isMobile = false;
@property({ type: Boolean }) macAppConnected = false;
@property({ type: Function }) onTerminateSession?: () => void;
@property({ type: Function }) onClearSession?: () => void;
@state() private isHovered = false;
connectedCallback() {
@ -159,7 +162,7 @@ export class SessionHeader extends LitElement {
}
<div class="text-primary min-w-0 flex-1 overflow-hidden">
<div class="text-bright font-medium text-xs sm:text-sm min-w-0 overflow-hidden">
<div class="grid grid-cols-[1fr_auto] items-center gap-2 min-w-0" @mouseenter=${this.handleMouseEnter} @mouseleave=${this.handleMouseLeave}>
<div class="flex items-center gap-1 min-w-0" @mouseenter=${this.handleMouseEnter} @mouseleave=${this.handleMouseLeave}>
<inline-edit
class="min-w-0"
.value=${
@ -179,16 +182,26 @@ export class SessionHeader extends LitElement {
isAIAssistantSession(this.session)
? html`
<button
class="bg-transparent border-0 p-0 cursor-pointer transition-opacity duration-200 text-primary magic-button flex-shrink-0 ${this.isHovered ? 'opacity-50 hover:opacity-100' : 'opacity-0'}"
class="bg-transparent border-0 p-0 cursor-pointer transition-opacity duration-200 text-primary magic-button flex-shrink-0 ${this.isHovered ? 'opacity-50 hover:opacity-100' : 'opacity-0'} ml-1"
@click=${(e: Event) => {
e.stopPropagation();
this.handleMagicButton();
}}
title="Send prompt to update terminal title"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M14.9 0.3a1 1 0 01-.2 1.4l-4 3a1 1 0 01-1.4-.2l-.3-.4a1 1 0 01.2-1.4l4-3a1 1 0 011.4.2l.3.4zM11.5 2.5l-1.5 1-1 1.5L3.5 10.5l-.3.3a2 2 0 00-.5.8l-.7 2.4a.5.5 0 00.6.6l2.4-.7a2 2 0 00.8-.5l.3-.3L11.5 7.5l1.5-1 1-1.5-2.5-2.5zM3 13l-.7.2.2-.7a1 1 0 01.2-.4l.3-.1v.5a.5.5 0 00.5.5h.5l-.1.3a1 1 0 01-.4.2L3 13z"/>
<path d="M9 1a1 1 0 100 2 1 1 0 000-2zM5 0a1 1 0 100 2 1 1 0 000-2zM2 3a1 1 0 100 2 1 1 0 000-2zM14 6a1 1 0 100 2 1 1 0 000-2zM15 10a1 1 0 100 2 1 1 0 000-2zM12 13a1 1 0 100 2 1 1 0 000-2z" opacity="0.5"/>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<!-- Wand -->
<path d="M9.5 21.5L21.5 9.5a1 1 0 000-1.414l-1.086-1.086a1 1 0 00-1.414 0L7 19l2.5 2.5z" opacity="0.9"/>
<path d="M6 18l-1.5 3.5a.5.5 0 00.7.7L8.5 21l-2.5-3z" opacity="0.9"/>
<!-- Sparkles/Rays -->
<circle cx="8" cy="4" r="1"/>
<circle cx="4" cy="8" r="1"/>
<circle cx="16" cy="4" r="1"/>
<circle cx="20" cy="8" r="1"/>
<circle cx="12" cy="2" r=".5"/>
<circle cx="2" cy="12" r=".5"/>
<circle cx="22" cy="12" r=".5"/>
<circle cx="18" cy="2" r=".5"/>
</svg>
</button>
<style>
@ -216,11 +229,13 @@ export class SessionHeader extends LitElement {
</div>
</div>
<div class="flex items-center gap-2 text-xs flex-shrink-0 ml-2">
<!-- Notification status - desktop only -->
<!-- Status dropdown - desktop only -->
<div class="hidden sm:block">
<notification-status
@open-settings=${() => this.onOpenSettings?.()}
></notification-status>
<session-status-dropdown
.session=${this.session}
.onTerminate=${this.onTerminateSession}
.onClear=${this.onClearSession}
></session-status-dropdown>
</div>
<!-- Keyboard capture indicator -->
@ -240,14 +255,6 @@ export class SessionHeader extends LitElement {
<!-- Desktop buttons - hidden on mobile -->
<div class="hidden sm:flex items-center gap-2">
<!-- Theme toggle -->
<theme-toggle-icon
.theme=${this.currentTheme}
@theme-changed=${(e: CustomEvent) => {
this.currentTheme = e.detail.theme;
}}
></theme-toggle-icon>
<!-- Image Upload Menu -->
<image-upload-menu
.onPasteImage=${() => this.handlePasteImage()}
@ -257,6 +264,20 @@ export class SessionHeader extends LitElement {
.isMobile=${this.isMobile}
></image-upload-menu>
<!-- Theme toggle -->
<theme-toggle-icon
.theme=${this.currentTheme}
@theme-changed=${(e: CustomEvent) => {
this.currentTheme = e.detail.theme;
}}
></theme-toggle-icon>
<!-- Settings button -->
<notification-status
@open-settings=${() => this.onOpenSettings?.()}
></notification-status>
<!-- Terminal size button -->
<button
class="bg-bg-tertiary border border-border rounded-lg px-3 py-2 font-mono text-xs text-muted transition-all duration-200 hover:text-primary hover:bg-surface-hover hover:border-primary hover:shadow-sm flex-shrink-0 width-selector-button"
@click=${() => this.onMaxWidthToggle?.()}
@ -281,23 +302,6 @@ export class SessionHeader extends LitElement {
.macAppConnected=${this.macAppConnected}
></mobile-menu>
</div>
<!-- Status indicator - desktop only (mobile shows it on the left) -->
<div class="hidden sm:flex flex-col items-end gap-0">
<span class="text-xs flex items-center gap-2 font-medium ${
this.getStatusText() === 'running' ? 'text-status-success' : 'text-status-warning'
}">
<div class="relative">
<div class="w-2.5 h-2.5 rounded-full ${this.getStatusDotColor()}"></div>
${
this.getStatusText() === 'running'
? html`<div class="absolute inset-0 w-2.5 h-2.5 rounded-full bg-status-success animate-ping opacity-50"></div>`
: ''
}
</div>
${this.getStatusText().toUpperCase()}
</span>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,256 @@
/**
* Session Status Dropdown Component
*
* Displays session status with a dropdown menu for actions.
* Shows "Terminate Session" for running sessions and "Clear Session" for exited sessions.
*/
import { html, LitElement, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import type { Session } from '../../../shared/types.js';
import { Z_INDEX } from '../../utils/constants.js';
@customElement('session-status-dropdown')
export class SessionStatusDropdown extends LitElement {
// Disable shadow DOM to use Tailwind
createRenderRoot() {
return this;
}
@property({ type: Object }) session: Session | null = null;
@property({ type: Function }) onTerminate?: () => void;
@property({ type: Function }) onClear?: () => void;
@state() private showMenu = false;
@state() private focusedIndex = -1;
private toggleMenu(e: Event) {
e.stopPropagation();
this.showMenu = !this.showMenu;
if (!this.showMenu) {
this.focusedIndex = -1;
}
}
private handleAction(callback?: () => void) {
if (callback) {
// Close menu immediately
this.showMenu = false;
this.focusedIndex = -1;
// Call the callback after a brief delay to ensure menu is closed
setTimeout(() => {
callback();
}, 50);
}
}
connectedCallback() {
super.connectedCallback();
// Close menu when clicking outside
document.addEventListener('click', this.handleOutsideClick);
// Add keyboard support
document.addEventListener('keydown', this.handleKeyDown);
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('click', this.handleOutsideClick);
document.removeEventListener('keydown', this.handleKeyDown);
}
private handleOutsideClick = (e: MouseEvent) => {
const path = e.composedPath();
if (!path.includes(this)) {
this.showMenu = false;
this.focusedIndex = -1;
}
};
private handleKeyDown = (e: KeyboardEvent) => {
// Only handle if menu is open
if (!this.showMenu) return;
if (e.key === 'Escape') {
e.preventDefault();
this.showMenu = false;
this.focusedIndex = -1;
// Focus the menu button
const button = this.querySelector(
'button[aria-label="Session actions menu"]'
) as HTMLButtonElement;
button?.focus();
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
this.navigateMenu(e.key === 'ArrowDown' ? 1 : -1);
} else if (e.key === 'Enter' && this.focusedIndex >= 0) {
e.preventDefault();
this.selectFocusedItem();
}
};
private navigateMenu(direction: number) {
const menuItems = this.getMenuItems();
if (menuItems.length === 0) return;
// Calculate new index
let newIndex = this.focusedIndex + direction;
// Handle wrapping
if (newIndex < 0) {
newIndex = menuItems.length - 1;
} else if (newIndex >= menuItems.length) {
newIndex = 0;
}
this.focusedIndex = newIndex;
// Focus the element
const focusedItem = menuItems[newIndex];
if (focusedItem) {
focusedItem.focus();
}
}
private getMenuItems(): HTMLButtonElement[] {
if (!this.showMenu) return [];
// Find all menu buttons
const buttons = Array.from(this.querySelectorAll('button[data-action]')) as HTMLButtonElement[];
return buttons.filter((btn) => btn.tagName === 'BUTTON');
}
private selectFocusedItem() {
const menuItems = this.getMenuItems();
const focusedItem = menuItems[this.focusedIndex];
if (focusedItem) {
focusedItem.click();
}
}
private getStatusText(): string {
if (!this.session) return '';
if ('active' in this.session && this.session.active === false) {
return 'waiting';
}
return this.session.status;
}
private getStatusColor(): string {
if (!this.session) return 'text-muted';
if ('active' in this.session && this.session.active === false) {
return 'text-muted';
}
return this.session.status === 'running' ? 'text-status-success' : 'text-status-warning';
}
private getStatusDotColor(): string {
if (!this.session) return 'bg-muted';
if ('active' in this.session && this.session.active === false) {
return 'bg-muted';
}
return this.session.status === 'running' ? 'bg-status-success' : 'bg-status-warning';
}
private handleMenuButtonKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown' && this.showMenu) {
e.preventDefault();
// Focus first menu item when pressing down on the menu button
this.focusedIndex = 0;
const menuItems = this.getMenuItems();
if (menuItems[0]) {
menuItems[0].focus();
}
}
};
render() {
if (!this.session) return null;
const isRunning = this.session.status === 'running';
const statusText = this.getStatusText();
return html`
<div class="relative">
<button
class="flex items-center gap-2 bg-bg-tertiary border border-border rounded-lg px-3 py-2 transition-all duration-200 hover:bg-surface-hover hover:border-primary hover:shadow-sm ${
this.showMenu ? 'border-primary shadow-sm' : ''
}"
@click=${this.toggleMenu}
@keydown=${this.handleMenuButtonKeyDown}
title="${isRunning ? 'Running - Click for actions' : 'Exited - Click for actions'}"
aria-label="Session actions menu"
aria-expanded=${this.showMenu}
>
<span class="text-xs flex items-center gap-2 font-medium ${this.getStatusColor()}">
<div class="relative">
<div class="w-2 h-2 rounded-full ${this.getStatusDotColor()}"></div>
${
statusText === 'running'
? html`<div class="absolute inset-0 w-2 h-2 rounded-full bg-status-success animate-ping opacity-50"></div>`
: ''
}
</div>
${statusText.toUpperCase()}
</span>
<!-- Dropdown arrow -->
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
class="transition-transform text-muted ${this.showMenu ? 'rotate-180' : ''}"
>
<path d="M5 7L1 3h8z" />
</svg>
</button>
${this.showMenu ? this.renderDropdown(isRunning) : nothing}
</div>
`;
}
private renderDropdown(isRunning: boolean) {
let menuItemIndex = 0;
return html`
<div
class="absolute right-0 top-full mt-2 bg-surface border border-border rounded-lg shadow-xl py-1 min-w-[250px]"
style="z-index: ${Z_INDEX.WIDTH_SELECTOR_DROPDOWN};"
>
${
isRunning
? html`
<button
class="w-full text-left px-6 py-3 text-sm font-mono text-status-error hover:bg-bg-secondary flex items-center gap-3 ${
this.focusedIndex === menuItemIndex++ ? 'bg-bg-secondary' : ''
}"
@click=${() => this.handleAction(this.onTerminate)}
data-action="terminate"
tabindex="${this.showMenu ? '0' : '-1'}"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zM4.5 7.5a.5.5 0 0 0 0 1h7a.5.5 0 0 0 0-1h-7z"/>
</svg>
Terminate Session
</button>
`
: html`
<button
class="w-full text-left px-6 py-3 text-sm font-mono text-muted hover:bg-bg-secondary hover:text-primary flex items-center gap-3 ${
this.focusedIndex === menuItemIndex++ ? 'bg-bg-secondary text-primary' : ''
}"
@click=${() => this.handleAction(this.onClear)}
data-action="clear"
tabindex="${this.showMenu ? '0' : '-1'}"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
Clear Session
</button>
`
}
</div>
`;
}
}

View file

@ -5,10 +5,10 @@
* for session view components.
*/
import type { Session } from '../../../shared/types.js';
import { authClient } from '../../services/auth-client.js';
import { createLogger } from '../../utils/logger.js';
import type { TerminalThemeId } from '../../utils/terminal-themes.js';
import type { Session } from '../session-list.js';
import type { Terminal } from '../terminal.js';
import type { ConnectionManager } from './connection-manager.js';
import type { InputManager } from './input-manager.js';

View file

@ -28,19 +28,6 @@ export class TerminalSettingsModal extends LitElement {
connectedCallback() {
super.connectedCallback();
// Clean up old conflicting localStorage key if it exists
if (localStorage.getItem('terminal-theme')) {
const oldTheme = localStorage.getItem('terminal-theme') as TerminalThemeId;
// Migrate to TerminalPreferencesManager if it's a valid theme
if (
oldTheme &&
['auto', 'light', 'dark', 'vscode-dark', 'dracula', 'nord'].includes(oldTheme)
) {
this.preferencesManager.setTheme(oldTheme);
}
localStorage.removeItem('terminal-theme');
}
// Load theme from TerminalPreferencesManager
this.terminalTheme = this.preferencesManager.getTheme();
@ -207,7 +194,10 @@ export class TerminalSettingsModal extends LitElement {
class="w-full bg-bg-secondary border border-border rounded-md pl-4 pr-10 py-3 text-sm font-mono text-text focus:border-primary focus:shadow-glow-sm cursor-pointer appearance-none"
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 20 20%22 fill=%22${this.getArrowColor()}%22%3e%3cpath fill-rule=%22evenodd%22 d=%22M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z%22 clip-rule=%22evenodd%22/%3e%3c/svg%3e'); background-position: right 0.75rem center; background-repeat: no-repeat; background-size: 1.25em 1.25em;"
.value=${isCustomValue || this.showCustomInput ? 'custom' : String(this.terminalMaxCols)}
@click=${(e: Event) => e.stopPropagation()}
@mousedown=${(e: Event) => e.stopPropagation()}
@change=${(e: Event) => {
e.stopPropagation();
const value = (e.target as HTMLSelectElement).value;
if (value === 'custom') {
this.showCustomInput = true;
@ -322,6 +312,8 @@ export class TerminalSettingsModal extends LitElement {
id="theme-select"
class="w-full bg-bg-secondary border border-border rounded-md pl-4 pr-10 py-3 text-sm font-mono text-text focus:border-primary focus:shadow-glow-sm cursor-pointer appearance-none"
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 20 20%22 fill=%22${this.getArrowColor()}%22%3e%3cpath fill-rule=%22evenodd%22 d=%22M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z%22 clip-rule=%22evenodd%22/%3e%3c/svg%3e'); background-position: right 0.75rem center; background-repeat: no-repeat; background-size: 1.25em 1.25em;"
@click=${(e: Event) => e.stopPropagation()}
@mousedown=${(e: Event) => e.stopPropagation()}
@change=${(e: Event) => {
e.stopPropagation();
const value = (e.target as HTMLSelectElement).value as TerminalThemeId;
@ -334,16 +326,15 @@ export class TerminalSettingsModal extends LitElement {
);
this.onThemeChange?.(value);
}}
@click=${(e: Event) => e.stopPropagation()}
>
${TERMINAL_THEMES.map((t) => html`<option value=${t.id}>${t.name}</option>`)}
</select>
</div>
<!-- Binary Mode setting -->
<div class="grid grid-cols-[120px_1fr] gap-4 items-start">
<label class="text-sm font-medium text-text-bright text-right pt-3">Binary Mode</label>
<div>
<div>
<div class="grid grid-cols-[120px_1fr] gap-4 items-center">
<label class="text-sm font-medium text-text-bright text-right">Binary Mode</label>
<div class="flex items-center justify-between bg-bg-secondary border border-border rounded-md px-4 py-3">
<button
role="switch"
@ -363,8 +354,8 @@ export class TerminalSettingsModal extends LitElement {
></span>
</button>
</div>
<p class="text-xs text-text-muted mt-2">Experimental: More efficient for high-throughput sessions</p>
</div>
<p class="text-xs text-text-muted mt-2">Experimental: More efficient for high-throughput sessions</p>
</div>
</div>
</div>

View file

@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { fixture, html } from '@open-wc/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AppPreferences } from './unified-settings';
import './unified-settings';
import type { UnifiedSettings } from './unified-settings';
@ -64,56 +64,9 @@ vi.mock('@/client/utils/logger', () => ({
// Mock fetch for API calls
global.fetch = vi.fn();
// Mock WebSocket
class MockWebSocket {
url: string;
readyState = 1; // OPEN
onopen?: (event: Event) => void;
onmessage?: (event: MessageEvent) => void;
onerror?: (event: Event) => void;
onclose?: (event: CloseEvent) => void;
send: ReturnType<typeof vi.fn>;
static instances: MockWebSocket[] = [];
static CLOSED = 3;
static OPEN = 1;
constructor(url: string) {
this.url = url;
this.send = vi.fn();
MockWebSocket.instances.push(this);
// Simulate open event
setTimeout(() => {
if (this.onopen) {
this.onopen(new Event('open'));
}
}, 0);
}
close() {
if (this.onclose) {
this.onclose(new CloseEvent('close'));
}
}
// Helper to simulate receiving a message
simulateMessage(data: unknown) {
if (this.onmessage) {
this.onmessage(new MessageEvent('message', { data: JSON.stringify(data) }));
}
}
static reset() {
MockWebSocket.instances = [];
}
}
// Replace global WebSocket
(global as unknown as { WebSocket: typeof MockWebSocket }).WebSocket = MockWebSocket;
describe('UnifiedSettings - Repository Path Bidirectional Sync', () => {
describe('UnifiedSettings - Repository Path Configuration', () => {
beforeEach(() => {
vi.clearAllMocks();
MockWebSocket.reset();
localStorage.clear();
// Mock default fetch response
@ -126,214 +79,7 @@ describe('UnifiedSettings - Repository Path Bidirectional Sync', () => {
});
});
describe('Web to Mac sync', () => {
it('should send repository path updates through WebSocket when not server-configured', async () => {
const el = await fixture<UnifiedSettings>(html`<unified-settings></unified-settings>`);
// Make component visible
el.visible = true;
// Wait for WebSocket connection and component updates
await new Promise((resolve) => setTimeout(resolve, 100));
await el.updateComplete;
// Get the WebSocket instance
const ws = MockWebSocket.instances[0];
expect(ws).toBeTruthy();
// Wait for WebSocket to be ready
await new Promise((resolve) => setTimeout(resolve, 50));
// Find the repository path input
const input = el.querySelector('input[placeholder="~/"]') as HTMLInputElement;
expect(input).toBeTruthy();
// Simulate user changing the path
input.value = '/new/repository/path';
input.dispatchEvent(new Event('input', { bubbles: true }));
// Wait for debounce and processing
await new Promise((resolve) => setTimeout(resolve, 100));
// Verify WebSocket message was sent
expect(ws.send).toHaveBeenCalledWith(
JSON.stringify({
type: 'update-repository-path',
path: '/new/repository/path',
})
);
});
it('should NOT send updates when server-configured', async () => {
// Mock server response with serverConfigured = true
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => ({
repositoryBasePath: '/Users/test/Projects',
serverConfigured: true,
}),
});
const el = await fixture<UnifiedSettings>(html`<unified-settings></unified-settings>`);
// Make component visible
el.visible = true;
// Wait for WebSocket connection
await new Promise((resolve) => setTimeout(resolve, 100));
await el.updateComplete;
// Get the WebSocket instance
const ws = MockWebSocket.instances[0];
expect(ws).toBeTruthy();
// Try to change the path (should be blocked)
(
el as UnifiedSettings & { handleAppPreferenceChange: (key: string, value: string) => void }
).handleAppPreferenceChange('repositoryBasePath', '/different/path');
// Wait for any potential send
await new Promise((resolve) => setTimeout(resolve, 50));
// Verify NO WebSocket message was sent
expect(ws.send).not.toHaveBeenCalled();
});
it('should handle WebSocket not connected gracefully', async () => {
const el = await fixture<UnifiedSettings>(html`<unified-settings></unified-settings>`);
// Make component visible
el.visible = true;
// Wait for initialization
await new Promise((resolve) => setTimeout(resolve, 100));
await el.updateComplete;
// Get the WebSocket instance and simulate closed state
const ws = MockWebSocket.instances[0];
expect(ws).toBeTruthy();
ws.readyState = MockWebSocket.CLOSED;
// Find and change the input
const input = el.querySelector('input[placeholder="~/"]') as HTMLInputElement;
input.value = '/new/path';
input.dispatchEvent(new Event('input', { bubbles: true }));
// Wait for debounce
await new Promise((resolve) => setTimeout(resolve, 50));
// Verify no send was attempted on closed WebSocket
expect(ws.send).not.toHaveBeenCalled();
});
});
describe('Mac to Web sync', () => {
it('should update UI when receiving path update from Mac', async () => {
const el = await fixture<UnifiedSettings>(html`<unified-settings></unified-settings>`);
// Make component visible
el.visible = true;
// Wait for initialization
await new Promise((resolve) => setTimeout(resolve, 100));
await el.updateComplete;
// Get the WebSocket instance
const ws = MockWebSocket.instances[0];
expect(ws).toBeTruthy();
// Simulate Mac sending a config update with serverConfigured=true
ws.simulateMessage({
type: 'config',
data: {
repositoryBasePath: '/mac/updated/path',
serverConfigured: true,
},
});
// Wait for the update to process
await new Promise((resolve) => setTimeout(resolve, 50));
await el.updateComplete;
// Check that the input value updated
const input = el.querySelector('input[placeholder="~/"]') as HTMLInputElement;
expect(input?.value).toBe('/mac/updated/path');
expect(input?.disabled).toBe(true); // Now disabled since server-configured
});
it('should update sync status text when serverConfigured changes', async () => {
const el = await fixture<UnifiedSettings>(html`<unified-settings></unified-settings>`);
// Make component visible
el.visible = true;
// Wait for initialization
await new Promise((resolve) => setTimeout(resolve, 100));
await el.updateComplete;
// Initially not server-configured - look for the repository path description
const descriptions = Array.from(el.querySelectorAll('p.text-xs') || []);
const repoDescription = descriptions.find((p) =>
p.textContent?.includes('Default directory for new sessions and repository discovery')
);
expect(repoDescription).toBeTruthy();
// Get the WebSocket instance
const ws = MockWebSocket.instances[0];
// Simulate Mac enabling server configuration
ws.simulateMessage({
type: 'config',
data: {
repositoryBasePath: '/mac/controlled/path',
serverConfigured: true,
},
});
// Wait for update
await new Promise((resolve) => setTimeout(resolve, 50));
await el.updateComplete;
// Check updated text
const updatedDescriptions = Array.from(el.querySelectorAll('p.text-xs') || []);
const updatedRepoDescription = updatedDescriptions.find((p) =>
p.textContent?.includes('This path is synced with the VibeTunnel Mac app')
);
expect(updatedRepoDescription).toBeTruthy();
// Check lock icon appeared
const lockIconContainer = el.querySelector('[title="Synced with Mac app"]');
expect(lockIconContainer).toBeTruthy();
});
});
});
describe('UnifiedSettings - Repository Path Server Configuration', () => {
beforeEach(() => {
vi.clearAllMocks();
MockWebSocket.reset();
localStorage.clear();
// Mock default fetch response
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => ({
repositoryBasePath: '~/',
serverConfigured: false,
}),
});
});
afterEach(() => {
// Clean up any remaining WebSocket instances
MockWebSocket.instances.forEach((ws) => {
if (ws.onclose) {
ws.close();
}
});
});
it('should show repository path as editable when not server-configured', async () => {
it('should show repository path as always editable', async () => {
const el = await fixture<UnifiedSettings>(html`<unified-settings></unified-settings>`);
// Make component visible
@ -353,16 +99,7 @@ describe('UnifiedSettings - Repository Path Server Configuration', () => {
expect(input?.classList.contains('cursor-not-allowed')).toBe(false);
});
it('should show repository path as read-only when server-configured', async () => {
// Mock server response with serverConfigured = true
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => ({
repositoryBasePath: '/Users/test/Projects',
serverConfigured: true,
}),
});
it('should save repository path changes to localStorage', async () => {
const el = await fixture<UnifiedSettings>(html`<unified-settings></unified-settings>`);
// Make component visible
@ -372,160 +109,125 @@ describe('UnifiedSettings - Repository Path Server Configuration', () => {
await new Promise((resolve) => setTimeout(resolve, 100));
await el.updateComplete;
// Find the repository base path input
const input = el.querySelector('input[placeholder="~/"]') as HTMLInputElement | null;
expect(input).toBeTruthy();
expect(input?.disabled).toBe(true);
expect(input?.readOnly).toBe(true);
expect(input?.classList.contains('opacity-60')).toBe(true);
expect(input?.classList.contains('cursor-not-allowed')).toBe(true);
expect(input?.value).toBe('/Users/test/Projects');
});
it('should display lock icon and message when server-configured', async () => {
// Mock server response with serverConfigured = true
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => ({
repositoryBasePath: '/Users/test/Projects',
serverConfigured: true,
}),
});
const el = await fixture<UnifiedSettings>(html`<unified-settings></unified-settings>`);
// Make component visible
el.visible = true;
// Wait for async initialization
await new Promise((resolve) => setTimeout(resolve, 100));
await el.updateComplete;
// Check for the lock icon
const lockIcon = el.querySelector('svg');
expect(lockIcon).toBeTruthy();
// Check for the descriptive text
const descriptions = Array.from(el.querySelectorAll('p.text-xs') || []);
const repoDescription = descriptions.find((p) =>
p.textContent?.includes('This path is synced with the VibeTunnel Mac app')
);
expect(repoDescription).toBeTruthy();
});
it('should update repository path via WebSocket when server sends update', async () => {
const el = await fixture<UnifiedSettings>(html`<unified-settings></unified-settings>`);
// Make component visible
el.visible = true;
// Wait for async initialization
await new Promise((resolve) => setTimeout(resolve, 100));
await el.updateComplete;
// Get the WebSocket instance created by the component
const ws = MockWebSocket.instances[0];
expect(ws).toBeTruthy();
// Simulate server sending a config update
ws.simulateMessage({
type: 'config',
data: {
repositoryBasePath: '/Users/new/path',
serverConfigured: true,
},
});
// Wait for the update to process
await new Promise((resolve) => setTimeout(resolve, 50));
await el.updateComplete;
// Check that the input value updated
const input = el.querySelector('input[placeholder="~/"]') as HTMLInputElement | null;
expect(input?.value).toBe('/Users/new/path');
expect(input?.disabled).toBe(true);
});
it('should ignore repository path changes when server-configured', async () => {
// Mock server response with serverConfigured = true
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => ({
repositoryBasePath: '/Users/test/Projects',
serverConfigured: true,
}),
});
const el = await fixture<UnifiedSettings>(html`<unified-settings></unified-settings>`);
// Wait for async initialization
await new Promise((resolve) => setTimeout(resolve, 100));
await el.updateComplete;
// Try to change the repository path
const originalPath = '/Users/test/Projects';
// Change the repository path
const newPath = '/Users/test/new-path';
(
el as UnifiedSettings & { handleAppPreferenceChange: (key: string, value: string) => void }
).handleAppPreferenceChange('repositoryBasePath', '/Users/different/path');
).handleAppPreferenceChange('repositoryBasePath', newPath);
// Wait for any updates
await new Promise((resolve) => setTimeout(resolve, 50));
await el.updateComplete;
// Verify the path didn't change
// Verify the path was saved
const preferences = (el as UnifiedSettings & { appPreferences: AppPreferences }).appPreferences;
expect(preferences.repositoryBasePath).toBe(originalPath);
expect(preferences.repositoryBasePath).toBe(newPath);
// Verify it was saved to localStorage
const savedPrefs = JSON.parse(localStorage.getItem('vibetunnel_app_preferences') || '{}');
expect(savedPrefs.repositoryBasePath).toBe(newPath);
});
it('should reconnect WebSocket after disconnection', async () => {
it('should load repository path from localStorage on initialization', async () => {
// Set a value in localStorage
const savedPath = '/Users/saved/path';
localStorage.setItem(
'vibetunnel_app_preferences',
JSON.stringify({ repositoryBasePath: savedPath })
);
const el = await fixture<UnifiedSettings>(html`<unified-settings></unified-settings>`);
// Make component visible
el.visible = true;
// Wait for async initialization
await new Promise((resolve) => setTimeout(resolve, 100));
await el.updateComplete;
const ws = MockWebSocket.instances[0];
expect(ws).toBeTruthy();
// Clear instances before close to track new connection
MockWebSocket.instances = [];
// Simulate WebSocket close
ws.close();
// Wait for reconnection timeout (5 seconds in the code, but we'll use a shorter time for testing)
await new Promise((resolve) => setTimeout(resolve, 5100));
// Check that a new WebSocket was created
expect(MockWebSocket.instances.length).toBeGreaterThan(0);
const newWs = MockWebSocket.instances[0];
expect(newWs).toBeTruthy();
expect(newWs).not.toBe(ws);
// Verify the path was loaded from localStorage
const preferences = (el as UnifiedSettings & { appPreferences: AppPreferences }).appPreferences;
expect(preferences.repositoryBasePath).toBe(savedPath);
});
it('should handle WebSocket message parsing errors gracefully', async () => {
const el = await fixture<UnifiedSettings>(html`<unified-settings></unified-settings>`);
it('should persist repository path changes across component lifecycle', async () => {
// Create first instance and set a path
const el1 = await fixture<UnifiedSettings>(html`<unified-settings></unified-settings>`);
const newPath = '/Users/test/lifecycle-path';
(
el1 as UnifiedSettings & { handleAppPreferenceChange: (key: string, value: string) => void }
).handleAppPreferenceChange('repositoryBasePath', newPath);
// Wait for async initialization
await new Promise((resolve) => setTimeout(resolve, 100));
await el.updateComplete;
const ws = MockWebSocket.instances[0];
expect(ws).toBeTruthy();
// Send invalid JSON
if (ws.onmessage) {
ws.onmessage(new MessageEvent('message', { data: 'invalid json' }));
}
// Should not throw and component should still work
await new Promise((resolve) => setTimeout(resolve, 50));
expect(el).toBeTruthy();
// Create second instance and verify it loads the saved path
const el2 = await fixture<UnifiedSettings>(html`<unified-settings></unified-settings>`);
await new Promise((resolve) => setTimeout(resolve, 100));
await el2.updateComplete;
const preferences = (el2 as UnifiedSettings & { appPreferences: AppPreferences })
.appPreferences;
expect(preferences.repositoryBasePath).toBe(newPath);
});
it('should save preferences when updated from server', async () => {
// Mock server response with non-server-configured state initially
it('should not overwrite localStorage path when loading server config', async () => {
// Set a value in localStorage
const localPath = '/Users/local/path';
localStorage.setItem(
'vibetunnel_app_preferences',
JSON.stringify({ repositoryBasePath: localPath })
);
// Mock server response that should NOT override the local path
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => ({
quickStartCommands: [{ name: 'test', command: 'test' }],
}),
});
const el = await fixture<UnifiedSettings>(html`<unified-settings></unified-settings>`);
// Make component visible
el.visible = true;
// Wait for async initialization
await new Promise((resolve) => setTimeout(resolve, 100));
await el.updateComplete;
// Verify the path from localStorage was preserved
const path = (el as UnifiedSettings & { appPreferences: AppPreferences }).appPreferences
.repositoryBasePath;
expect(path).toBe(localPath);
});
});
// Mock the RepositoryService import at the module level
vi.mock('@/client/services/repository-service');
describe('UnifiedSettings - Repository Discovery', () => {
let mockAuthClient: { getAuthHeader: ReturnType<typeof vi.fn> };
let mockRepositoryService: { discoverRepositories: ReturnType<typeof vi.fn> };
beforeEach(async () => {
vi.clearAllMocks();
localStorage.clear();
// Mock auth client
mockAuthClient = {
getAuthHeader: vi.fn().mockReturnValue({ Authorization: 'Bearer test-token' }),
};
// Mock repository service response
mockRepositoryService = {
discoverRepositories: vi.fn(),
};
// Set up the mocked RepositoryService
const { RepositoryService } = await import('@/client/services/repository-service');
(RepositoryService as ReturnType<typeof vi.fn>).mockImplementation(() => mockRepositoryService);
// Mock default fetch response
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => ({
@ -533,46 +235,192 @@ describe('UnifiedSettings - Repository Path Server Configuration', () => {
serverConfigured: false,
}),
});
});
const el = await fixture<UnifiedSettings>(html`<unified-settings></unified-settings>`);
it('should trigger repository discovery when settings are opened', async () => {
// Mock repository discovery to return some repositories
mockRepositoryService.discoverRepositories.mockResolvedValue([
{ name: 'repo1', path: '/path/to/repo1' },
{ name: 'repo2', path: '/path/to/repo2' },
]);
const el = await fixture<UnifiedSettings>(html`
<unified-settings .authClient=${mockAuthClient}></unified-settings>
`);
// Initially not visible
expect(el.visible).toBe(false);
// Make component visible
el.visible = true;
// Wait for async initialization
await new Promise((resolve) => setTimeout(resolve, 100));
await el.updateComplete;
// Get the WebSocket instance
const ws = MockWebSocket.instances[0];
expect(ws).toBeTruthy();
// Directly check that the values get updated
const initialPath = (el as UnifiedSettings & { appPreferences: AppPreferences }).appPreferences
.repositoryBasePath;
expect(initialPath).toBe('~/');
// Simulate server update that changes to server-configured with new path
ws.simulateMessage({
type: 'config',
data: {
repositoryBasePath: '/Users/updated/path',
serverConfigured: true,
},
});
// Wait for the update to process
await new Promise((resolve) => setTimeout(resolve, 100));
// Wait for discovery to complete
await new Promise((resolve) => setTimeout(resolve, 200));
await el.updateComplete;
// Verify the path was updated
const updatedPath = (el as UnifiedSettings & { appPreferences: AppPreferences }).appPreferences
.repositoryBasePath;
expect(updatedPath).toBe('/Users/updated/path');
// Check that repository count is displayed
const repositoryCountElement = el.querySelector('#repository-status');
expect(repositoryCountElement?.textContent).toContain('2 repositories found');
});
// Verify the server configured state changed
const isServerConfigured = (el as UnifiedSettings & { isServerConfigured: boolean })
.isServerConfigured;
expect(isServerConfigured).toBe(true);
it('should show refresh button for repository discovery', async () => {
const el = await fixture<UnifiedSettings>(html`
<unified-settings .authClient=${mockAuthClient}></unified-settings>
`);
el.visible = true;
await el.updateComplete;
// Find refresh button
const refreshButton = el.querySelector('button[title="Refresh repository list"]');
expect(refreshButton).toBeTruthy();
expect(refreshButton?.querySelector('svg')).toBeTruthy();
});
it('should refresh repositories when refresh button is clicked', async () => {
// Mock initial discovery returns 2 repos
mockRepositoryService.discoverRepositories
.mockResolvedValueOnce([
{ name: 'repo1', path: '/path/to/repo1' },
{ name: 'repo2', path: '/path/to/repo2' },
])
.mockResolvedValueOnce([
{ name: 'repo1', path: '/path/to/repo1' },
{ name: 'repo2', path: '/path/to/repo2' },
{ name: 'repo3', path: '/path/to/repo3' },
]);
const el = await fixture<UnifiedSettings>(html`
<unified-settings .authClient=${mockAuthClient}></unified-settings>
`);
el.visible = true;
await el.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 200));
await el.updateComplete;
// Initial count should be 2
let repositoryCountElement = el.querySelector('#repository-status');
expect(repositoryCountElement?.textContent).toContain('2 repositories found');
// Click refresh button
const refreshButton = el.querySelector(
'button[title="Refresh repository list"]'
) as HTMLButtonElement;
refreshButton.click();
await new Promise((resolve) => setTimeout(resolve, 200));
await el.updateComplete;
// Count should now be 3
repositoryCountElement = el.querySelector('#repository-status');
expect(repositoryCountElement?.textContent).toContain('3 repositories found');
});
it('should show scanning state during discovery', async () => {
// Mock slow discovery
mockRepositoryService.discoverRepositories.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve([]), 500))
);
const el = await fixture<UnifiedSettings>(html`
<unified-settings .authClient=${mockAuthClient}></unified-settings>
`);
el.visible = true;
await el.updateComplete;
// Click refresh button
const refreshButton = el.querySelector(
'button[title="Refresh repository list"]'
) as HTMLButtonElement;
refreshButton.click();
await el.updateComplete;
// Should show scanning state
let scanningText = el.querySelector('#repository-status');
expect(scanningText?.textContent).toContain('Scanning...');
// Button should be disabled
expect(refreshButton.disabled).toBe(true);
// Wait for discovery to complete
await new Promise((resolve) => setTimeout(resolve, 700));
await el.updateComplete;
// Re-query the element after updates
scanningText = el.querySelector('#repository-status');
// Should show result
expect(scanningText?.textContent).toContain('0 repositories found');
expect(refreshButton.disabled).toBe(false);
});
it('should trigger repository discovery when repository path changes', async () => {
mockRepositoryService.discoverRepositories
.mockResolvedValueOnce([{ name: 'repo1', path: '/path/to/repo1' }])
.mockResolvedValueOnce([
{ name: 'other-repo1', path: '/other/path/repo1' },
{ name: 'other-repo2', path: '/other/path/repo2' },
]);
const el = await fixture<UnifiedSettings>(html`
<unified-settings .authClient=${mockAuthClient}></unified-settings>
`);
el.visible = true;
await el.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 200));
await el.updateComplete;
// Initial count
let repositoryCountElement = el.querySelector('#repository-status');
expect(repositoryCountElement?.textContent).toContain('1 repositories found');
// Change repository path
const input = el.querySelector('input[placeholder="~/"]') as HTMLInputElement;
input.value = '/other/path';
input.dispatchEvent(new Event('input'));
await new Promise((resolve) => setTimeout(resolve, 200));
await el.updateComplete;
// Should show new count
repositoryCountElement = el.querySelector('#repository-status');
expect(repositoryCountElement?.textContent).toContain('2 repositories found');
});
it('should handle repository discovery errors gracefully', async () => {
// Mock discovery to fail
mockRepositoryService.discoverRepositories.mockRejectedValue(new Error('Discovery failed'));
const el = await fixture<UnifiedSettings>(html`
<unified-settings .authClient=${mockAuthClient}></unified-settings>
`);
el.visible = true;
await el.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 200));
await el.updateComplete;
// Should show 0 repositories
const repositoryCountElement = el.querySelector('#repository-status');
expect(repositoryCountElement?.textContent).toContain('0 repositories found');
});
it('should not trigger discovery if authClient is not available', async () => {
// Don't initialize mockRepositoryService for this test since no authClient is provided
const el = await fixture<UnifiedSettings>(html`
<unified-settings></unified-settings>
`);
el.visible = true;
await el.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 200));
await el.updateComplete;
// Should still show repository count as 0
const repositoryCountElement = el.querySelector('#repository-status');
expect(repositoryCountElement?.textContent).toContain('0 repositories found');
});
});

View file

@ -6,6 +6,8 @@ import {
type PushSubscription,
pushNotificationService,
} from '../services/push-notification-service.js';
import { RepositoryService } from '../services/repository-service.js';
import { ServerConfigService } from '../services/server-config-service.js';
import { createLogger } from '../utils/logger.js';
import { type MediaQueryState, responsiveObserver } from '../utils/responsive-utils.js';
@ -15,19 +17,12 @@ export interface AppPreferences {
useDirectKeyboard: boolean;
useBinaryMode: boolean;
showLogLink: boolean;
repositoryBasePath: string;
}
interface ServerConfig {
repositoryBasePath: string;
serverConfigured?: boolean;
}
const DEFAULT_APP_PREFERENCES: AppPreferences = {
useDirectKeyboard: true, // Default to modern direct keyboard for new users
useBinaryMode: false, // Default to SSE/RSC mode for compatibility
showLogLink: false,
repositoryBasePath: '~/',
};
export const STORAGE_KEY = 'vibetunnel_app_preferences';
@ -60,8 +55,8 @@ export class UnifiedSettings extends LitElement {
// App settings state
@state() private appPreferences: AppPreferences = DEFAULT_APP_PREFERENCES;
@state() private repositoryBasePath = '~/';
@state() private mediaState: MediaQueryState = responsiveObserver.getCurrentState();
@state() private serverConfig: ServerConfig | null = null;
@state() private isServerConfigured = false;
@state() private repositoryCount = 0;
@state() private isDiscoveringRepositories = false;
@ -69,14 +64,21 @@ export class UnifiedSettings extends LitElement {
private permissionChangeUnsubscribe?: () => void;
private subscriptionChangeUnsubscribe?: () => void;
private unsubscribeResponsive?: () => void;
private configWebSocket?: WebSocket;
private repositoryService?: RepositoryService;
private serverConfigService?: ServerConfigService;
connectedCallback() {
super.connectedCallback();
this.initializeNotifications();
this.loadAppPreferences();
this.connectConfigWebSocket();
this.discoverRepositories();
// Initialize services
this.serverConfigService = new ServerConfigService(this.authClient);
// Initialize repository service if authClient is available
if (this.authClient) {
this.repositoryService = new RepositoryService(this.authClient, this.serverConfigService);
}
// Subscribe to responsive changes
this.unsubscribeResponsive = responsiveObserver.subscribe((state) => {
@ -95,10 +97,6 @@ export class UnifiedSettings extends LitElement {
if (this.unsubscribeResponsive) {
this.unsubscribeResponsive();
}
if (this.configWebSocket) {
this.configWebSocket.close();
this.configWebSocket = undefined;
}
// Clean up keyboard listener
document.removeEventListener('keydown', this.handleKeyDown);
}
@ -110,10 +108,27 @@ export class UnifiedSettings extends LitElement {
document.startViewTransition?.(() => {
this.requestUpdate();
});
// Discover repositories when settings are opened
this.discoverRepositories();
} else {
document.removeEventListener('keydown', this.handleKeyDown);
}
}
// Initialize repository service when authClient becomes available
if (changedProperties.has('authClient') && this.authClient) {
if (!this.repositoryService && this.serverConfigService) {
this.repositoryService = new RepositoryService(this.authClient, this.serverConfigService);
}
// Update server config service's authClient
if (this.serverConfigService) {
this.serverConfigService.setAuthClient(this.authClient);
}
// Discover repositories if settings are already visible
if (this.visible) {
this.discoverRepositories();
}
}
}
private async initializeNotifications(): Promise<void> {
@ -143,28 +158,20 @@ export class UnifiedSettings extends LitElement {
}
// Fetch server configuration
try {
const response = await fetch('/api/config');
if (response.ok) {
const serverConfig: ServerConfig = await response.json();
this.serverConfig = serverConfig;
if (this.serverConfigService) {
try {
const serverConfig = await this.serverConfigService.loadConfig();
this.isServerConfigured = serverConfig.serverConfigured ?? false;
// If server-configured, always use server's path
if (this.isServerConfigured) {
this.appPreferences.repositoryBasePath = serverConfig.repositoryBasePath;
// Save the updated preferences
this.saveAppPreferences();
} else if (!stored || !JSON.parse(stored).repositoryBasePath) {
// If we don't have a local repository base path and not server-configured, use the server's default
this.appPreferences.repositoryBasePath =
serverConfig.repositoryBasePath || DEFAULT_APP_PREFERENCES.repositoryBasePath;
// Save the updated preferences
this.saveAppPreferences();
}
// Always use server's repository base path
this.repositoryBasePath = serverConfig.repositoryBasePath || '~/';
} catch (error) {
logger.warn('Failed to fetch server config', error);
}
} catch (error) {
logger.warn('Failed to fetch server config', error);
}
// Discover repositories after preferences are loaded if visible
if (this.visible && this.repositoryService) {
this.discoverRepositories();
}
} catch (error) {
logger.error('Failed to load app preferences', error);
@ -186,6 +193,27 @@ export class UnifiedSettings extends LitElement {
}
}
private async discoverRepositories() {
if (!this.repositoryService || this.isDiscoveringRepositories) {
return;
}
this.isDiscoveringRepositories = true;
try {
// Add a small delay to ensure preferences are loaded
await new Promise((resolve) => setTimeout(resolve, 100));
const repositories = await this.repositoryService.discoverRepositories();
this.repositoryCount = repositories.length;
logger.log(`Discovered ${this.repositoryCount} repositories in ${this.repositoryBasePath}`);
} catch (error) {
logger.error('Failed to discover repositories', error);
this.repositoryCount = 0;
} finally {
this.isDiscoveringRepositories = false;
}
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && this.visible) {
this.handleClose();
@ -271,104 +299,25 @@ export class UnifiedSettings extends LitElement {
}
private handleAppPreferenceChange(key: keyof AppPreferences, value: boolean | string) {
// Don't allow changes to repository path if server-configured
if (key === 'repositoryBasePath' && this.isServerConfigured) {
return;
}
// Update locally
this.appPreferences = { ...this.appPreferences, [key]: value };
this.saveAppPreferences();
// Send repository path updates to server/Mac app
if (key === 'repositoryBasePath' && this.configWebSocket?.readyState === WebSocket.OPEN) {
logger.log('Sending repository path update to server:', value);
this.configWebSocket.send(
JSON.stringify({
type: 'update-repository-path',
path: value as string,
})
);
// Re-discover repositories when path changes
this.discoverRepositories();
}
}
private async discoverRepositories() {
this.isDiscoveringRepositories = true;
try {
const basePath = this.appPreferences.repositoryBasePath || '~/';
const response = await fetch(
`/api/repositories/discover?path=${encodeURIComponent(basePath)}`,
{
headers: this.authClient?.getAuthHeader() || {},
}
);
if (response.ok) {
const repositories = await response.json();
this.repositoryCount = repositories.length;
logger.debug(`Discovered ${this.repositoryCount} repositories in ${basePath}`);
} else {
logger.error('Failed to discover repositories');
this.repositoryCount = 0;
private async handleRepositoryBasePathChange(value: string) {
if (this.serverConfigService) {
try {
// Update server config
await this.serverConfigService.updateConfig({ repositoryBasePath: value });
// Update local state
this.repositoryBasePath = value;
// Rediscover repositories
this.discoverRepositories();
} catch (error) {
logger.error('Failed to update repository base path:', error);
// Revert the change on error
this.requestUpdate();
}
} catch (error) {
logger.error('Error discovering repositories:', error);
this.repositoryCount = 0;
} finally {
this.isDiscoveringRepositories = false;
}
}
private connectConfigWebSocket() {
try {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/config`;
this.configWebSocket = new WebSocket(wsUrl);
this.configWebSocket.onopen = () => {
logger.log('Config WebSocket connected');
};
this.configWebSocket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'config' && message.data) {
const { repositoryBasePath } = message.data;
// Update server config state
this.serverConfig = message.data;
this.isServerConfigured = message.data.serverConfigured ?? false;
// If server-configured, update the app preferences
if (this.isServerConfigured && repositoryBasePath) {
this.appPreferences.repositoryBasePath = repositoryBasePath;
this.saveAppPreferences();
logger.log('Repository path updated from server:', repositoryBasePath);
}
}
} catch (error) {
logger.error('Failed to parse config WebSocket message:', error);
}
};
this.configWebSocket.onerror = (error) => {
logger.error('Config WebSocket error:', error);
};
this.configWebSocket.onclose = () => {
logger.log('Config WebSocket closed');
// Attempt to reconnect after a delay
setTimeout(() => {
// Check if component is still connected to DOM
if (this.isConnected) {
this.connectConfigWebSocket();
}
}, 5000);
};
} catch (error) {
logger.error('Failed to connect config WebSocket:', error);
}
}
@ -433,7 +382,7 @@ export class UnifiedSettings extends LitElement {
style="view-transition-name: settings-modal"
>
<!-- Header -->
<div class="p-4 pb-4 border-b border-base relative flex-shrink-0">
<div class="p-4 pb-4 border-b border-border/50 relative flex-shrink-0">
<h2 class="text-primary text-lg font-bold">Settings</h2>
<button
class="absolute top-4 right-4 text-muted hover:text-primary transition-colors p-1"
@ -493,7 +442,7 @@ export class UnifiedSettings extends LitElement {
`
: html`
<!-- Main toggle -->
<div class="flex items-center justify-between p-4 bg-tertiary rounded-lg border border-base">
<div class="flex items-center justify-between p-4 bg-tertiary rounded-lg border border-border/50">
<div class="flex-1">
<label class="text-primary font-medium">Enable Notifications</label>
<p class="text-muted text-xs mt-1">
@ -543,7 +492,7 @@ export class UnifiedSettings extends LitElement {
</div>
<!-- Test button -->
<div class="flex items-center justify-between pt-3 mt-3 border-t border-base">
<div class="flex items-center justify-between pt-3 mt-3 border-t border-border/50">
<p class="text-xs text-muted">Test your notification settings</p>
<button
class="btn-secondary text-xs px-3 py-1.5"
@ -601,7 +550,7 @@ export class UnifiedSettings extends LitElement {
${
this.mediaState.isMobile
? html`
<div class="flex items-center justify-between p-4 bg-tertiary rounded-lg border border-base">
<div class="flex items-center justify-between p-4 bg-tertiary rounded-lg border border-border/50">
<div class="flex-1">
<label class="text-primary font-medium">
Use Direct Keyboard
@ -630,7 +579,7 @@ export class UnifiedSettings extends LitElement {
}
<!-- Show log link -->
<div class="flex items-center justify-between p-4 bg-tertiary rounded-lg border border-base">
<div class="flex items-center justify-between p-4 bg-tertiary rounded-lg border border-border/50">
<div class="flex-1">
<label class="text-primary font-medium">Show Log Link</label>
<p class="text-muted text-xs mt-1">
@ -654,15 +603,28 @@ export class UnifiedSettings extends LitElement {
</div>
<!-- Repository Base Path -->
<div class="p-4 bg-tertiary rounded-lg border border-base">
<div class="p-4 bg-tertiary rounded-lg border border-border/50">
<div class="mb-3">
<div class="flex items-center justify-between">
<label class="text-primary font-medium">Repository Base Path</label>
${
this.isDiscoveringRepositories
? html`<span class="text-muted text-xs">Scanning...</span>`
: html`<span class="text-muted text-xs">${this.repositoryCount} repositories found</span>`
}
<div class="flex items-center gap-2">
${
this.isDiscoveringRepositories
? html`<span id="repository-status" class="text-muted text-xs">Scanning...</span>`
: html`<span id="repository-status" class="text-muted text-xs">${this.repositoryCount} repositories found</span>`
}
<button
@click=${() => this.discoverRepositories()}
?disabled=${this.isDiscoveringRepositories}
class="text-primary hover:text-primary-hover text-xs transition-colors duration-200"
title="Refresh repository list"
>
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div>
<p class="text-muted text-xs mt-1">
${
@ -675,10 +637,10 @@ export class UnifiedSettings extends LitElement {
<div class="flex gap-2">
<input
type="text"
.value=${this.appPreferences.repositoryBasePath}
.value=${this.repositoryBasePath}
@input=${(e: Event) => {
const input = e.target as HTMLInputElement;
this.handleAppPreferenceChange('repositoryBasePath', input.value);
this.handleRepositoryBasePathChange(input.value);
}}
placeholder="~/"
class="input-field py-2 text-sm flex-1 ${

View file

@ -260,6 +260,7 @@ describe('VibeTerminalBinary', () => {
it('should apply max columns constraint', () => {
element.maxCols = 120;
element.fitHorizontally = true; // Enable horizontal fitting to apply maxCols constraint
// Mock terminal container dimensions
const container = element.querySelector('#terminal-container') as HTMLElement;

View file

@ -225,9 +225,15 @@ export class VibeTerminalBinary extends VibeTerminalBuffer {
let newCols = Math.floor(rect.width / charWidth);
const newRows = Math.floor(rect.height / lineHeight);
// Apply max columns constraint if set
if (this.maxCols > 0 && newCols > this.maxCols) {
newCols = this.maxCols;
// Apply fitHorizontally logic (same as ASCII terminal)
if (!this.fitHorizontally && !this.userOverrideWidth) {
// If not fitting to window and no user override, use initial cols or default
newCols = this.initialCols || 80;
} else {
// Apply max columns constraint if set
if (this.maxCols > 0 && newCols > this.maxCols) {
newCols = this.maxCols;
}
}
// Only resize if dimensions actually changed

View file

@ -8,9 +8,6 @@ export type NotificationPreferences = PushNotificationPreferences;
const logger = createLogger('push-notification-service');
// VAPID public key will be fetched from server
let _VAPID_PUBLIC_KEY: string | null = null;
type NotificationPermissionChangeCallback = (permission: NotificationPermission) => void;
type SubscriptionChangeCallback = (subscription: PushSubscription | null) => void;
@ -544,7 +541,6 @@ export class PushNotificationService {
this.vapidPublicKey = data.publicKey;
this.pushNotificationsAvailable = true;
_VAPID_PUBLIC_KEY = data.publicKey; // For backward compatibility
logger.log('VAPID public key fetched from server');
logger.debug(`Public key: ${data.publicKey.substring(0, 20)}...`);

View file

@ -5,10 +5,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Repository } from '../components/autocomplete-manager';
import type { AuthClient } from './auth-client';
import { RepositoryService } from './repository-service';
import type { ServerConfigService } from './server-config-service';
describe('RepositoryService', () => {
let service: RepositoryService;
let mockAuthClient: AuthClient;
let mockServerConfigService: ServerConfigService;
let fetchMock: ReturnType<typeof vi.fn>;
let mockStorage: { [key: string]: string };
@ -41,8 +43,13 @@ describe('RepositoryService', () => {
getAuthHeader: vi.fn(() => ({ Authorization: 'Bearer test-token' })),
} as unknown as AuthClient;
// Mock server config service
mockServerConfigService = {
getRepositoryBasePath: vi.fn(async () => '~/'),
} as unknown as ServerConfigService;
// Create service instance
service = new RepositoryService(mockAuthClient);
service = new RepositoryService(mockAuthClient, mockServerConfigService);
});
afterEach(() => {
@ -84,11 +91,11 @@ describe('RepositoryService', () => {
expect(result).toEqual(mockRepositories);
});
it('should use repository base path from preferences', async () => {
// Set preferences in localStorage - using the correct key from unified-settings.js
mockStorage.vibetunnel_app_preferences = JSON.stringify({
repositoryBasePath: '/custom/path',
});
it('should use repository base path from server config', async () => {
// Mock the server config service to return a custom path
vi.mocked(mockServerConfigService.getRepositoryBasePath).mockResolvedValueOnce(
'/custom/path'
);
fetchMock.mockResolvedValueOnce({
ok: true,
@ -105,24 +112,20 @@ describe('RepositoryService', () => {
);
});
it('should handle invalid preferences JSON', async () => {
// Set invalid JSON in localStorage
mockStorage.vibetunnel_app_preferences = 'invalid-json';
it('should handle error from server config', async () => {
// Mock the server config to throw an error
vi.mocked(mockServerConfigService.getRepositoryBasePath).mockRejectedValueOnce(
new Error('Config error')
);
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => [],
});
await service.discoverRepositories();
// Should fall back to default path
expect(fetchMock).toHaveBeenCalledWith(
`/api/repositories/discover?path=${encodeURIComponent('~/')}`,
{
headers: { Authorization: 'Bearer test-token' },
}
);
// Should handle the error gracefully
const result = await service.discoverRepositories();
expect(result).toEqual([]);
});
it('should handle fetch errors', async () => {
@ -145,10 +148,9 @@ describe('RepositoryService', () => {
expect(result).toEqual([]);
});
it('should handle empty repository base path in preferences', async () => {
mockStorage.vibetunnel_app_preferences = JSON.stringify({
repositoryBasePath: '',
});
it('should handle empty repository base path from server config', async () => {
// Mock the server config service to return empty string
vi.mocked(mockServerConfigService.getRepositoryBasePath).mockResolvedValueOnce('');
fetchMock.mockResolvedValueOnce({
ok: true,
@ -157,13 +159,10 @@ describe('RepositoryService', () => {
await service.discoverRepositories();
// Should fall back to default path
expect(fetchMock).toHaveBeenCalledWith(
`/api/repositories/discover?path=${encodeURIComponent('~/')}`,
{
headers: { Authorization: 'Bearer test-token' },
}
);
// Should use empty path in the request
expect(fetchMock).toHaveBeenCalledWith(`/api/repositories/discover?path=`, {
headers: { Authorization: 'Bearer test-token' },
});
});
it('should include auth header in request', async () => {

View file

@ -1,18 +1,17 @@
import type { Repository } from '../components/autocomplete-manager.js';
import {
STORAGE_KEY as APP_PREFERENCES_STORAGE_KEY,
type AppPreferences,
} from '../components/unified-settings.js';
import { createLogger } from '../utils/logger.js';
import type { AuthClient } from './auth-client.js';
import type { ServerConfigService } from './server-config-service.js';
const logger = createLogger('repository-service');
export class RepositoryService {
private authClient: AuthClient;
private serverConfigService: ServerConfigService;
constructor(authClient: AuthClient) {
constructor(authClient: AuthClient, serverConfigService: ServerConfigService) {
this.authClient = authClient;
this.serverConfigService = serverConfigService;
}
/**
@ -20,10 +19,10 @@ export class RepositoryService {
* @returns Promise with discovered repositories
*/
async discoverRepositories(): Promise<Repository[]> {
// Get app preferences to read repositoryBasePath
const basePath = this.getRepositoryBasePath();
try {
// Get repository base path from server config
const basePath = await this.serverConfigService.getRepositoryBasePath();
const response = await fetch(
`/api/repositories/discover?path=${encodeURIComponent(basePath)}`,
{
@ -44,23 +43,4 @@ export class RepositoryService {
return [];
}
}
/**
* Gets the repository base path from app preferences
* @returns The base path or default '~/'
*/
private getRepositoryBasePath(): string {
const savedPreferences = localStorage.getItem(APP_PREFERENCES_STORAGE_KEY);
if (savedPreferences) {
try {
const preferences: AppPreferences = JSON.parse(savedPreferences);
return preferences.repositoryBasePath || '~/';
} catch (error) {
logger.error('Failed to parse app preferences:', error);
}
}
return '~/';
}
}

View file

@ -0,0 +1,307 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { QuickStartCommand } from '../../types/config.js';
import type { AuthClient } from './auth-client.js';
import { type ServerConfig, ServerConfigService } from './server-config-service.js';
// Mock the logger
vi.mock('../utils/logger.js', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
}),
}));
// Mock response data
const mockServerConfig: ServerConfig = {
repositoryBasePath: '/Users/test/repos',
serverConfigured: true,
quickStartCommands: [{ name: '✨ claude', command: 'claude' }, { command: 'zsh' }],
};
describe('ServerConfigService', () => {
let service: ServerConfigService;
let fetchMock: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Reset fetch mock
fetchMock = vi.spyOn(global, 'fetch');
service = new ServerConfigService();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('loadConfig', () => {
it('should load config from server', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => mockServerConfig,
} as Response);
const config = await service.loadConfig();
expect(fetchMock).toHaveBeenCalledWith('/api/config', {
headers: {},
});
expect(config).toEqual(mockServerConfig);
});
it('should include auth header when authClient is set', async () => {
const mockAuthClient = {
getAuthHeader: () => ({ Authorization: 'Bearer test-token' }),
} as AuthClient;
service.setAuthClient(mockAuthClient);
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => mockServerConfig,
} as Response);
await service.loadConfig();
expect(fetchMock).toHaveBeenCalledWith('/api/config', {
headers: { Authorization: 'Bearer test-token' },
});
});
it('should return cached config when not expired', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => mockServerConfig,
} as Response);
// First load
const config1 = await service.loadConfig();
expect(fetchMock).toHaveBeenCalledTimes(1);
// Second load should use cache
const config2 = await service.loadConfig();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(config2).toEqual(config1);
});
it('should refresh config when forceRefresh is true', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockServerConfig,
} as Response);
// First load
await service.loadConfig();
expect(fetchMock).toHaveBeenCalledTimes(1);
// Force refresh
await service.loadConfig(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('should return default config on error', async () => {
fetchMock.mockRejectedValueOnce(new Error('Network error'));
const config = await service.loadConfig();
expect(config).toEqual({
repositoryBasePath: '~/',
serverConfigured: false,
quickStartCommands: [],
});
});
it('should handle non-ok response', async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
statusText: 'Not Found',
} as Response);
const config = await service.loadConfig();
expect(config).toEqual({
repositoryBasePath: '~/',
serverConfigured: false,
quickStartCommands: [],
});
});
});
describe('updateQuickStartCommands', () => {
it('should update quick start commands', async () => {
const newCommands: QuickStartCommand[] = [
{ name: 'Python', command: 'python3' },
{ command: 'node' },
];
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
} as Response);
await service.updateQuickStartCommands(newCommands);
expect(fetchMock).toHaveBeenCalledWith('/api/config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ quickStartCommands: newCommands }),
});
});
it('should filter invalid commands', async () => {
const commands: QuickStartCommand[] = [
{ name: 'Valid', command: 'valid' },
{ command: '' }, // Invalid - empty command
{ command: ' ' }, // Invalid - whitespace only
{ command: 'valid2' },
];
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
} as Response);
await service.updateQuickStartCommands(commands);
expect(fetchMock).toHaveBeenCalledWith('/api/config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
quickStartCommands: [{ name: 'Valid', command: 'valid' }, { command: 'valid2' }],
}),
});
});
it('should throw on invalid input', async () => {
await expect(
service.updateQuickStartCommands(null as unknown as QuickStartCommand[])
).rejects.toThrow('Invalid quick start commands');
await expect(
service.updateQuickStartCommands(undefined as unknown as QuickStartCommand[])
).rejects.toThrow('Invalid quick start commands');
});
it('should throw on server error', async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
statusText: 'Bad Request',
} as Response);
await expect(service.updateQuickStartCommands([])).rejects.toThrow(
'Failed to update config: Bad Request'
);
});
it('should clear cache after update', async () => {
// Load config to populate cache
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => mockServerConfig,
} as Response);
await service.loadConfig();
// Update commands
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
} as Response);
await service.updateQuickStartCommands([]);
// Next load should fetch from server (cache cleared)
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => mockServerConfig,
} as Response);
await service.loadConfig();
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});
describe('helper methods', () => {
beforeEach(() => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => mockServerConfig,
} as Response);
});
it('getRepositoryBasePath should return repository path', async () => {
const path = await service.getRepositoryBasePath();
expect(path).toBe('/Users/test/repos');
});
it('getRepositoryBasePath should return default when not set', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ ...mockServerConfig, repositoryBasePath: undefined }),
} as Response);
const path = await service.getRepositoryBasePath();
expect(path).toBe('~/');
});
it('isServerConfigured should return server configured status', async () => {
const configured = await service.isServerConfigured();
expect(configured).toBe(true);
});
it('isServerConfigured should return false when not set', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ ...mockServerConfig, serverConfigured: undefined }),
} as Response);
const configured = await service.isServerConfigured();
expect(configured).toBe(false);
});
it('getQuickStartCommands should return commands', async () => {
const commands = await service.getQuickStartCommands();
expect(commands).toEqual(mockServerConfig.quickStartCommands);
});
it('getQuickStartCommands should return empty array when not set', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ ...mockServerConfig, quickStartCommands: undefined }),
} as Response);
const commands = await service.getQuickStartCommands();
expect(commands).toEqual([]);
});
});
describe('setAuthClient', () => {
it('should clear cache when auth client changes', async () => {
// Load config to populate cache
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => mockServerConfig,
} as Response);
await service.loadConfig();
expect(fetchMock).toHaveBeenCalledTimes(1);
// Set new auth client
const mockAuthClient = {
getAuthHeader: () => ({ Authorization: 'Bearer new-token' }),
} as AuthClient;
service.setAuthClient(mockAuthClient);
// Next load should fetch from server (cache cleared)
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => mockServerConfig,
} as Response);
await service.loadConfig();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenLastCalledWith('/api/config', {
headers: { Authorization: 'Bearer new-token' },
});
});
});
});

View file

@ -0,0 +1,192 @@
/**
* Server Configuration Service
*
* Centralized service for managing server configuration including:
* - Quick start commands
* - Repository base path
* - Server configuration status
*/
import type { QuickStartCommand } from '../../types/config.js';
import { createLogger } from '../utils/logger.js';
import type { AuthClient } from './auth-client.js';
const logger = createLogger('server-config-service');
export interface ServerConfig {
repositoryBasePath: string;
serverConfigured?: boolean;
quickStartCommands?: QuickStartCommand[];
}
export class ServerConfigService {
private authClient?: AuthClient;
private configCache?: ServerConfig;
private cacheTimestamp?: number;
private readonly CACHE_TTL = 60000; // 1 minute cache
constructor(authClient?: AuthClient) {
this.authClient = authClient;
}
/**
* Set or update the auth client
*/
setAuthClient(authClient: AuthClient): void {
this.authClient = authClient;
// Clear cache when auth changes
this.clearCache();
}
/**
* Clear the config cache
*/
private clearCache(): void {
this.configCache = undefined;
this.cacheTimestamp = undefined;
}
/**
* Check if cache is still valid
*/
private isCacheValid(): boolean {
if (!this.configCache || !this.cacheTimestamp) {
return false;
}
return Date.now() - this.cacheTimestamp < this.CACHE_TTL;
}
/**
* Load server configuration
* @param forceRefresh - Force refresh even if cache is valid
*/
async loadConfig(forceRefresh = false): Promise<ServerConfig> {
// Return cached config if valid and not forcing refresh
if (!forceRefresh && this.isCacheValid() && this.configCache) {
logger.debug('Returning cached server config');
return this.configCache;
}
try {
const response = await fetch('/api/config', {
headers: this.authClient ? this.authClient.getAuthHeader() : {},
});
if (!response.ok) {
throw new Error(`Failed to load config: ${response.statusText}`);
}
const config: ServerConfig = await response.json();
// Update cache
this.configCache = config;
this.cacheTimestamp = Date.now();
logger.debug('Loaded server config:', config);
return config;
} catch (error) {
logger.error('Failed to load server config:', error);
// Return default config on error
return {
repositoryBasePath: '~/',
serverConfigured: false,
quickStartCommands: [],
};
}
}
/**
* Update quick start commands
*/
async updateQuickStartCommands(commands: QuickStartCommand[]): Promise<void> {
if (!commands || !Array.isArray(commands)) {
throw new Error('Invalid quick start commands');
}
// Validate commands
const validCommands = commands.filter(
(cmd) => cmd && typeof cmd.command === 'string' && cmd.command.trim()
);
try {
const response = await fetch('/api/config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(this.authClient ? this.authClient.getAuthHeader() : {}),
},
body: JSON.stringify({ quickStartCommands: validCommands }),
});
if (!response.ok) {
throw new Error(`Failed to update config: ${response.statusText}`);
}
// Clear cache to force reload on next access
this.clearCache();
logger.debug('Updated quick start commands:', validCommands);
} catch (error) {
logger.error('Failed to update quick start commands:', error);
throw error;
}
}
/**
* Get repository base path from config
*/
async getRepositoryBasePath(): Promise<string> {
const config = await this.loadConfig();
return config.repositoryBasePath || '~/';
}
/**
* Check if server is configured (Mac app connected)
*/
async isServerConfigured(): Promise<boolean> {
const config = await this.loadConfig();
return config.serverConfigured ?? false;
}
/**
* Get quick start commands
*/
async getQuickStartCommands(): Promise<QuickStartCommand[]> {
const config = await this.loadConfig();
return config.quickStartCommands || [];
}
/**
* Update configuration (supports partial updates)
*/
async updateConfig(updates: Partial<ServerConfig>): Promise<void> {
if (!updates || typeof updates !== 'object') {
throw new Error('Invalid configuration updates');
}
try {
const response = await fetch('/api/config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(this.authClient ? this.authClient.getAuthHeader() : {}),
},
body: JSON.stringify(updates),
});
if (!response.ok) {
throw new Error(`Failed to update config: ${response.statusText}`);
}
// Clear cache to force reload on next access
this.clearCache();
logger.debug('Updated server config:', updates);
} catch (error) {
logger.error('Failed to update server config:', error);
throw error;
}
}
}
// Export singleton instance for easy access
export const serverConfigService = new ServerConfigService();

View file

@ -0,0 +1,523 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Session } from '../../shared/types.js';
import type { AuthClient } from './auth-client.js';
import { sessionActionService } from './session-action-service.js';
// Mock the session-actions utility - must use vi.hoisted
const { mockTerminateSession } = vi.hoisted(() => {
return {
mockTerminateSession: vi.fn(),
};
});
vi.mock('../utils/session-actions.js', () => ({
terminateSession: mockTerminateSession,
}));
describe('SessionActionService', () => {
const mockAuthClient: AuthClient = {
getAuthHeader: () => ({ Authorization: 'Bearer test-token' }),
isAuthenticated: () => true,
login: vi.fn(),
logout: vi.fn(),
on: vi.fn(),
emit: vi.fn(),
removeListener: vi.fn(),
};
const mockSession: Session = {
id: 'test-session-id',
name: 'Test Session',
status: 'running',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastActivity: new Date().toISOString(),
path: '/test/path',
cols: 80,
rows: 24,
};
beforeEach(() => {
vi.clearAllMocks();
// Reset mock implementation to default success
mockTerminateSession.mockResolvedValue({ success: true });
// Mock window.dispatchEvent using happy-dom's window
vi.spyOn(window, 'dispatchEvent').mockImplementation(() => true);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('singleton pattern', () => {
it('should return the same instance', () => {
const instance1 = sessionActionService;
const instance2 = sessionActionService;
expect(instance1).toBe(instance2);
});
});
describe('terminateSession', () => {
it('should terminate a running session successfully', async () => {
const onSuccess = vi.fn();
const onError = vi.fn();
const result = await sessionActionService.terminateSession(mockSession, {
authClient: mockAuthClient,
callbacks: {
onSuccess,
onError,
},
});
expect(result.success).toBe(true);
expect(onSuccess).toHaveBeenCalledWith('terminate', 'test-session-id');
expect(onError).not.toHaveBeenCalled();
expect(mockTerminateSession).toHaveBeenCalledWith(
'test-session-id',
mockAuthClient,
'running'
);
// Check global event was dispatched
expect(window.dispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'session-action',
detail: {
action: 'terminate',
sessionId: 'test-session-id',
},
})
);
});
it('should not terminate a non-running session', async () => {
const onError = vi.fn();
const exitedSession = { ...mockSession, status: 'exited' as const };
const result = await sessionActionService.terminateSession(exitedSession, {
authClient: mockAuthClient,
callbacks: { onError },
});
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid session state');
expect(onError).toHaveBeenCalledWith('Cannot terminate session: invalid state');
expect(mockTerminateSession).not.toHaveBeenCalled();
expect(window.dispatchEvent).not.toHaveBeenCalled();
});
it('should handle null session', async () => {
const onError = vi.fn();
const result = await sessionActionService.terminateSession(null as unknown as Session, {
authClient: mockAuthClient,
callbacks: { onError },
});
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid session state');
expect(onError).toHaveBeenCalledWith('Cannot terminate session: invalid state');
});
it('should handle termination failure', async () => {
const onError = vi.fn();
const onSuccess = vi.fn();
mockTerminateSession.mockResolvedValueOnce({
success: false,
error: 'Network error',
});
const result = await sessionActionService.terminateSession(mockSession, {
authClient: mockAuthClient,
callbacks: { onError, onSuccess },
});
expect(result.success).toBe(false);
expect(result.error).toBe('Network error');
expect(onError).toHaveBeenCalledWith('Failed to terminate session: Network error');
expect(onSuccess).not.toHaveBeenCalled();
expect(window.dispatchEvent).not.toHaveBeenCalled();
});
it('should work without callbacks', async () => {
const result = await sessionActionService.terminateSession(mockSession, {
authClient: mockAuthClient,
});
expect(result.success).toBe(true);
expect(mockTerminateSession).toHaveBeenCalled();
expect(window.dispatchEvent).toHaveBeenCalled();
});
});
describe('clearSession', () => {
it('should clear an exited session successfully', async () => {
const onSuccess = vi.fn();
const exitedSession = { ...mockSession, status: 'exited' as const };
const result = await sessionActionService.clearSession(exitedSession, {
authClient: mockAuthClient,
callbacks: { onSuccess },
});
expect(result.success).toBe(true);
expect(onSuccess).toHaveBeenCalledWith('clear', 'test-session-id');
expect(mockTerminateSession).toHaveBeenCalledWith(
'test-session-id',
mockAuthClient,
'exited'
);
expect(window.dispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'session-action',
detail: {
action: 'clear',
sessionId: 'test-session-id',
},
})
);
});
it('should not clear a running session', async () => {
const onError = vi.fn();
const result = await sessionActionService.clearSession(mockSession, {
authClient: mockAuthClient,
callbacks: { onError },
});
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid session state');
expect(onError).toHaveBeenCalledWith('Cannot clear session: invalid state');
expect(mockTerminateSession).not.toHaveBeenCalled();
expect(window.dispatchEvent).not.toHaveBeenCalled();
});
it('should handle null session', async () => {
const onError = vi.fn();
const result = await sessionActionService.clearSession(null as unknown as Session, {
authClient: mockAuthClient,
callbacks: { onError },
});
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid session state');
expect(onError).toHaveBeenCalledWith('Cannot clear session: invalid state');
});
it('should handle clear failure', async () => {
const onError = vi.fn();
const onSuccess = vi.fn();
const exitedSession = { ...mockSession, status: 'exited' as const };
mockTerminateSession.mockResolvedValueOnce({
success: false,
error: 'Database error',
});
const result = await sessionActionService.clearSession(exitedSession, {
authClient: mockAuthClient,
callbacks: { onError, onSuccess },
});
expect(result.success).toBe(false);
expect(result.error).toBe('Database error');
expect(onError).toHaveBeenCalledWith('Failed to clear session: Database error');
expect(onSuccess).not.toHaveBeenCalled();
expect(window.dispatchEvent).not.toHaveBeenCalled();
});
it('should work without callbacks', async () => {
const exitedSession = { ...mockSession, status: 'exited' as const };
const result = await sessionActionService.clearSession(exitedSession, {
authClient: mockAuthClient,
});
expect(result.success).toBe(true);
expect(mockTerminateSession).toHaveBeenCalled();
expect(window.dispatchEvent).toHaveBeenCalled();
});
});
describe('deleteSession', () => {
it('should terminate running sessions', async () => {
const onSuccess = vi.fn();
const result = await sessionActionService.deleteSession(mockSession, {
authClient: mockAuthClient,
callbacks: { onSuccess },
});
expect(result.success).toBe(true);
expect(onSuccess).toHaveBeenCalledWith('terminate', 'test-session-id');
expect(mockTerminateSession).toHaveBeenCalledWith(
'test-session-id',
mockAuthClient,
'running'
);
});
it('should clear exited sessions', async () => {
const onSuccess = vi.fn();
const exitedSession = { ...mockSession, status: 'exited' as const };
const result = await sessionActionService.deleteSession(exitedSession, {
authClient: mockAuthClient,
callbacks: { onSuccess },
});
expect(result.success).toBe(true);
expect(onSuccess).toHaveBeenCalledWith('clear', 'test-session-id');
expect(mockTerminateSession).toHaveBeenCalledWith(
'test-session-id',
mockAuthClient,
'exited'
);
});
it('should handle unsupported session status', async () => {
const onError = vi.fn();
const pendingSession = {
...mockSession,
status: 'pending' as unknown as 'running' | 'exited',
};
const result = await sessionActionService.deleteSession(pendingSession, {
authClient: mockAuthClient,
callbacks: { onError },
});
expect(result.success).toBe(false);
expect(result.error).toBe('Cannot delete session with status: pending');
expect(onError).toHaveBeenCalledWith('Cannot delete session with status: pending');
expect(mockTerminateSession).not.toHaveBeenCalled();
});
it('should propagate errors from underlying methods', async () => {
const onError = vi.fn();
mockTerminateSession.mockResolvedValueOnce({
success: false,
error: 'Permission denied',
});
const result = await sessionActionService.deleteSession(mockSession, {
authClient: mockAuthClient,
callbacks: { onError },
});
expect(result.success).toBe(false);
expect(result.error).toBe('Permission denied');
expect(onError).toHaveBeenCalledWith('Failed to terminate session: Permission denied');
});
});
describe('deleteSessionById', () => {
it('should delete session by ID successfully', async () => {
const onSuccess = vi.fn();
// Mock fetch as Response-like object
const mockResponse = {
ok: true,
text: vi.fn().mockResolvedValue(''),
};
global.fetch = vi.fn().mockResolvedValue(mockResponse);
const result = await sessionActionService.deleteSessionById('test-id', {
authClient: mockAuthClient,
callbacks: { onSuccess },
});
expect(result.success).toBe(true);
expect(onSuccess).toHaveBeenCalledWith('terminate', 'test-id');
expect(fetch).toHaveBeenCalledWith('/api/sessions/test-id', {
method: 'DELETE',
headers: { Authorization: 'Bearer test-token' },
});
expect(window.dispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'session-action',
detail: {
action: 'delete',
sessionId: 'test-id',
},
})
);
});
it('should handle deletion errors', async () => {
const onError = vi.fn();
// Mock fetch to fail
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
text: async () => 'Not found',
});
const result = await sessionActionService.deleteSessionById('test-id', {
authClient: mockAuthClient,
callbacks: { onError },
});
expect(result.success).toBe(false);
expect(result.error).toBe('Delete failed: 404');
expect(onError).toHaveBeenCalledWith('Delete failed: 404');
expect(window.dispatchEvent).not.toHaveBeenCalled();
});
it('should handle network errors', async () => {
const onError = vi.fn();
// Mock fetch to throw
global.fetch = vi.fn().mockRejectedValue(new Error('Network failure'));
const result = await sessionActionService.deleteSessionById('test-id', {
authClient: mockAuthClient,
callbacks: { onError },
});
expect(result.success).toBe(false);
expect(result.error).toBe('Network failure');
expect(onError).toHaveBeenCalledWith('Network failure');
});
it('should handle non-Error exceptions', async () => {
const onError = vi.fn();
// Mock fetch to throw non-Error
global.fetch = vi.fn().mockRejectedValue('String error');
const result = await sessionActionService.deleteSessionById('test-id', {
authClient: mockAuthClient,
callbacks: { onError },
});
expect(result.success).toBe(false);
expect(result.error).toBe('Unknown error');
expect(onError).toHaveBeenCalledWith('Unknown error');
});
it('should work without callbacks', async () => {
const mockResponse = {
ok: true,
text: vi.fn().mockResolvedValue(''),
};
global.fetch = vi.fn().mockResolvedValue(mockResponse);
const result = await sessionActionService.deleteSessionById('test-id', {
authClient: mockAuthClient,
});
expect(result.success).toBe(true);
expect(fetch).toHaveBeenCalled();
expect(window.dispatchEvent).toHaveBeenCalled();
});
it('should handle empty session ID', async () => {
const onError = vi.fn();
// Even with empty ID, the method should attempt the call
// The server will handle validation
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
text: async () => 'Invalid session ID',
});
const result = await sessionActionService.deleteSessionById('', {
authClient: mockAuthClient,
callbacks: { onError },
});
expect(result.success).toBe(false);
expect(fetch).toHaveBeenCalledWith('/api/sessions/', expect.any(Object));
});
});
describe('event emission', () => {
it('should not emit events when window is undefined', async () => {
// Remove window.dispatchEvent mock temporarily
vi.mocked(window.dispatchEvent).mockRestore();
// Mock window as undefined temporarily
const originalWindow = global.window;
// @ts-expect-error - Testing edge case
delete global.window;
const result = await sessionActionService.terminateSession(mockSession, {
authClient: mockAuthClient,
});
expect(result.success).toBe(true);
// No error should be thrown even without window
// Restore window
global.window = originalWindow;
});
it('should emit custom events with correct detail structure', async () => {
const dispatchSpy = vi.mocked(window.dispatchEvent);
await sessionActionService.terminateSession(mockSession, {
authClient: mockAuthClient,
});
const eventCall = dispatchSpy.mock.calls[0];
const event = eventCall[0] as CustomEvent;
expect(event).toBeInstanceOf(CustomEvent);
expect(event.type).toBe('session-action');
expect(event.detail).toEqual({
action: 'terminate',
sessionId: 'test-session-id',
});
});
});
describe('edge cases and error handling', () => {
it('should handle sessions with missing properties gracefully', async () => {
const incompleteSession = {
id: 'test-id',
status: 'running',
} as Session;
const result = await sessionActionService.terminateSession(incompleteSession, {
authClient: mockAuthClient,
});
expect(result.success).toBe(true);
expect(mockTerminateSession).toHaveBeenCalledWith('test-id', mockAuthClient, 'running');
});
it('should handle concurrent operations', async () => {
const onSuccess = vi.fn();
// Start multiple operations concurrently
const operations = [
sessionActionService.terminateSession(mockSession, {
authClient: mockAuthClient,
callbacks: { onSuccess },
}),
sessionActionService.terminateSession(
{ ...mockSession, id: 'session-2' },
{
authClient: mockAuthClient,
callbacks: { onSuccess },
}
),
];
const results = await Promise.all(operations);
expect(results).toHaveLength(2);
expect(results.every((r) => r.success)).toBe(true);
expect(onSuccess).toHaveBeenCalledTimes(2);
expect(onSuccess).toHaveBeenCalledWith('terminate', 'test-session-id');
expect(onSuccess).toHaveBeenCalledWith('terminate', 'session-2');
});
});
});

View file

@ -0,0 +1,382 @@
/**
* Session Action Service
*
* A singleton service that manages session actions like terminate and clear,
* coordinating with the auth client and handling UI updates through callbacks.
* Reusable across session-view, session-list, and session-card components.
*
* @remarks
* This service provides a unified interface for session management operations,
* emitting global events that other components can listen to for reactive updates.
*
* @example
* ```typescript
* // Get the singleton instance
* const service = sessionActionService;
*
* // Terminate a running session
* const result = await service.terminateSession(session, {
* authClient,
* callbacks: {
* onSuccess: (action, sessionId) => console.log(`${action} successful`),
* onError: (message) => console.error(message)
* }
* });
*
* // Listen for session actions globally
* window.addEventListener('session-action', (event) => {
* console.log(event.detail.action, event.detail.sessionId);
* });
* ```
*/
import type { Session } from '../../shared/types.js';
import { createLogger } from '../utils/logger.js';
import type { SessionActionResult } from '../utils/session-actions.js';
import { terminateSession as terminateSessionUtil } from '../utils/session-actions.js';
import type { AuthClient } from './auth-client.js';
const logger = createLogger('session-action-service');
/**
* Callback functions for session action results
*
* @interface SessionActionCallbacks
*/
export interface SessionActionCallbacks {
/**
* Called when an error occurs during a session action
* @param message - Human-readable error message
*/
onError?: (message: string) => void;
/**
* Called when a session action completes successfully
* @param action - The action that was performed ('terminate' or 'clear')
* @param sessionId - The ID of the affected session
*/
onSuccess?: (action: 'terminate' | 'clear', sessionId: string) => void;
}
/**
* Options for session action operations
*
* @interface SessionActionOptions
*/
export interface SessionActionOptions {
/**
* AuthClient instance for API authentication
*/
authClient: AuthClient;
/**
* Optional callbacks for handling action results
*/
callbacks?: SessionActionCallbacks;
}
/**
* Singleton service for managing session lifecycle actions
*
* @class SessionActionService
* @singleton
*/
class SessionActionService {
private static instance: SessionActionService;
/**
* Private constructor to enforce singleton pattern
* @private
*/
private constructor() {
logger.log('SessionActionService initialized');
}
/**
* Gets the singleton instance of SessionActionService
*
* @returns {SessionActionService} The singleton instance
* @static
*
* @example
* ```typescript
* const service = SessionActionService.getInstance();
* // or use the exported instance
* import { sessionActionService } from './session-action-service.js';
* ```
*/
static getInstance(): SessionActionService {
if (!SessionActionService.instance) {
SessionActionService.instance = new SessionActionService();
}
return SessionActionService.instance;
}
/**
* Terminates a running session
*
* @param {Session} session - The session to terminate (must have status 'running')
* @param {SessionActionOptions} options - Options including auth client and callbacks
* @returns {Promise<SessionActionResult>} Result indicating success or failure
*
* @remarks
* - Only works on sessions with status 'running'
* - Emits a 'session-action' event on window for global listeners
* - Calls onSuccess callback with ('terminate', sessionId) on success
* - Calls onError callback with error message on failure
*
* @example
* ```typescript
* const result = await service.terminateSession(runningSession, {
* authClient: myAuthClient,
* callbacks: {
* onSuccess: (action, id) => console.log(`Session ${id} terminated`),
* onError: (msg) => alert(msg)
* }
* });
*
* if (result.success) {
* console.log('Termination successful');
* } else {
* console.error('Failed:', result.error);
* }
* ```
*/
async terminateSession(
session: Session,
options: SessionActionOptions
): Promise<SessionActionResult> {
if (!session || session.status !== 'running') {
logger.warn('Cannot terminate session: invalid state', { session });
options.callbacks?.onError?.('Cannot terminate session: invalid state');
return { success: false, error: 'Invalid session state' };
}
logger.debug('Terminating session', { sessionId: session.id });
const result = await terminateSessionUtil(session.id, options.authClient, 'running');
if (!result.success) {
const errorMessage = `Failed to terminate session: ${result.error}`;
logger.error(errorMessage, { sessionId: session.id, error: result.error });
options.callbacks?.onError?.(errorMessage);
} else {
logger.log('Session terminated successfully', { sessionId: session.id });
options.callbacks?.onSuccess?.('terminate', session.id);
// Emit global event (only in browser environment) for other components to react (only in browser environment)
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('session-action', {
detail: {
action: 'terminate',
sessionId: session.id,
},
})
);
}
}
return result;
}
/**
* Clears an exited session from the system
*
* @param {Session} session - The session to clear (must have status 'exited')
* @param {SessionActionOptions} options - Options including auth client and callbacks
* @returns {Promise<SessionActionResult>} Result indicating success or failure
*
* @remarks
* - Only works on sessions with status 'exited'
* - Removes the session record from the server
* - Emits a 'session-action' event with action 'clear'
* - Useful for cleaning up terminated sessions from the UI
*
* @example
* ```typescript
* const exitedSession = { ...session, status: 'exited' };
* const result = await service.clearSession(exitedSession, {
* authClient,
* callbacks: {
* onSuccess: (action, id) => removeFromUI(id),
* onError: (msg) => showError(msg)
* }
* });
* ```
*/
async clearSession(
session: Session,
options: SessionActionOptions
): Promise<SessionActionResult> {
if (!session || session.status !== 'exited') {
logger.warn('Cannot clear session: invalid state', { session });
options.callbacks?.onError?.('Cannot clear session: invalid state');
return { success: false, error: 'Invalid session state' };
}
logger.debug('Clearing session', { sessionId: session.id });
const result = await terminateSessionUtil(session.id, options.authClient, 'exited');
if (!result.success) {
const errorMessage = `Failed to clear session: ${result.error}`;
logger.error(errorMessage, { sessionId: session.id, error: result.error });
options.callbacks?.onError?.(errorMessage);
} else {
logger.log('Session cleared successfully', { sessionId: session.id });
options.callbacks?.onSuccess?.('clear', session.id);
// Emit global event for other components to react (only in browser environment)
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('session-action', {
detail: {
action: 'clear',
sessionId: session.id,
},
})
);
}
}
return result;
}
/**
* Deletes a session regardless of its status
*
* @param {Session} session - The session to delete
* @param {SessionActionOptions} options - Options including auth client and callbacks
* @returns {Promise<SessionActionResult>} Result indicating success or failure
*
* @remarks
* This is a unified method that intelligently handles different session states:
* - For 'running' sessions: calls terminateSession()
* - For 'exited' sessions: calls clearSession()
* - For other statuses: returns an error
*
* This method is useful when you want to remove a session without
* checking its status first.
*
* @example
* ```typescript
* // Delete any session without checking status
* const result = await service.deleteSession(session, {
* authClient,
* callbacks: {
* onSuccess: (action, id) => {
* console.log(`Session ${id} deleted via ${action}`);
* removeFromSessionList(id);
* }
* }
* });
* ```
*/
async deleteSession(
session: Session,
options: SessionActionOptions
): Promise<SessionActionResult> {
if (session.status === 'running') {
return this.terminateSession(session, options);
} else if (session.status === 'exited') {
return this.clearSession(session, options);
} else {
const errorMessage = `Cannot delete session with status: ${session.status}`;
logger.warn(errorMessage, { session });
options.callbacks?.onError?.(errorMessage);
return { success: false, error: errorMessage };
}
}
/**
* Deletes a session by ID without requiring the full session object
*
* @param {string} sessionId - The ID of the session to delete
* @param {SessionActionOptions} options - Options including auth client and callbacks
* @returns {Promise<SessionActionResult>} Result indicating success or failure
*
* @remarks
* This method makes a direct DELETE API call to /api/sessions/:id
* without needing to know the session's current status. Useful when:
* - You only have the session ID (e.g., from a URL parameter)
* - The session object is not readily available
* - You want to force deletion regardless of client-side state
*
* The server will handle the deletion appropriately based on the
* session's actual status.
*
* @throws {Error} Throws if the API request fails
*
* @example
* ```typescript
* // Delete by ID from URL parameter
* const sessionId = new URLSearchParams(location.search).get('session');
* if (sessionId) {
* const result = await service.deleteSessionById(sessionId, {
* authClient,
* callbacks: {
* onSuccess: () => navigate('/sessions'),
* onError: (msg) => showNotification(msg)
* }
* });
* }
* ```
*/
async deleteSessionById(
sessionId: string,
options: SessionActionOptions
): Promise<SessionActionResult> {
try {
const response = await fetch(`/api/sessions/${sessionId}`, {
method: 'DELETE',
headers: {
...options.authClient.getAuthHeader(),
},
});
if (!response.ok) {
const errorData = await response.text();
logger.error('Failed to delete session', { errorData, sessionId });
throw new Error(`Delete failed: ${response.status}`);
}
logger.log('Session deleted successfully', { sessionId });
options.callbacks?.onSuccess?.('terminate', sessionId);
// Emit global event (only in browser environment)
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('session-action', {
detail: {
action: 'delete',
sessionId,
},
})
);
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error deleting session', { error, sessionId });
options.callbacks?.onError?.(errorMessage);
return { success: false, error: errorMessage };
}
}
}
/**
* Global singleton instance of SessionActionService
*
* @remarks
* Use this exported instance instead of calling getInstance() directly.
* This ensures consistent usage across the application.
*
* @example
* ```typescript
* import { sessionActionService } from './services/session-action-service.js';
*
* // Use in components
* await sessionActionService.terminateSession(session, options);
* ```
*/
export const sessionActionService = SessionActionService.getInstance();

View file

@ -0,0 +1,88 @@
/**
* Session Action Utilities
*
* Provides common session operations like termination and cleanup
* that can be reused across components.
*/
import type { AuthClient } from '../services/auth-client.js';
import { createLogger } from './logger.js';
const logger = createLogger('session-actions');
export interface SessionActionResult {
success: boolean;
error?: string;
}
/**
* Terminates a running session or cleans up an exited session
* @param sessionId - The ID of the session to terminate/cleanup
* @param authClient - The auth client for authentication headers
* @param status - The current status of the session
* @returns Result indicating success or failure with error message
*/
export async function terminateSession(
sessionId: string,
authClient: AuthClient,
status: 'running' | 'exited'
): Promise<SessionActionResult> {
const action = status === 'exited' ? 'cleanup' : 'terminate';
try {
const response = await fetch(`/api/sessions/${sessionId}`, {
method: 'DELETE',
headers: {
...authClient.getAuthHeader(),
},
});
if (!response.ok) {
const errorData = await response.text();
logger.error(`Failed to ${action} session`, { errorData, sessionId });
throw new Error(`${action} failed: ${response.status}`);
}
logger.debug(`Session ${action} successful`, { sessionId });
return { success: true };
} catch (error) {
logger.error(`Failed to ${action} session:`, error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Cleans up all exited sessions
* @param authClient - The auth client for authentication headers
* @returns Result indicating success or failure with error message
*/
export async function cleanupAllExitedSessions(
authClient: AuthClient
): Promise<SessionActionResult> {
try {
const response = await fetch('/api/cleanup-exited', {
method: 'POST',
headers: {
...authClient.getAuthHeader(),
},
});
if (!response.ok) {
const errorData = await response.text();
logger.error('Failed to cleanup exited sessions', { errorData });
throw new Error(`Cleanup failed: ${response.status}`);
}
logger.debug('Exited sessions cleanup successful');
return { success: true };
} catch (error) {
logger.error('Failed to cleanup exited sessions:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}

View file

@ -0,0 +1,275 @@
import type { Express } from 'express';
import express from 'express';
import request from 'supertest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { QuickStartCommand, VibeTunnelConfig } from '../../types/config.js';
import type { ConfigService } from '../services/config-service.js';
import { createConfigRoutes } from './config.js';
describe('Config Routes', () => {
let app: Express;
let mockConfigService: ConfigService;
const defaultConfig: VibeTunnelConfig = {
version: 1,
repositoryBasePath: '/home/user/repos',
quickStartCommands: [
{ name: '✨ claude', command: 'claude' },
{ command: 'zsh' },
{ name: '▶️ pnpm run dev', command: 'pnpm run dev' },
],
};
beforeEach(() => {
app = express();
app.use(express.json());
// Mock config service
mockConfigService = {
getConfig: vi.fn(() => defaultConfig),
updateQuickStartCommands: vi.fn(),
updateRepositoryBasePath: vi.fn(),
updateConfig: vi.fn(),
startWatching: vi.fn(),
stopWatching: vi.fn(),
onConfigChange: vi.fn(),
getConfigPath: vi.fn(() => '/home/user/.vibetunnel/config.json'),
} as unknown as ConfigService;
// Create routes
const configRoutes = createConfigRoutes({
configService: mockConfigService,
});
app.use('/api', configRoutes);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('GET /api/config', () => {
it('should return application configuration', async () => {
const response = await request(app).get('/api/config');
expect(response.status).toBe(200);
expect(response.body).toEqual({
repositoryBasePath: '/home/user/repos',
serverConfigured: true,
quickStartCommands: defaultConfig.quickStartCommands,
});
expect(mockConfigService.getConfig).toHaveBeenCalledOnce();
});
it('should use default repository path when not configured', async () => {
mockConfigService.getConfig = vi.fn(() => ({
...defaultConfig,
repositoryBasePath: null,
}));
const response = await request(app).get('/api/config');
expect(response.status).toBe(200);
expect(response.body).toEqual({
repositoryBasePath: '~/',
serverConfigured: true,
quickStartCommands: defaultConfig.quickStartCommands,
});
});
it('should handle config service errors', async () => {
mockConfigService.getConfig = vi.fn(() => {
throw new Error('Config read error');
});
const response = await request(app).get('/api/config');
expect(response.status).toBe(500);
expect(response.body).toEqual({
error: 'Failed to get app config',
});
});
});
describe('PUT /api/config', () => {
it('should update quick start commands', async () => {
const newCommands: QuickStartCommand[] = [
{ command: 'python3' },
{ name: '🚀 node', command: 'node' },
];
const response = await request(app)
.put('/api/config')
.send({ quickStartCommands: newCommands });
expect(response.status).toBe(200);
expect(response.body).toEqual({
success: true,
quickStartCommands: newCommands,
});
expect(mockConfigService.updateQuickStartCommands).toHaveBeenCalledWith(newCommands);
});
it('should filter out empty commands', async () => {
const commandsWithEmpty: QuickStartCommand[] = [
{ command: 'python3' },
{ command: '' }, // Empty command
{ name: 'Empty', command: ' ' }, // Whitespace only
{ name: '🚀 node', command: 'node' },
];
const expectedFiltered: QuickStartCommand[] = [
{ command: 'python3' },
{ name: '🚀 node', command: 'node' },
];
const response = await request(app)
.put('/api/config')
.send({ quickStartCommands: commandsWithEmpty });
expect(response.status).toBe(200);
expect(response.body).toEqual({
success: true,
quickStartCommands: expectedFiltered,
});
expect(mockConfigService.updateQuickStartCommands).toHaveBeenCalledWith(expectedFiltered);
});
it('should validate command structure', async () => {
const invalidCommands = [
{ command: 'valid' },
{ notCommand: 'invalid' }, // Missing command field
null, // Null entry
{ command: 123 }, // Invalid type
];
const expectedValid = [{ command: 'valid' }];
const response = await request(app)
.put('/api/config')
.send({ quickStartCommands: invalidCommands });
expect(response.status).toBe(200);
expect(response.body).toEqual({
success: true,
quickStartCommands: expectedValid,
});
expect(mockConfigService.updateQuickStartCommands).toHaveBeenCalledWith(expectedValid);
});
it('should return 400 for missing quickStartCommands', async () => {
const response = await request(app).put('/api/config').send({});
expect(response.status).toBe(400);
expect(response.body).toEqual({
error: 'No valid updates provided',
});
expect(mockConfigService.updateQuickStartCommands).not.toHaveBeenCalled();
});
it('should return 400 for non-array quickStartCommands', async () => {
const response = await request(app)
.put('/api/config')
.send({ quickStartCommands: 'not-an-array' });
expect(response.status).toBe(400);
expect(response.body).toEqual({
error: 'No valid updates provided',
});
expect(mockConfigService.updateQuickStartCommands).not.toHaveBeenCalled();
});
it('should handle config service update errors', async () => {
mockConfigService.updateQuickStartCommands = vi.fn(() => {
throw new Error('Write error');
});
const response = await request(app)
.put('/api/config')
.send({ quickStartCommands: [{ command: 'test' }] });
expect(response.status).toBe(500);
expect(response.body).toEqual({
error: 'Failed to update config',
});
});
it('should allow empty array of commands', async () => {
const response = await request(app).put('/api/config').send({ quickStartCommands: [] });
expect(response.status).toBe(200);
expect(response.body).toEqual({
success: true,
quickStartCommands: [],
});
expect(mockConfigService.updateQuickStartCommands).toHaveBeenCalledWith([]);
});
it('should preserve optional name field', async () => {
const commandsWithNames: QuickStartCommand[] = [
{ name: 'Python REPL', command: 'python3' },
{ command: 'node' }, // No name
{ name: undefined, command: 'bash' }, // Explicitly undefined
];
const response = await request(app)
.put('/api/config')
.send({ quickStartCommands: commandsWithNames });
expect(response.status).toBe(200);
expect(mockConfigService.updateQuickStartCommands).toHaveBeenCalledWith(commandsWithNames);
});
it('should update repository base path', async () => {
const newPath = '/new/repo/path';
const response = await request(app).put('/api/config').send({ repositoryBasePath: newPath });
expect(response.status).toBe(200);
expect(response.body).toEqual({
success: true,
repositoryBasePath: newPath,
});
expect(mockConfigService.updateRepositoryBasePath).toHaveBeenCalledWith(newPath);
});
it('should update both repository base path and quick start commands', async () => {
const newPath = '/new/repo/path';
const newCommands = [{ command: 'test' }];
const response = await request(app).put('/api/config').send({
repositoryBasePath: newPath,
quickStartCommands: newCommands,
});
expect(response.status).toBe(200);
expect(response.body).toEqual({
success: true,
repositoryBasePath: newPath,
quickStartCommands: newCommands,
});
expect(mockConfigService.updateRepositoryBasePath).toHaveBeenCalledWith(newPath);
expect(mockConfigService.updateQuickStartCommands).toHaveBeenCalledWith(newCommands);
});
it('should reject invalid repository base path', async () => {
const response = await request(app).put('/api/config').send({ repositoryBasePath: 123 }); // Not a string
expect(response.status).toBe(400);
expect(response.body).toEqual({
error: 'No valid updates provided',
});
expect(mockConfigService.updateRepositoryBasePath).not.toHaveBeenCalled();
});
});
});

View file

@ -1,4 +1,6 @@
import { Router } from 'express';
import type { QuickStartCommand } from '../../types/config.js';
import type { ConfigService } from '../services/config-service.js';
import { createLogger } from '../utils/logger.js';
const logger = createLogger('config');
@ -6,10 +8,11 @@ const logger = createLogger('config');
export interface AppConfig {
repositoryBasePath: string;
serverConfigured?: boolean;
quickStartCommands?: QuickStartCommand[];
}
interface ConfigRouteOptions {
getRepositoryBasePath: () => string | null;
configService: ConfigService;
}
/**
@ -17,7 +20,7 @@ interface ConfigRouteOptions {
*/
export function createConfigRoutes(options: ConfigRouteOptions): Router {
const router = Router();
const { getRepositoryBasePath } = options;
const { configService } = options;
/**
* Get application configuration
@ -25,10 +28,13 @@ export function createConfigRoutes(options: ConfigRouteOptions): Router {
*/
router.get('/config', (_req, res) => {
try {
const repositoryBasePath = getRepositoryBasePath();
const vibeTunnelConfig = configService.getConfig();
const repositoryBasePath = vibeTunnelConfig.repositoryBasePath || '~/';
const config: AppConfig = {
repositoryBasePath: repositoryBasePath || '~/',
serverConfigured: repositoryBasePath !== null,
repositoryBasePath: repositoryBasePath,
serverConfigured: true, // Always configured when server is running
quickStartCommands: vibeTunnelConfig.quickStartCommands,
};
logger.debug('[GET /api/config] Returning app config:', config);
@ -39,5 +45,44 @@ export function createConfigRoutes(options: ConfigRouteOptions): Router {
}
});
/**
* Update application configuration
* PUT /api/config
*/
router.put('/config', (req, res) => {
try {
const { quickStartCommands, repositoryBasePath } = req.body;
const updates: { [key: string]: unknown } = {};
if (quickStartCommands && Array.isArray(quickStartCommands)) {
// Validate commands
const validCommands = quickStartCommands.filter(
(cmd: QuickStartCommand) => cmd && typeof cmd.command === 'string' && cmd.command.trim()
);
// Update config
configService.updateQuickStartCommands(validCommands);
updates.quickStartCommands = validCommands;
logger.debug('[PUT /api/config] Updated quick start commands:', validCommands);
}
if (repositoryBasePath && typeof repositoryBasePath === 'string') {
// Update repository base path
configService.updateRepositoryBasePath(repositoryBasePath);
updates.repositoryBasePath = repositoryBasePath;
logger.debug('[PUT /api/config] Updated repository base path:', repositoryBasePath);
}
if (Object.keys(updates).length > 0) {
res.json({ success: true, ...updates });
} else {
res.status(400).json({ error: 'No valid updates provided' });
}
} catch (error) {
logger.error('[PUT /api/config] Error updating config:', error);
res.status(500).json({ error: 'Failed to update config' });
}
});
return router;
}

View file

@ -9,7 +9,7 @@ import { createServer } from 'http';
import * as os from 'os';
import * as path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { WebSocket, WebSocketServer } from 'ws';
import { WebSocketServer } from 'ws';
import type { AuthenticatedRequest } from './middleware/auth.js';
import { createAuthMiddleware } from './middleware/auth.js';
import { PtyManager } from './pty/index.js';
@ -27,6 +27,7 @@ import { ActivityMonitor } from './services/activity-monitor.js';
import { AuthService } from './services/auth-service.js';
import { BellEventHandler } from './services/bell-event-handler.js';
import { BufferAggregator } from './services/buffer-aggregator.js';
import { ConfigService } from './services/config-service.js';
import { ControlDirWatcher } from './services/control-dir-watcher.js';
import { HQClient } from './services/hq-client.js';
import { mdnsService } from './services/mdns-service.js';
@ -87,8 +88,6 @@ interface Config {
noHqAuth: boolean;
// mDNS advertisement
enableMDNS: boolean;
// Repository configuration
repositoryBasePath: string | null;
}
// Show help message
@ -108,7 +107,6 @@ Options:
--no-auth Disable authentication (auto-login as current user)
--allow-local-bypass Allow localhost connections to bypass authentication
--local-auth-token <token> Token for localhost authentication bypass
--repository-base-path <path> Base path for repository discovery (default: ~/)
--debug Enable debug logging
Push Notification Options:
@ -183,8 +181,6 @@ function parseArgs(): Config {
noHqAuth: false,
// mDNS advertisement
enableMDNS: true, // Enable mDNS by default
// Repository configuration
repositoryBasePath: null as string | null,
};
// Check for help flag first
@ -250,9 +246,6 @@ function parseArgs(): Config {
config.noHqAuth = true;
} else if (args[i] === '--no-mdns') {
config.enableMDNS = false;
} else if (args[i] === '--repository-base-path' && i + 1 < args.length) {
config.repositoryBasePath = args[i + 1];
i++; // Skip the path value in next iteration
} else if (args[i].startsWith('--')) {
// Unknown argument
logger.error(`Unknown argument: ${args[i]}`);
@ -336,6 +329,7 @@ interface AppInstance {
wss: WebSocketServer;
startServer: () => void;
config: Config;
configService: ConfigService;
ptyManager: PtyManager;
terminalManager: TerminalManager;
streamWatcher: StreamWatcher;
@ -462,6 +456,11 @@ export async function createApp(): Promise<AppInstance> {
const activityMonitor = new ActivityMonitor(CONTROL_DIR);
logger.debug('Initialized activity monitor');
// Initialize configuration service
const configService = new ConfigService();
configService.startWatching();
logger.debug('Initialized configuration service');
// Initialize push notification services
let vapidManager: VapidManager | null = null;
let pushNotificationService: PushNotificationService | null = null;
@ -747,7 +746,7 @@ export async function createApp(): Promise<AppInstance> {
app.use(
'/api',
createConfigRoutes({
getRepositoryBasePath: () => config.repositoryBasePath,
configService,
})
);
logger.debug('Mounted config routes');
@ -767,29 +766,6 @@ export async function createApp(): Promise<AppInstance> {
// Initialize control socket
try {
// Set up configuration update callback
controlUnixHandler.setConfigUpdateCallback((updatedConfig) => {
// Update server configuration
config.repositoryBasePath = updatedConfig.repositoryBasePath;
// Broadcast to all connected config WebSocket clients
const message = JSON.stringify({
type: 'config',
data: {
repositoryBasePath: updatedConfig.repositoryBasePath,
serverConfigured: true, // Path from Mac app is always server-configured
},
});
configWebSocketClients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
logger.log(`Broadcast config update to ${configWebSocketClients.size} clients`);
});
await controlUnixHandler.start();
logger.log(chalk.green('Control UNIX socket: READY'));
} catch (error) {
@ -805,11 +781,7 @@ export async function createApp(): Promise<AppInstance> {
const parsedUrl = new URL(request.url || '', `http://${request.headers.host || 'localhost'}`);
// Handle WebSocket paths
if (
parsedUrl.pathname !== '/buffers' &&
parsedUrl.pathname !== '/ws/input' &&
parsedUrl.pathname !== '/ws/config'
) {
if (parsedUrl.pathname !== '/buffers' && parsedUrl.pathname !== '/ws/input') {
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
socket.destroy();
return;
@ -928,9 +900,6 @@ export async function createApp(): Promise<AppInstance> {
});
});
// Store connected config WebSocket clients
const configWebSocketClients = new Set<WebSocket>();
// WebSocket connection router
wss.on('connection', (ws, req) => {
const wsReq = req as WebSocketRequest;
@ -965,72 +934,6 @@ export async function createApp(): Promise<AppInstance> {
const userId = wsReq.userId || 'unknown';
websocketInputHandler.handleConnection(ws, sessionId, userId);
} else if (pathname === '/ws/config') {
logger.log('⚙️ Handling config WebSocket connection');
// Add client to the set
configWebSocketClients.add(ws);
// Send current configuration
ws.send(
JSON.stringify({
type: 'config',
data: {
repositoryBasePath: config.repositoryBasePath || '~/',
serverConfigured: config.repositoryBasePath !== null,
},
})
);
// Handle incoming messages from web client
ws.on('message', async (data) => {
try {
const message = JSON.parse(data.toString());
if (message.type === 'update-repository-path') {
const newPath = message.path;
logger.log(`Received repository path update from web: ${newPath}`);
// Forward to Mac app via Unix socket if available
if (controlUnixHandler) {
const controlMessage = {
id: uuidv4(),
type: 'request' as const,
category: 'system' as const,
action: 'repository-path-update',
payload: { path: newPath, source: 'web' },
};
// Send to Mac and wait for response
const response = await controlUnixHandler.sendControlMessage(controlMessage);
if (response && response.type === 'response') {
const payload = response.payload as { success?: boolean };
if (payload?.success) {
logger.log(`Mac app confirmed repository path update: ${newPath}`);
// The update will be broadcast back via the config update callback
} else {
logger.error('Mac app failed to update repository path');
}
} else {
logger.error('No response from Mac app for repository path update');
}
} else {
logger.warn('No control Unix handler available, cannot forward path update to Mac');
}
}
} catch (error) {
logger.error('Failed to handle config WebSocket message:', error);
}
});
// Handle client disconnection
ws.on('close', () => {
configWebSocketClients.delete(ws);
logger.log('Config WebSocket client disconnected');
});
ws.on('error', (error) => {
logger.error('Config WebSocket error:', error);
configWebSocketClients.delete(ws);
});
} else {
logger.error(`❌ Unknown WebSocket path: ${pathname}`);
ws.close();
@ -1202,6 +1105,7 @@ export async function createApp(): Promise<AppInstance> {
wss,
startServer,
config,
configService,
ptyManager,
terminalManager,
streamWatcher,
@ -1242,6 +1146,7 @@ export async function startVibeTunnelServer() {
controlDirWatcher,
activityMonitor,
config,
configService,
} = appInstance;
// Update debug mode based on config or environment variable
@ -1299,6 +1204,9 @@ export async function startVibeTunnelServer() {
// Stop activity monitor
activityMonitor.stop();
logger.debug('Stopped activity monitor');
// Stop configuration service watcher
configService.stopWatching();
logger.debug('Stopped configuration service watcher');
// Stop mDNS advertisement if it was started
if (mdnsService.isActive()) {

View file

@ -0,0 +1,422 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
DEFAULT_CONFIG,
type QuickStartCommand,
type VibeTunnelConfig,
} from '../../types/config.js';
import { ConfigService } from './config-service.js';
// Mock modules
vi.mock('fs');
vi.mock('os');
vi.mock('chokidar', () => ({
watch: vi.fn(() => ({
on: vi.fn(),
close: vi.fn(() => Promise.resolve()),
})),
}));
// Mock logger to avoid path issues
vi.mock('../utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
describe('ConfigService', () => {
let configService: ConfigService;
const mockHomeDir = '/home/testuser';
const mockConfigDir = path.join(mockHomeDir, '.vibetunnel');
const mockConfigPath = path.join(mockConfigDir, 'config.json');
beforeEach(() => {
vi.clearAllMocks();
// Mock os.homedir
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
// Mock fs methods
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
vi.mocked(fs.readFileSync).mockImplementation(() => JSON.stringify(DEFAULT_CONFIG));
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
configService = new ConfigService();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('constructor and initialization', () => {
it('should create config directory if it does not exist', () => {
expect(vi.mocked(fs.existsSync)).toHaveBeenCalledWith(mockConfigDir);
expect(vi.mocked(fs.mkdirSync)).toHaveBeenCalledWith(mockConfigDir, { recursive: true });
});
it('should load existing config file', () => {
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === mockConfigDir) return true;
if (p === mockConfigPath) return true;
return false;
});
const customConfig: VibeTunnelConfig = {
version: 1,
quickStartCommands: [{ command: 'custom' }],
};
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(customConfig));
const service = new ConfigService();
expect(service.getConfig()).toEqual(customConfig);
});
it('should create default config if file does not exist', () => {
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === mockConfigDir) return true;
if (p === mockConfigPath) return false;
return false;
});
new ConfigService();
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
mockConfigPath,
JSON.stringify(DEFAULT_CONFIG, null, 2),
'utf8'
);
});
it('should validate config and use defaults on invalid structure', () => {
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === mockConfigDir) return true;
if (p === mockConfigPath) return true;
return false;
});
// Invalid config - missing version
const invalidConfig = {
quickStartCommands: [{ command: 'test' }],
};
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(invalidConfig));
const service = new ConfigService();
// Should fall back to defaults and save them
expect(service.getConfig()).toEqual(DEFAULT_CONFIG);
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
mockConfigPath,
JSON.stringify(DEFAULT_CONFIG, null, 2),
'utf8'
);
});
it('should validate config and reject empty commands', () => {
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === mockConfigDir) return true;
if (p === mockConfigPath) return true;
return false;
});
// Invalid config - empty command
const invalidConfig: VibeTunnelConfig = {
version: 1,
quickStartCommands: [{ command: '' }],
};
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(invalidConfig));
const service = new ConfigService();
// Should fall back to defaults
expect(service.getConfig()).toEqual(DEFAULT_CONFIG);
});
});
describe('updateQuickStartCommands', () => {
it('should update quick start commands and save', () => {
const newCommands: QuickStartCommand[] = [
{ command: 'python3' },
{ name: '🚀 node', command: 'node' },
];
configService.updateQuickStartCommands(newCommands);
expect(configService.getConfig().quickStartCommands).toEqual(newCommands);
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
mockConfigPath,
JSON.stringify({ ...DEFAULT_CONFIG, quickStartCommands: newCommands }, null, 2),
'utf8'
);
});
it('should notify config change callbacks', () => {
const callback = vi.fn();
configService.onConfigChange(callback);
const newCommands: QuickStartCommand[] = [{ command: 'test' }];
configService.updateQuickStartCommands(newCommands);
expect(callback).toHaveBeenCalledWith({
...DEFAULT_CONFIG,
quickStartCommands: newCommands,
});
});
it('should handle empty commands array', () => {
configService.updateQuickStartCommands([]);
expect(configService.getConfig().quickStartCommands).toEqual([]);
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalled();
});
it('should preserve other config properties', () => {
const initialConfig = configService.getConfig();
const newCommands: QuickStartCommand[] = [{ command: 'new' }];
configService.updateQuickStartCommands(newCommands);
const updatedConfig = configService.getConfig();
expect(updatedConfig.version).toEqual(initialConfig.version);
expect(updatedConfig.quickStartCommands).toEqual(newCommands);
});
it('should reject commands with empty strings', () => {
const invalidCommands: QuickStartCommand[] = [
{ command: 'valid' },
{ command: '' }, // Invalid empty command
];
expect(() => {
configService.updateQuickStartCommands(invalidCommands);
}).toThrow('Invalid config');
// Config should remain unchanged
expect(configService.getConfig()).toEqual(DEFAULT_CONFIG);
});
it('should accept commands with optional names', () => {
const commandsWithNames: QuickStartCommand[] = [
{ name: '✨ Special', command: 'special-cmd' },
{ command: 'no-name-cmd' }, // No name is valid
{ name: '', command: 'empty-name-cmd' }, // Empty name is valid
];
configService.updateQuickStartCommands(commandsWithNames);
expect(configService.getConfig().quickStartCommands).toEqual(commandsWithNames);
});
});
describe('config change notifications', () => {
it('should register and notify multiple callbacks', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
configService.onConfigChange(callback1);
configService.onConfigChange(callback2);
configService.updateQuickStartCommands([{ command: 'test' }]);
expect(callback1).toHaveBeenCalledOnce();
expect(callback2).toHaveBeenCalledOnce();
});
it('should allow unsubscribing from changes', () => {
const callback = vi.fn();
const unsubscribe = configService.onConfigChange(callback);
unsubscribe();
configService.updateQuickStartCommands([{ command: 'test' }]);
expect(callback).not.toHaveBeenCalled();
});
it('should handle callback errors gracefully', () => {
const errorCallback = vi.fn(() => {
throw new Error('Callback error');
});
const normalCallback = vi.fn();
configService.onConfigChange(errorCallback);
configService.onConfigChange(normalCallback);
// Should not throw
expect(() => {
configService.updateQuickStartCommands([{ command: 'test' }]);
}).not.toThrow();
expect(errorCallback).toHaveBeenCalled();
expect(normalCallback).toHaveBeenCalled();
});
});
describe('file system error handling', () => {
it('should handle directory creation errors', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(fs.mkdirSync).mockImplementation(() => {
throw new Error('Permission denied');
});
// Should not throw during construction
expect(() => new ConfigService()).not.toThrow();
});
it('should handle config save errors', () => {
vi.mocked(fs.writeFileSync).mockImplementation(() => {
throw new Error('Disk full');
});
// Should not throw
expect(() => {
configService.updateQuickStartCommands([{ command: 'test' }]);
}).not.toThrow();
// Config should still be updated in memory
expect(configService.getConfig().quickStartCommands).toEqual([{ command: 'test' }]);
});
it('should handle corrupted config file', () => {
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === mockConfigDir) return true;
if (p === mockConfigPath) return true;
return false;
});
vi.mocked(fs.readFileSync).mockReturnValue('{ invalid json');
const service = new ConfigService();
// Should fall back to defaults
expect(service.getConfig()).toEqual(DEFAULT_CONFIG);
});
});
describe('getConfigPath', () => {
it('should return the correct config path', () => {
expect(configService.getConfigPath()).toBe(mockConfigPath);
});
});
describe('updateConfig', () => {
it('should update entire config and validate', () => {
const newConfig: VibeTunnelConfig = {
version: 2,
quickStartCommands: [{ command: 'python3' }, { name: 'Node.js', command: 'node' }],
};
configService.updateConfig(newConfig);
expect(configService.getConfig()).toEqual(newConfig);
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
mockConfigPath,
JSON.stringify(newConfig, null, 2),
'utf8'
);
});
it('should reject invalid config structure', () => {
const invalidConfig = {
version: 'not-a-number', // Should be number
quickStartCommands: [{ command: 'test' }],
} as unknown as VibeTunnelConfig;
expect(() => {
configService.updateConfig(invalidConfig);
}).toThrow('Invalid config');
// Config should remain unchanged
expect(configService.getConfig()).toEqual(DEFAULT_CONFIG);
});
it('should reject config with invalid command structure', () => {
const invalidConfig = {
version: 1,
quickStartCommands: [
{ command: 'valid' },
{ notACommand: 'invalid' }, // Missing required 'command' field
],
} as unknown as VibeTunnelConfig;
expect(() => {
configService.updateConfig(invalidConfig);
}).toThrow('Invalid config');
});
it('should reject config with non-array quickStartCommands', () => {
const invalidConfig = {
version: 1,
quickStartCommands: 'not-an-array',
} as unknown as VibeTunnelConfig;
expect(() => {
configService.updateConfig(invalidConfig);
}).toThrow('Invalid config');
});
});
describe('validation edge cases', () => {
it('should handle config with extra properties', () => {
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === mockConfigDir) return true;
if (p === mockConfigPath) return true;
return false;
});
// Config with extra properties (should be stripped)
const configWithExtras = {
version: 1,
quickStartCommands: [{ command: 'test' }],
extraProperty: 'should be ignored',
};
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(configWithExtras));
const service = new ConfigService();
const config = service.getConfig();
// Should only have valid properties
expect(config).toEqual({
version: 1,
quickStartCommands: [{ command: 'test' }],
});
expect('extraProperty' in config).toBe(false);
});
it('should handle commands with extra properties', () => {
const commandsWithExtras = [
{
command: 'valid',
name: 'Valid Command',
extraProp: 'ignored', // Should be stripped
},
] as Array<QuickStartCommand & { extraProp: string }>;
configService.updateQuickStartCommands(commandsWithExtras);
const saved = configService.getConfig().quickStartCommands;
// Extra properties should be stripped
expect(saved).toEqual([{ command: 'valid', name: 'Valid Command' }]);
});
it('should handle very long command strings', () => {
const longCommand = 'a'.repeat(1000); // 1000 character command
const commands: QuickStartCommand[] = [{ command: longCommand }];
// Should accept long commands (no max length)
configService.updateQuickStartCommands(commands);
expect(configService.getConfig().quickStartCommands[0].command).toBe(longCommand);
});
it('should handle unicode in commands and names', () => {
const unicodeCommands: QuickStartCommand[] = [
{ name: '🚀 火箭', command: 'echo "Hello 世界"' },
{ name: 'émojis 😀', command: 'café' },
];
configService.updateQuickStartCommands(unicodeCommands);
expect(configService.getConfig().quickStartCommands).toEqual(unicodeCommands);
});
});
});

View file

@ -0,0 +1,191 @@
import type { FSWatcher } from 'chokidar';
import { watch } from 'chokidar';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { z } from 'zod';
import { DEFAULT_CONFIG, type VibeTunnelConfig } from '../../types/config.js';
import { createLogger } from '../utils/logger.js';
const logger = createLogger('config-service');
// Zod schema for config validation
const ConfigSchema = z.object({
version: z.number(),
quickStartCommands: z.array(
z.object({
name: z.string().optional(),
command: z.string().min(1, 'Command cannot be empty'),
})
),
repositoryBasePath: z.string().optional(),
});
export class ConfigService {
private configDir: string;
private configPath: string;
private config: VibeTunnelConfig = DEFAULT_CONFIG;
private watcher?: FSWatcher;
private configChangeCallbacks: Set<(config: VibeTunnelConfig) => void> = new Set();
constructor() {
this.configDir = path.join(os.homedir(), '.vibetunnel');
this.configPath = path.join(this.configDir, 'config.json');
this.loadConfig();
}
private ensureConfigDir(): void {
try {
if (!fs.existsSync(this.configDir)) {
fs.mkdirSync(this.configDir, { recursive: true });
logger.info(`Created config directory: ${this.configDir}`);
}
} catch (error) {
logger.error('Failed to create config directory:', error);
}
}
private validateConfig(data: unknown): VibeTunnelConfig {
try {
return ConfigSchema.parse(data);
} catch (error) {
if (error instanceof z.ZodError) {
logger.error('Config validation failed:', error.issues);
throw new Error(`Invalid config: ${error.issues.map((e) => e.message).join(', ')}`);
}
throw error;
}
}
private loadConfig(): void {
try {
this.ensureConfigDir();
if (fs.existsSync(this.configPath)) {
const data = fs.readFileSync(this.configPath, 'utf8');
const parsedData = JSON.parse(data);
try {
// Validate config using Zod schema
this.config = this.validateConfig(parsedData);
logger.info('Loaded and validated configuration from disk');
} catch (validationError) {
logger.warn('Config validation failed, using defaults:', validationError);
this.config = DEFAULT_CONFIG;
this.saveConfig(); // Save defaults to fix invalid config
}
} else {
logger.info('No config file found, creating with defaults');
this.saveConfig(); // Create config with defaults
}
} catch (error) {
logger.error('Failed to load config:', error);
// Keep using defaults
}
}
private saveConfig(): void {
try {
this.ensureConfigDir();
fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2), 'utf8');
logger.info('Saved configuration to disk');
} catch (error) {
logger.error('Failed to save config:', error);
}
}
public startWatching(): void {
if (this.watcher) {
return; // Already watching
}
try {
this.watcher = watch(this.configPath, {
persistent: true,
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: 500,
pollInterval: 100,
},
});
this.watcher.on('change', () => {
logger.info('Configuration file changed, reloading...');
const oldConfig = JSON.stringify(this.config);
this.loadConfig();
// Only notify if config actually changed
if (JSON.stringify(this.config) !== oldConfig) {
this.notifyConfigChange();
}
});
this.watcher.on('error', (error) => {
logger.error('Config watcher error:', error);
});
logger.info('Started watching configuration file');
} catch (error) {
logger.error('Failed to start config watcher:', error);
}
}
public stopWatching(): void {
if (this.watcher) {
this.watcher.close().catch((error) => {
logger.error('Error closing config watcher:', error);
});
this.watcher = undefined;
logger.info('Stopped watching configuration file');
}
}
private notifyConfigChange(): void {
for (const callback of this.configChangeCallbacks) {
try {
callback(this.config);
} catch (error) {
logger.error('Error in config change callback:', error);
}
}
}
public onConfigChange(callback: (config: VibeTunnelConfig) => void): () => void {
this.configChangeCallbacks.add(callback);
// Return unsubscribe function
return () => {
this.configChangeCallbacks.delete(callback);
};
}
public getConfig(): VibeTunnelConfig {
return this.config;
}
public updateConfig(config: VibeTunnelConfig): void {
// Validate the config before updating
this.config = this.validateConfig(config);
this.saveConfig();
this.notifyConfigChange();
}
public updateQuickStartCommands(commands: VibeTunnelConfig['quickStartCommands']): void {
// Validate the entire config with updated commands
const updatedConfig = { ...this.config, quickStartCommands: commands };
this.config = this.validateConfig(updatedConfig);
this.saveConfig();
this.notifyConfigChange();
}
public updateRepositoryBasePath(path: string): void {
// Validate the entire config with updated repository base path
const updatedConfig = { ...this.config, repositoryBasePath: path };
this.config = this.validateConfig(updatedConfig);
this.saveConfig();
this.notifyConfigChange();
}
public getConfigPath(): string {
return this.configPath;
}
}

View file

@ -80,37 +80,6 @@ class SystemHandler implements MessageHandler {
logger.log(`System handler: ${message.action}, type: ${message.type}, id: ${message.id}`);
switch (message.action) {
case 'repository-path-update': {
const payload = message.payload as { path: string };
logger.log(`Repository path update received: ${JSON.stringify(payload)}`);
if (!payload?.path) {
logger.error('Missing path in payload');
return createControlResponse(message, null, 'Missing path in payload');
}
try {
// Update the server configuration
logger.log(`Calling updateRepositoryPath with: ${payload.path}`);
const updateSuccess = await this.controlUnixHandler.updateRepositoryPath(payload.path);
if (updateSuccess) {
logger.log(`Successfully updated repository path to: ${payload.path}`);
return createControlResponse(message, { success: true, path: payload.path });
} else {
logger.error('updateRepositoryPath returned false');
return createControlResponse(message, null, 'Failed to update repository path');
}
} catch (error) {
logger.error('Failed to update repository path:', error);
return createControlResponse(
message,
null,
error instanceof Error ? error.message : 'Failed to update repository path'
);
}
}
case 'ping':
// Already handled in handleMacMessage
return null;
@ -133,8 +102,6 @@ export class ControlUnixHandler {
private readonly socketPath: string;
private handlers = new Map<ControlCategory, MessageHandler>();
private messageBuffer = Buffer.alloc(0);
private configUpdateCallback: ((config: { repositoryBasePath: string }) => void) | null = null;
private currentRepositoryPath: string | null = null;
constructor() {
// Use a unique socket path in user's home directory to avoid /tmp issues
@ -427,11 +394,6 @@ export class ControlUnixHandler {
return;
}
// Log repository-path-update messages specifically
if (message.category === 'system' && message.action === 'repository-path-update') {
logger.log(`🔍 Repository path update message details:`, JSON.stringify(message));
}
// Check if this is a response to a pending request
if (message.type === 'response' && this.pendingRequests.has(message.id)) {
const resolver = this.pendingRequests.get(message.id);
@ -576,46 +538,6 @@ export class ControlUnixHandler {
this.macSocket = null;
}
}
/**
* Set a callback to be called when configuration is updated
*/
setConfigUpdateCallback(callback: (config: { repositoryBasePath: string }) => void): void {
this.configUpdateCallback = callback;
}
/**
* Update the repository path and notify all connected clients
*/
async updateRepositoryPath(path: string): Promise<boolean> {
logger.log(`updateRepositoryPath called with path: ${path}`);
try {
this.currentRepositoryPath = path;
logger.log(`Set currentRepositoryPath to: ${this.currentRepositoryPath}`);
// Call the callback to update server configuration and broadcast to web clients
if (this.configUpdateCallback) {
logger.log('Calling configUpdateCallback...');
this.configUpdateCallback({ repositoryBasePath: path });
logger.log('configUpdateCallback completed successfully');
return true;
}
logger.warn('No config update callback set - is the server initialized?');
return false;
} catch (error) {
logger.error('Failed to update repository path:', error);
return false;
}
}
/**
* Get the current repository path
*/
getRepositoryPath(): string | null {
return this.currentRepositoryPath;
}
}
export const controlUnixHandler = new ControlUnixHandler();

View file

@ -1,285 +0,0 @@
import type { Server } from 'http';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { WebSocket, WebSocketServer } from 'ws';
// Mock the control Unix handler
const mockControlUnixHandler = {
sendControlMessage: vi.fn(),
updateRepositoryPath: vi.fn(),
};
// Mock the app setup
vi.mock('../../server/server', () => ({
createApp: () => {
const express = require('express');
const app = express();
return app;
},
}));
describe('Repository Path Bidirectional Sync Integration', () => {
let wsServer: WebSocketServer;
let httpServer: Server;
let client: WebSocket;
const port = 4321;
beforeEach(async () => {
// Create a simple WebSocket server to simulate the config endpoint
httpServer = require('http').createServer();
wsServer = new WebSocketServer({ server: httpServer, path: '/ws/config' });
// Handle WebSocket connections
wsServer.on('connection', (ws) => {
// Send initial config
ws.send(
JSON.stringify({
type: 'config',
data: {
repositoryBasePath: '~/',
serverConfigured: false,
},
})
);
// Handle messages from client
ws.on('message', async (data) => {
try {
const message = JSON.parse(data.toString());
if (message.type === 'update-repository-path') {
// Simulate forwarding to Mac
const _response = await mockControlUnixHandler.sendControlMessage({
id: 'test-id',
type: 'request',
category: 'system',
action: 'repository-path-update',
payload: { path: message.path, source: 'web' },
});
// Broadcast update back to all clients
wsServer.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(
JSON.stringify({
type: 'config',
data: {
repositoryBasePath: message.path,
serverConfigured: false,
},
})
);
}
});
}
} catch (error) {
console.error('Error handling message:', error);
}
});
});
// Start server
await new Promise<void>((resolve) => {
httpServer.listen(port, resolve);
});
});
afterEach(async () => {
// Clean up
if (client && client.readyState === WebSocket.OPEN) {
client.close();
}
wsServer.close();
await new Promise<void>((resolve) => {
httpServer.close(() => resolve());
});
vi.clearAllMocks();
});
it('should complete full bidirectional sync flow', async () => {
// Setup mock Mac response
mockControlUnixHandler.sendControlMessage.mockResolvedValue({
id: 'test-id',
type: 'response',
category: 'system',
action: 'repository-path-update',
payload: { success: true },
});
// Connect client
client = new WebSocket(`ws://localhost:${port}/ws/config`);
await new Promise<void>((resolve) => {
client.on('open', resolve);
});
// Track received messages
const receivedMessages: Array<{ type: string; data?: unknown }> = [];
client.on('message', (data) => {
receivedMessages.push(JSON.parse(data.toString()));
});
// Wait for initial config
await new Promise((resolve) => setTimeout(resolve, 50));
expect(receivedMessages).toHaveLength(1);
expect(receivedMessages[0]).toEqual({
type: 'config',
data: {
repositoryBasePath: '~/',
serverConfigured: false,
},
});
// Step 1: Web sends update
const newPath = '/Users/test/Projects';
client.send(
JSON.stringify({
type: 'update-repository-path',
path: newPath,
})
);
// Wait for processing
await new Promise((resolve) => setTimeout(resolve, 100));
// Step 2: Verify Mac handler was called
expect(mockControlUnixHandler.sendControlMessage).toHaveBeenCalledWith({
id: 'test-id',
type: 'request',
category: 'system',
action: 'repository-path-update',
payload: { path: newPath, source: 'web' },
});
// Step 3: Verify broadcast was sent back
expect(receivedMessages).toHaveLength(2);
expect(receivedMessages[1]).toEqual({
type: 'config',
data: {
repositoryBasePath: newPath,
serverConfigured: false,
},
});
});
it('should handle Mac-initiated updates', async () => {
// Connect client
client = new WebSocket(`ws://localhost:${port}/ws/config`);
await new Promise<void>((resolve) => {
client.on('open', resolve);
});
const receivedMessages: Array<{ type: string; data?: unknown }> = [];
client.on('message', (data) => {
receivedMessages.push(JSON.parse(data.toString()));
});
// Wait for initial config
await new Promise((resolve) => setTimeout(resolve, 50));
// Simulate Mac sending update through server
const macPath = '/mac/initiated/path';
wsServer.clients.forEach((ws) => {
ws.send(
JSON.stringify({
type: 'config',
data: {
repositoryBasePath: macPath,
serverConfigured: true,
},
})
);
});
// Wait for message
await new Promise((resolve) => setTimeout(resolve, 50));
// Verify client received update
expect(receivedMessages).toHaveLength(2);
expect(receivedMessages[1]).toEqual({
type: 'config',
data: {
repositoryBasePath: macPath,
serverConfigured: true,
},
});
});
it('should handle multiple clients', async () => {
// Connect first client
const client1 = new WebSocket(`ws://localhost:${port}/ws/config`);
await new Promise<void>((resolve) => {
client1.on('open', resolve);
});
const client1Messages: Array<{ type: string; data?: unknown }> = [];
client1.on('message', (data) => {
client1Messages.push(JSON.parse(data.toString()));
});
// Connect second client
const client2 = new WebSocket(`ws://localhost:${port}/ws/config`);
await new Promise<void>((resolve) => {
client2.on('open', resolve);
});
const client2Messages: Array<{ type: string; data?: unknown }> = [];
client2.on('message', (data) => {
client2Messages.push(JSON.parse(data.toString()));
});
// Wait for initial configs
await new Promise((resolve) => setTimeout(resolve, 100));
// Client 1 sends update
const newPath = '/shared/path';
client1.send(
JSON.stringify({
type: 'update-repository-path',
path: newPath,
})
);
// Wait for broadcast
await new Promise((resolve) => setTimeout(resolve, 100));
// Both clients should receive the update
expect(client1Messages).toHaveLength(2);
expect(client2Messages).toHaveLength(2);
expect(client1Messages[1].data.repositoryBasePath).toBe(newPath);
expect(client2Messages[1].data.repositoryBasePath).toBe(newPath);
// Clean up
client1.close();
client2.close();
});
it('should handle errors gracefully', async () => {
// Setup mock to fail
mockControlUnixHandler.sendControlMessage.mockRejectedValue(new Error('Unix socket error'));
// Connect client
client = new WebSocket(`ws://localhost:${port}/ws/config`);
await new Promise<void>((resolve) => {
client.on('open', resolve);
});
// Send update that will fail
client.send(
JSON.stringify({
type: 'update-repository-path',
path: '/failing/path',
})
);
// Wait for processing
await new Promise((resolve) => setTimeout(resolve, 100));
// Verify handler was called despite error
expect(mockControlUnixHandler.sendControlMessage).toHaveBeenCalled();
// Connection should remain open
expect(client.readyState).toBe(WebSocket.OPEN);
});
});

View file

@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { controlUnixHandler } from '../../server/websocket/control-unix-handler';
import type { ControlUnixHandler } from '../../server/websocket/control-unix-handler.js';
// Mock dependencies
vi.mock('fs', () => ({
@ -8,11 +8,14 @@ vi.mock('fs', () => ({
mkdir: vi.fn().mockResolvedValue(undefined),
},
existsSync: vi.fn().mockReturnValue(false),
mkdirSync: vi.fn(),
unlinkSync: vi.fn(),
chmod: vi.fn((_path, _mode, cb) => cb(null)),
}));
vi.mock('net', () => ({
createServer: vi.fn(() => ({
listen: vi.fn(),
listen: vi.fn((_path, cb) => cb?.()),
close: vi.fn(),
on: vi.fn(),
})),
@ -35,161 +38,67 @@ vi.mock('../../server/utils/logger', () => ({
}));
describe('Control Unix Handler', () => {
beforeEach(() => {
let controlUnixHandler: ControlUnixHandler;
beforeEach(async () => {
vi.clearAllMocks();
// Import after mocks are set up
const module = await import('../../server/websocket/control-unix-handler');
controlUnixHandler = module.controlUnixHandler;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Repository Path Update', () => {
it('should update and retrieve repository path', async () => {
const mockCallback = vi.fn();
controlUnixHandler.setConfigUpdateCallback(mockCallback);
describe('Basic Functionality', () => {
it('should start the Unix socket server', async () => {
await controlUnixHandler.start();
// Update path
const success = await controlUnixHandler.updateRepositoryPath('/Users/test/NewProjects');
expect(success).toBe(true);
expect(mockCallback).toHaveBeenCalledWith({
repositoryBasePath: '/Users/test/NewProjects',
});
// Verify path is stored
expect(controlUnixHandler.getRepositoryPath()).toBe('/Users/test/NewProjects');
const net = await vi.importMock<typeof import('net')>('net');
expect(net.createServer).toHaveBeenCalled();
});
it('should handle errors during path update', async () => {
const mockCallback = vi.fn(() => {
throw new Error('Update failed');
});
controlUnixHandler.setConfigUpdateCallback(mockCallback);
it('should check if Mac app is connected', () => {
expect(controlUnixHandler.isMacAppConnected()).toBe(false);
});
// Update path should return false on error
const success = await controlUnixHandler.updateRepositoryPath('/Users/test/BadPath');
expect(success).toBe(false);
it('should stop the Unix socket server', () => {
controlUnixHandler.stop();
// Just verify it doesn't throw
expect(true).toBe(true);
});
});
describe('Config Update Callback', () => {
it('should set and call config update callback', () => {
const mockCallback = vi.fn();
describe('Message Handling', () => {
it('should handle browser WebSocket connections', () => {
const mockWs = {
on: vi.fn(),
send: vi.fn(),
close: vi.fn(),
readyState: 1,
} as unknown as import('ws').WebSocket;
// Set callback
controlUnixHandler.setConfigUpdateCallback(mockCallback);
// Should not throw
controlUnixHandler.handleBrowserConnection(mockWs, 'test-user');
// Trigger update
(
controlUnixHandler as unknown as {
configUpdateCallback: (config: { repositoryBasePath: string }) => void;
}
).configUpdateCallback({ repositoryBasePath: '/test/path' });
// Verify callback was called
expect(mockCallback).toHaveBeenCalledWith({ repositoryBasePath: '/test/path' });
expect(mockWs.on).toHaveBeenCalledWith('message', expect.any(Function));
expect(mockWs.on).toHaveBeenCalledWith('close', expect.any(Function));
expect(mockWs.on).toHaveBeenCalledWith('error', expect.any(Function));
});
});
describe('Mac Message Handling', () => {
it('should process repository-path-update messages from Mac app', async () => {
const mockCallback = vi.fn();
controlUnixHandler.setConfigUpdateCallback(mockCallback);
// Simulate Mac sending a repository-path-update message
it('should send control messages when Mac is connected', async () => {
const message = {
id: 'mac-msg-123',
id: 'test-123',
type: 'request' as const,
category: 'system' as const,
action: 'repository-path-update',
payload: { path: '/Users/test/MacSelectedPath' },
action: 'test',
payload: { test: true },
};
// Process the message through the system handler
const systemHandler = (
controlUnixHandler as unknown as {
handlers: Map<string, { handleMessage: (msg: typeof message) => Promise<unknown> }>;
}
).handlers.get('system');
const response = await systemHandler?.handleMessage(message);
// Verify the update was processed
expect(mockCallback).toHaveBeenCalledWith({
repositoryBasePath: '/Users/test/MacSelectedPath',
});
// Verify successful response
expect(response).toMatchObject({
id: 'mac-msg-123',
type: 'response',
category: 'system',
action: 'repository-path-update',
payload: { success: true, path: '/Users/test/MacSelectedPath' },
});
// Verify the path was stored
expect(controlUnixHandler.getRepositoryPath()).toBe('/Users/test/MacSelectedPath');
});
it('should handle missing path in repository-path-update payload', async () => {
const mockCallback = vi.fn();
controlUnixHandler.setConfigUpdateCallback(mockCallback);
// Message with missing path
const message = {
id: 'mac-msg-456',
type: 'request' as const,
category: 'system' as const,
action: 'repository-path-update',
payload: {},
};
// Process the message
const systemHandler = (
controlUnixHandler as unknown as {
handlers: Map<string, { handleMessage: (msg: typeof message) => Promise<unknown> }>;
}
).handlers.get('system');
const response = await systemHandler?.handleMessage(message);
// Verify callback was not called
expect(mockCallback).not.toHaveBeenCalled();
// Verify error response
expect(response).toMatchObject({
id: 'mac-msg-456',
type: 'response',
category: 'system',
action: 'repository-path-update',
error: 'Missing path in payload',
});
});
it('should not process response messages for repository-path-update', async () => {
const mockCallback = vi.fn();
controlUnixHandler.setConfigUpdateCallback(mockCallback);
// Response message (should be ignored)
const message = {
id: 'mac-msg-789',
type: 'response' as const,
category: 'system' as const,
action: 'repository-path-update',
payload: { success: true, path: '/some/path' },
};
// Simulate handleMacMessage behavior - response messages without pending requests are ignored
const pendingRequests = (
controlUnixHandler as unknown as { pendingRequests: Map<string, unknown> }
).pendingRequests;
const hasPendingRequest = pendingRequests.has(message.id);
// Since this is a response without a pending request, it should be ignored
expect(hasPendingRequest).toBe(false);
// Verify callback was not called
expect(mockCallback).not.toHaveBeenCalled();
// When Mac is not connected, should resolve to null
const result = await controlUnixHandler.sendControlMessage(message);
expect(result).toBe(null);
});
});
});

25
web/src/types/config.ts Normal file
View file

@ -0,0 +1,25 @@
export interface QuickStartCommand {
name?: string; // Optional display name (can include emoji), if empty uses command
command: string; // The actual command to execute
}
export interface VibeTunnelConfig {
version: number;
quickStartCommands: QuickStartCommand[];
repositoryBasePath?: string;
}
export const DEFAULT_QUICK_START_COMMANDS: QuickStartCommand[] = [
{ name: '✨ claude', command: 'claude' },
{ name: '✨ gemini', command: 'gemini' },
{ command: 'zsh' },
{ command: 'python3' },
{ command: 'node' },
{ name: '▶️ pnpm run dev', command: 'pnpm run dev' },
];
export const DEFAULT_CONFIG: VibeTunnelConfig = {
version: 1,
quickStartCommands: DEFAULT_QUICK_START_COMMANDS,
repositoryBasePath: '~/',
};