mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-21 13:55:54 +00:00
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:
parent
276dad95c9
commit
f8a7cf9537
91 changed files with 7188 additions and 2717 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -52,7 +52,7 @@ jobs:
|
|||
|
||||
mac:
|
||||
name: Mac CI
|
||||
needs: [changes, node]
|
||||
needs: [changes]
|
||||
if: |
|
||||
always() &&
|
||||
!contains(needs.*.result, 'failure') &&
|
||||
|
|
|
|||
70
.github/workflows/mac.yml
vendored
70
.github/workflows/mac.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
20
.github/workflows/node.yml
vendored
20
.github/workflows/node.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
76
CHANGELOG.md
76
CHANGELOG.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
48
mac/VibeTunnel/Core/Models/ConfigurationEnums.swift
Normal file
48
mac/VibeTunnel/Core/Models/ConfigurationEnums.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
266
mac/VibeTunnel/Core/Services/AutocompleteService.swift
Normal file
266
mac/VibeTunnel/Core/Services/AutocompleteService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)")
|
||||
|
|
|
|||
413
mac/VibeTunnel/Core/Services/ConfigManager.swift
Normal file
413
mac/VibeTunnel/Core/Services/ConfigManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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? {
|
||||
|
|
|
|||
24
mac/VibeTunnel/Core/Utilities/GitAppHelper.swift
Normal file
24
mac/VibeTunnel/Core/Utilities/GitAppHelper.swift
Normal 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
|
||||
}
|
||||
}
|
||||
254
mac/VibeTunnel/Presentation/Components/AutocompleteView.swift
Normal file
254
mac/VibeTunnel/Presentation/Components/AutocompleteView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
165
mac/VibeTunnel/Presentation/Components/ClickableURLView.swift
Normal file
165
mac/VibeTunnel/Presentation/Components/ClickableURLView.swift
Normal 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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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") {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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!**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
488
web/src/client/components/quick-start-editor.test.ts
Normal file
488
web/src/client/components/quick-start-editor.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
297
web/src/client/components/quick-start-editor.ts
Normal file
297
web/src/client/components/quick-start-editor.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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=${
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ${
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}...`);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 '~/';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
307
web/src/client/services/server-config-service.test.ts
Normal file
307
web/src/client/services/server-config-service.test.ts
Normal 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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
192
web/src/client/services/server-config-service.ts
Normal file
192
web/src/client/services/server-config-service.ts
Normal 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();
|
||||
523
web/src/client/services/session-action-service.test.ts
Normal file
523
web/src/client/services/session-action-service.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
382
web/src/client/services/session-action-service.ts
Normal file
382
web/src/client/services/session-action-service.ts
Normal 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();
|
||||
88
web/src/client/utils/session-actions.ts
Normal file
88
web/src/client/utils/session-actions.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
275
web/src/server/routes/config.test.ts
Normal file
275
web/src/server/routes/config.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
422
web/src/server/services/config-service.test.ts
Normal file
422
web/src/server/services/config-service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
191
web/src/server/services/config-service.ts
Normal file
191
web/src/server/services/config-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
25
web/src/types/config.ts
Normal 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: '~/',
|
||||
};
|
||||
Loading…
Reference in a new issue