feat: add comprehensive Git worktree management with follow mode and enhanced UI (#452)

This commit is contained in:
Peter Steinberger 2025-07-26 15:06:18 +02:00 committed by GitHub
parent 4c897f139b
commit f3a98ee058
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
328 changed files with 31934 additions and 6561 deletions

View file

@ -21,8 +21,9 @@ jobs:
- name: Clean workspace
run: |
# Clean workspace for self-hosted runner
# Clean workspace but preserve .git directory
find . -maxdepth 1 -name '.*' -not -name '.git' -not -name '.' -not -name '..' -exec rm -rf {} + || true
rm -rf * || true
rm -rf .* || true
- name: Checkout code
uses: actions/checkout@v4
@ -464,8 +465,9 @@ jobs:
- name: Clean workspace
run: |
# Clean workspace for self-hosted runner
# Clean workspace but preserve .git directory
find . -maxdepth 1 -name '.*' -not -name '.git' -not -name '.' -not -name '..' -exec rm -rf {} + || true
rm -rf * || true
rm -rf .* || true
- name: Checkout code
uses: actions/checkout@v4

View file

@ -21,8 +21,9 @@ jobs:
- name: Clean workspace
run: |
# Clean workspace for self-hosted runner
# Clean workspace but preserve .git directory
find . -maxdepth 1 -name '.*' -not -name '.git' -not -name '.' -not -name '..' -exec rm -rf {} + || true
rm -rf * || true
rm -rf .* || true
- name: Checkout code
uses: actions/checkout@v4
@ -362,8 +363,9 @@ jobs:
- name: Clean workspace
run: |
# Clean workspace for self-hosted runner
# Clean workspace but preserve .git directory
find . -maxdepth 1 -name '.*' -not -name '.git' -not -name '.' -not -name '..' -exec rm -rf {} + || true
rm -rf * || true
rm -rf .* || true
- name: Checkout code
uses: actions/checkout@v4

View file

@ -21,8 +21,9 @@ jobs:
- name: Clean workspace
run: |
# Clean workspace for self-hosted runner
# Clean workspace but preserve .git directory
find . -maxdepth 1 -name '.*' -not -name '.git' -not -name '.' -not -name '..' -exec rm -rf {} + || true
rm -rf * || true
rm -rf .* || true
- name: Checkout code
uses: actions/checkout@v4
@ -161,6 +162,7 @@ jobs:
PROVISIONING_PROFILE_SPECIFIER="" \
DEVELOPMENT_TEAM="" \
ONLY_ACTIVE_ARCH=NO \
ENABLE_TESTABILITY=YES \
archive | xcbeautify
echo "Release build completed successfully"
@ -181,7 +183,8 @@ jobs:
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO \
COMPILER_INDEX_STORE_ENABLE=NO | xcbeautify || {
COMPILER_INDEX_STORE_ENABLE=NO \
ENABLE_TESTABILITY=YES | xcbeautify || {
echo "::error::Release configuration tests failed"
# Try to get more detailed error information
echo "=== Attempting to get test failure details ==="

View file

@ -8,12 +8,9 @@ permissions:
pull-requests: write
issues: write
# All jobs run in parallel for faster CI execution
# Using pnpm install --frozen-lockfile for reproducible installs
# Build already uses esbuild for fast TypeScript compilation
jobs:
lint:
name: Lint TypeScript/JavaScript Code
node-ci:
name: Node.js CI - Lint, Build, Test, Type Check
runs-on: blacksmith-8vcpu-ubuntu-2404-arm
env:
GITHUB_REPO_NAME: ${{ github.repository }}
@ -28,110 +25,11 @@ jobs:
node-version: '24'
- name: Setup pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v4
with:
version: 9
version: 10
run_install: false
# Skip pnpm cache - testing if fresh installs are faster
# The cache was extremely large and might be slower than fresh install
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libpam0g-dev
- name: Install dependencies
working-directory: web
run: |
pnpm config set network-concurrency 4
pnpm config set child-concurrency 2
pnpm install --frozen-lockfile --prefer-offline
- name: Check formatting with Biome
id: biome-format
working-directory: web
continue-on-error: true
run: |
pnpm run format:check 2>&1 | tee biome-format-output.txt
echo "result=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
- name: Run Biome linting
id: biome-lint
working-directory: web
continue-on-error: true
run: |
pnpm run lint:biome 2>&1 | tee biome-lint-output.txt
echo "result=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
- name: Read Biome Format Output
if: always()
id: biome-format-output
working-directory: web
run: |
if [ -f biome-format-output.txt ]; then
echo 'content<<EOF' >> $GITHUB_OUTPUT
cat biome-format-output.txt >> $GITHUB_OUTPUT
echo 'EOF' >> $GITHUB_OUTPUT
else
echo "content=No output" >> $GITHUB_OUTPUT
fi
- name: Read Biome Lint Output
if: always()
id: biome-lint-output
working-directory: web
run: |
if [ -f biome-lint-output.txt ]; then
echo 'content<<EOF' >> $GITHUB_OUTPUT
cat biome-lint-output.txt >> $GITHUB_OUTPUT
echo 'EOF' >> $GITHUB_OUTPUT
else
echo "content=No output" >> $GITHUB_OUTPUT
fi
- name: Report Biome Format Results
if: always()
uses: ./.github/actions/lint-reporter
with:
title: 'Node.js Biome Formatting'
lint-result: ${{ steps.biome-format.outputs.result == '0' && 'success' || 'failure' }}
lint-output: ${{ steps.biome-format-output.outputs.content }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Report Biome Lint Results
if: always()
uses: ./.github/actions/lint-reporter
with:
title: 'Node.js Biome Linting'
lint-result: ${{ steps.biome-lint.outputs.result == '0' && 'success' || 'failure' }}
lint-output: ${{ steps.biome-lint-output.outputs.content }}
github-token: ${{ secrets.GITHUB_TOKEN }}
build-and-test:
name: Build and Test
runs-on: blacksmith-8vcpu-ubuntu-2404-arm
env:
GITHUB_REPO_NAME: ${{ github.repository }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9
run_install: false
# Skip pnpm cache - testing if fresh installs are faster
# The cache was extremely large and might be slower than fresh install
- name: Install system dependencies
run: |
sudo apt-get update
@ -161,38 +59,124 @@ jobs:
run: |
cd node-pty && npm install && npm run build
- name: Build frontend and backend
# Run all checks - build first, then tests
- name: Run all checks
working-directory: web
run: |
# Use all available cores for esbuild
# Create a temporary directory for outputs
mkdir -p ci-outputs
# Run format, lint, typecheck, and audit in parallel (these don't depend on build)
(
echo "Starting format check..."
pnpm run format:check > ci-outputs/format-output.txt 2>&1
echo $? > ci-outputs/format-exit-code.txt
echo "Format check completed"
) &
(
echo "Starting lint..."
pnpm run lint:biome > ci-outputs/lint-output.txt 2>&1
echo $? > ci-outputs/lint-exit-code.txt
echo "Lint completed"
) &
(
echo "Starting type check..."
pnpm run typecheck > ci-outputs/typecheck-output.txt 2>&1
echo $? > ci-outputs/typecheck-exit-code.txt
echo "Type check completed"
) &
(
echo "Starting security audit..."
pnpm audit --audit-level=moderate > ci-outputs/audit-output.txt 2>&1 || true
echo 0 > ci-outputs/audit-exit-code.txt # Don't fail on audit
echo "Audit completed"
) &
# Wait for parallel checks
wait
# Run build (must complete before tests)
echo "Starting build..."
export ESBUILD_MAX_WORKERS=$(nproc)
pnpm run build:ci
pnpm run build:ci > ci-outputs/build-output.txt 2>&1
echo $? > ci-outputs/build-exit-code.txt
echo "Build completed"
# Run tests after build completes
echo "Starting tests..."
# Run client and server tests sequentially to avoid conflicts
pnpm run test:client:coverage > ci-outputs/test-client-output.txt 2>&1
CLIENT_EXIT=$?
pnpm run test:server:coverage > ci-outputs/test-server-output.txt 2>&1
SERVER_EXIT=$?
# Return non-zero if either test failed
if [ $CLIENT_EXIT -ne 0 ] || [ $SERVER_EXIT -ne 0 ]; then
echo 1 > ci-outputs/test-exit-code.txt
else
echo 0 > ci-outputs/test-exit-code.txt
fi
echo "Tests completed"
echo "All checks completed"
- name: Run client tests with coverage
id: test-client-coverage
working-directory: web
run: |
pnpm run test:client:coverage 2>&1 | tee test-client-output.txt
echo "result=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
- name: Run server tests with coverage
id: test-server-coverage
working-directory: web
run: |
pnpm run test:server:coverage 2>&1 | tee test-server-output.txt
echo "result=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
env:
CI: true
- name: Check test results
# Process results
- name: Process check results
if: always()
id: results
working-directory: web
run: |
if [ "${{ steps.test-client-coverage.outputs.result }}" != "0" ] || [ "${{ steps.test-server-coverage.outputs.result }}" != "0" ]; then
echo "::error::Tests failed"
exit 1
# Read exit codes
FORMAT_EXIT=$(cat ci-outputs/format-exit-code.txt || echo 1)
LINT_EXIT=$(cat ci-outputs/lint-exit-code.txt || echo 1)
TYPECHECK_EXIT=$(cat ci-outputs/typecheck-exit-code.txt || echo 1)
BUILD_EXIT=$(cat ci-outputs/build-exit-code.txt || echo 1)
TEST_EXIT=$(cat ci-outputs/test-exit-code.txt || echo 1)
# Set outputs
echo "format_result=$FORMAT_EXIT" >> $GITHUB_OUTPUT
echo "lint_result=$LINT_EXIT" >> $GITHUB_OUTPUT
echo "typecheck_result=$TYPECHECK_EXIT" >> $GITHUB_OUTPUT
echo "build_result=$BUILD_EXIT" >> $GITHUB_OUTPUT
echo "test_result=$TEST_EXIT" >> $GITHUB_OUTPUT
# Read outputs for reporting
echo 'format_output<<EOF' >> $GITHUB_OUTPUT
cat ci-outputs/format-output.txt 2>/dev/null || echo "No output" >> $GITHUB_OUTPUT
echo 'EOF' >> $GITHUB_OUTPUT
echo 'lint_output<<EOF' >> $GITHUB_OUTPUT
cat ci-outputs/lint-output.txt 2>/dev/null || echo "No output" >> $GITHUB_OUTPUT
echo 'EOF' >> $GITHUB_OUTPUT
echo 'typecheck_output<<EOF' >> $GITHUB_OUTPUT
cat ci-outputs/typecheck-output.txt 2>/dev/null || echo "No output" >> $GITHUB_OUTPUT
echo 'EOF' >> $GITHUB_OUTPUT
echo 'build_output<<EOF' >> $GITHUB_OUTPUT
tail -n 50 ci-outputs/build-output.txt 2>/dev/null || echo "No output" >> $GITHUB_OUTPUT
echo 'EOF' >> $GITHUB_OUTPUT
echo 'test_output<<EOF' >> $GITHUB_OUTPUT
tail -n 100 ci-outputs/test-client-output.txt 2>/dev/null || echo "No client test output" >> $GITHUB_OUTPUT
echo "---" >> $GITHUB_OUTPUT
tail -n 100 ci-outputs/test-server-output.txt 2>/dev/null || echo "No server test output" >> $GITHUB_OUTPUT
echo 'EOF' >> $GITHUB_OUTPUT
echo 'audit_output<<EOF' >> $GITHUB_OUTPUT
cat ci-outputs/audit-output.txt 2>/dev/null || echo "No output" >> $GITHUB_OUTPUT
echo 'EOF' >> $GITHUB_OUTPUT
# Determine overall result
if [ $FORMAT_EXIT -ne 0 ] || [ $LINT_EXIT -ne 0 ] || [ $TYPECHECK_EXIT -ne 0 ] || [ $BUILD_EXIT -ne 0 ] || [ $TEST_EXIT -ne 0 ]; then
echo "overall_result=failure" >> $GITHUB_OUTPUT
else
echo "overall_result=success" >> $GITHUB_OUTPUT
fi
# Generate coverage summary
- name: Generate coverage summaries
if: always()
working-directory: web
@ -211,10 +195,6 @@ jobs:
};
console.log(JSON.stringify(summary, null, 2));
" > coverage-client-summary.json
if [ -f test-client-output.txt ]; then
tail -n 50 test-client-output.txt > coverage-client-output.txt
fi
else
echo '{"error": "No client coverage data found"}' > coverage-client-summary.json
fi
@ -233,206 +213,78 @@ jobs:
};
console.log(JSON.stringify(summary, null, 2));
" > coverage-server-summary.json
if [ -f test-server-output.txt ]; then
tail -n 50 test-server-output.txt > coverage-server-output.txt
fi
else
echo '{"error": "No server coverage data found"}' > coverage-server-summary.json
fi
# Create combined summary for backward compatibility
node -e "
const clientCov = require('./coverage-client-summary.json');
const serverCov = require('./coverage-server-summary.json');
const combined = {
client: clientCov,
server: serverCov
};
console.log(JSON.stringify(combined, null, 2));
" > coverage-summary-formatted.json || echo '{"error": "Failed to combine coverage data"}' > coverage-summary-formatted.json
- name: Upload coverage artifacts
# Report results
- name: Report Format Results
if: always()
uses: actions/upload-artifact@v4
uses: ./.github/actions/lint-reporter
with:
name: node-coverage
path: |
web/coverage-summary-formatted.json
web/coverage-client-summary.json
web/coverage-server-summary.json
web/coverage-client-output.txt
web/coverage-server-output.txt
web/coverage/client/lcov.info
web/coverage/server/lcov.info
title: 'Node.js Biome Formatting'
lint-result: ${{ steps.results.outputs.format_result == '0' && 'success' || 'failure' }}
lint-output: ${{ steps.results.outputs.format_output }}
github-token: ${{ secrets.GITHUB_TOKEN }}
# Build artifacts no longer uploaded - Mac CI builds web as part of Xcode build
type-check:
name: TypeScript Type Checking
runs-on: blacksmith-8vcpu-ubuntu-2404-arm
env:
GITHUB_REPO_NAME: ${{ github.repository }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Report Lint Results
if: always()
uses: ./.github/actions/lint-reporter
with:
node-version: '24'
title: 'Node.js Biome Linting'
lint-result: ${{ steps.results.outputs.lint_result == '0' && 'success' || 'failure' }}
lint-output: ${{ steps.results.outputs.lint_output }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup pnpm
uses: pnpm/action-setup@v2
- name: Report TypeCheck Results
if: always()
uses: ./.github/actions/lint-reporter
with:
version: 9
run_install: false
title: 'Node.js TypeScript Type Checking'
lint-result: ${{ steps.results.outputs.typecheck_result == '0' && 'success' || 'failure' }}
lint-output: ${{ steps.results.outputs.typecheck_output }}
github-token: ${{ secrets.GITHUB_TOKEN }}
# Skip pnpm cache - testing if fresh installs are faster
# The cache was extremely large and might be slower than fresh install
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libpam0g-dev
- name: Install dependencies
working-directory: web
run: |
pnpm config set network-concurrency 4
pnpm config set child-concurrency 2
pnpm install --frozen-lockfile --prefer-offline
- name: Build node-pty for TypeScript
working-directory: web
run: |
cd node-pty && npm install && npm run build
- name: Check TypeScript types
working-directory: web
run: pnpm run typecheck
audit:
name: Security Audit
runs-on: blacksmith-8vcpu-ubuntu-2404-arm
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Report Build Results
if: always()
uses: ./.github/actions/lint-reporter
with:
node-version: '24'
title: 'Node.js Build'
lint-result: ${{ steps.results.outputs.build_result == '0' && 'success' || 'failure' }}
lint-output: ${{ steps.results.outputs.build_output }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup pnpm
uses: pnpm/action-setup@v2
- name: Report Test Results
if: always()
uses: ./.github/actions/lint-reporter
with:
version: 9
run_install: false
title: 'Node.js Tests'
lint-result: ${{ steps.results.outputs.test_result == '0' && 'success' || 'failure' }}
lint-output: ${{ steps.results.outputs.test_output }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Run pnpm audit
working-directory: web
run: pnpm audit --audit-level=moderate || true
# || true to not fail the build on vulnerabilities, but still report them
report-coverage:
name: Report Coverage Results
runs-on: blacksmith-8vcpu-ubuntu-2404-arm
needs: [build-and-test]
# Keep Node.js coverage reporting for PRs since it's fast
if: always() && github.event_name == 'pull_request'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download coverage artifacts
uses: actions/download-artifact@v4
- name: Report Audit Results
if: always()
uses: ./.github/actions/lint-reporter
with:
name: node-coverage
path: web/coverage-artifacts
- name: Read coverage summaries
id: coverage
working-directory: web
run: |
# Initialize result variables
CLIENT_RESULT="failure"
SERVER_RESULT="failure"
# Process client coverage
if [ -f coverage-artifacts/coverage-client-summary.json ]; then
CLIENT_JSON=$(cat coverage-artifacts/coverage-client-summary.json)
CLIENT_LINES=$(echo "$CLIENT_JSON" | jq -r '.lines.pct // 0')
CLIENT_FUNCTIONS=$(echo "$CLIENT_JSON" | jq -r '.functions.pct // 0')
CLIENT_BRANCHES=$(echo "$CLIENT_JSON" | jq -r '.branches.pct // 0')
CLIENT_STATEMENTS=$(echo "$CLIENT_JSON" | jq -r '.statements.pct // 0')
# Always report as success - we're just reporting coverage
CLIENT_RESULT="success"
echo "client_lines=$CLIENT_LINES" >> $GITHUB_OUTPUT
echo "client_functions=$CLIENT_FUNCTIONS" >> $GITHUB_OUTPUT
echo "client_branches=$CLIENT_BRANCHES" >> $GITHUB_OUTPUT
echo "client_statements=$CLIENT_STATEMENTS" >> $GITHUB_OUTPUT
fi
# Process server coverage
if [ -f coverage-artifacts/coverage-server-summary.json ]; then
SERVER_JSON=$(cat coverage-artifacts/coverage-server-summary.json)
SERVER_LINES=$(echo "$SERVER_JSON" | jq -r '.lines.pct // 0')
SERVER_FUNCTIONS=$(echo "$SERVER_JSON" | jq -r '.functions.pct // 0')
SERVER_BRANCHES=$(echo "$SERVER_JSON" | jq -r '.branches.pct // 0')
SERVER_STATEMENTS=$(echo "$SERVER_JSON" | jq -r '.statements.pct // 0')
# Always report as success - we're just reporting coverage
SERVER_RESULT="success"
echo "server_lines=$SERVER_LINES" >> $GITHUB_OUTPUT
echo "server_functions=$SERVER_FUNCTIONS" >> $GITHUB_OUTPUT
echo "server_branches=$SERVER_BRANCHES" >> $GITHUB_OUTPUT
echo "server_statements=$SERVER_STATEMENTS" >> $GITHUB_OUTPUT
fi
# Always report as success - we're just reporting coverage
echo "result=success" >> $GITHUB_OUTPUT
echo "client_result=$CLIENT_RESULT" >> $GITHUB_OUTPUT
echo "server_result=$SERVER_RESULT" >> $GITHUB_OUTPUT
# Format output
CLIENT_OUTPUT=""
SERVER_OUTPUT=""
if [ -f coverage-artifacts/coverage-client-output.txt ]; then
CLIENT_OUTPUT=$(tail -n 20 coverage-artifacts/coverage-client-output.txt | grep -v "^\[" | head -10)
fi
if [ -f coverage-artifacts/coverage-server-output.txt ]; then
SERVER_OUTPUT=$(tail -n 20 coverage-artifacts/coverage-server-output.txt | grep -v "^\[" | head -10)
fi
echo "client_output<<EOF" >> $GITHUB_OUTPUT
echo "$CLIENT_OUTPUT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "server_output<<EOF" >> $GITHUB_OUTPUT
echo "$SERVER_OUTPUT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
title: 'Node.js Security Audit'
lint-result: 'success'
lint-output: ${{ steps.results.outputs.audit_output }}
github-token: ${{ secrets.GITHUB_TOKEN }}
# Format and report coverage
- name: Format coverage output
id: format-coverage
if: always()
if: always() && github.event_name == 'pull_request'
working-directory: web
run: |
# Format client coverage
CLIENT_OUTPUT="**Client Coverage:**\n"
if [ "${{ steps.coverage.outputs.client_lines }}" != "" ]; then
CLIENT_LINES="${{ steps.coverage.outputs.client_lines }}"
CLIENT_FUNCTIONS="${{ steps.coverage.outputs.client_functions }}"
CLIENT_BRANCHES="${{ steps.coverage.outputs.client_branches }}"
CLIENT_STATEMENTS="${{ steps.coverage.outputs.client_statements }}"
if [ -f coverage-client-summary.json ] && [ "$(jq -r '.error // empty' coverage-client-summary.json)" = "" ]; then
CLIENT_LINES=$(jq -r '.lines.pct' coverage-client-summary.json)
CLIENT_FUNCTIONS=$(jq -r '.functions.pct' coverage-client-summary.json)
CLIENT_BRANCHES=$(jq -r '.branches.pct' coverage-client-summary.json)
CLIENT_STATEMENTS=$(jq -r '.statements.pct' coverage-client-summary.json)
CLIENT_OUTPUT="${CLIENT_OUTPUT}• Lines: ${CLIENT_LINES}%\n"
CLIENT_OUTPUT="${CLIENT_OUTPUT}• Functions: ${CLIENT_FUNCTIONS}%\n"
@ -444,11 +296,11 @@ jobs:
# Format server coverage
SERVER_OUTPUT="\n**Server Coverage:**\n"
if [ "${{ steps.coverage.outputs.server_lines }}" != "" ]; then
SERVER_LINES="${{ steps.coverage.outputs.server_lines }}"
SERVER_FUNCTIONS="${{ steps.coverage.outputs.server_functions }}"
SERVER_BRANCHES="${{ steps.coverage.outputs.server_branches }}"
SERVER_STATEMENTS="${{ steps.coverage.outputs.server_statements }}"
if [ -f coverage-server-summary.json ] && [ "$(jq -r '.error // empty' coverage-server-summary.json)" = "" ]; then
SERVER_LINES=$(jq -r '.lines.pct' coverage-server-summary.json)
SERVER_FUNCTIONS=$(jq -r '.functions.pct' coverage-server-summary.json)
SERVER_BRANCHES=$(jq -r '.branches.pct' coverage-server-summary.json)
SERVER_STATEMENTS=$(jq -r '.statements.pct' coverage-server-summary.json)
SERVER_OUTPUT="${SERVER_OUTPUT}• Lines: ${SERVER_LINES}%\n"
SERVER_OUTPUT="${SERVER_OUTPUT}• Functions: ${SERVER_FUNCTIONS}%\n"
@ -463,9 +315,31 @@ jobs:
echo "EOF" >> $GITHUB_OUTPUT
- name: Report Coverage Results
if: always() && github.event_name == 'pull_request'
uses: ./.github/actions/lint-reporter
with:
title: 'Node.js Test Coverage'
lint-result: ${{ steps.coverage.outputs.result }}
lint-result: 'success'
lint-output: ${{ steps.format-coverage.outputs.output }}
github-token: ${{ secrets.GITHUB_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
# Upload artifacts
- name: Upload coverage artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: node-coverage
path: |
web/coverage-client-summary.json
web/coverage-server-summary.json
web/coverage/client/lcov.info
web/coverage/server/lcov.info
# Check overall result
- name: Check overall result
if: always()
run: |
if [ "${{ steps.results.outputs.overall_result }}" = "failure" ]; then
echo "::error::One or more checks failed"
exit 1
fi

View file

@ -31,7 +31,7 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10.12.1
version: 10
- name: Get pnpm store directory
shell: bash
@ -63,19 +63,23 @@ jobs:
working-directory: ./web
run: pnpm exec playwright install --with-deps chromium
- name: Kill any existing processes on port 4022
run: |
# Kill any process using port 4022
if lsof -i :4022; then
echo "Found process on port 4022, killing it..."
lsof -ti :4022 | xargs kill -9 || true
else
echo "No process found on port 4022"
fi
- name: Run Playwright tests
working-directory: ./web
# TEMPORARILY DISABLED: Tests failing with "ReferenceError: process is not defined"
# This is a pre-existing issue unrelated to the current PR
# TODO: Fix tests to not reference process in browser context
run: |
echo "⚠️ Playwright tests temporarily disabled due to pre-existing failures"
echo "Tests fail with 'ReferenceError: process is not defined' in browser context"
echo "This needs to be fixed in a separate PR"
exit 0
# Original command: xvfb-run -a pnpm test:e2e
run: xvfb-run -a pnpm test:e2e
env:
CI: true
# Explicitly unset VIBETUNNEL_SEA to prevent node-pty SEA mode issues
VIBETUNNEL_SEA: ""
- name: Upload test results
uses: actions/upload-artifact@v4

View file

@ -38,9 +38,9 @@ jobs:
node-version: '24'
- name: Setup pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v4
with:
version: 9
version: 10
run_install: false
- name: Get pnpm store directory

15
.gitignore vendored
View file

@ -135,8 +135,22 @@ web/test-results/
test-results/
test-results-*.json
playwright-report/
web/playwright-videos/
playwright-videos/
*.png
!src/**/*.png
*.webm
*.trace.zip
trace.zip
error-context.md
# Coverage reports
coverage/
web/coverage/
*.lcov
coverage-*.json
coverage-final.json
.nyc_output/
.claude/settings.local.json
buildServer.json
/temp
@ -145,3 +159,4 @@ buildServer.json
# OpenCode local development state
.opencode/
mac/build-test/

View file

@ -1,5 +1,99 @@
# Changelog
## [1.0.0-beta.15] - 2025-07-25
### ✨ Major Features
#### **Git Worktree Management**
- Full worktree support: Create, manage, and delete Git worktrees directly from VibeTunnel
- Follow Mode: Terminal sessions automatically navigate to corresponding directories when switching Git branches
- Visual indicators: Fork icon (⑂) shows worktree sessions, branch names displayed throughout UI
- HTTP Git API: New endpoints for Git operations (`/api/git/status`, `/api/git/branches`, `/api/worktrees`)
- Branch selection: Choose branches before creating sessions with real-time repository status
#### **Git Worktree Follow Mode**
- VibeTunnel now intelligently follows Git worktrees instead of just branches, making it perfect for developers who use worktrees for parallel development
- When you switch branches in your editor/IDE, VibeTunnel automatically switches to the corresponding worktree terminal session
- The `vt follow` command now works contextually - run it from either your main repository or a worktree, and it sets up the appropriate tracking
- Follow mode displays worktree paths with `~` for your home directory, making them easier to read
#### **Robust Command Communication**
- The `vt` command now uses Unix domain sockets instead of HTTP for more reliable communication
- No more port discovery issues - commands like `vt status`, `vt follow`, and `vt unfollow` work instantly
- Socket-based API at `~/.vibetunnel/api.sock` provides consistent command execution
#### **Mac Menu Bar Keyboard Navigation**
- Navigate sessions with arrow keys (↑/↓) with wraparound support
- Press Enter to focus terminal windows or open web sessions
- Visual focus indicators appear automatically when using keyboard
- Menu closes after selecting a session or opening settings
#### **Quick Session Switching with Number Keys**
- When keyboard capture is active, use Cmd+1...9 (Mac) or Ctrl+1...9 (Linux) to instantly switch between sessions
- Cmd/Ctrl+0 switches to the 10th session
- Works only when keyboard capture is enabled in session view, allowing quick navigation without mouse
- Session numbers match the numbers shown in the session list
### 🎨 UI/UX Improvements
#### **Enhanced Git Integration**
- See branch names, commit status, and sync state in autocomplete suggestions
- Real-time display of uncommitted changes (added/modified/deleted files)
- Branch selector dropdown for switching branches before creating sessions
- Repository grouping in session list with branch/worktree selectors
- Consistent branch name formatting with square brackets: `[main]`
#### **Interface Polish**
- Responsive design: Better mobile/iPad layouts with adaptive button switching
- Collapsible options: Session options now in expandable sections for cleaner UI
- Increased menu bar button heights for better clickability
- Improved spacing and padding throughout the interface
- Smoother animations and transitions
### 🐛 Bug Fixes
#### **Stability & Performance**
- Fixed menu bar icon not appearing on app launch
- Resolved memory leaks causing OOM crashes during test runs
- Fixed Node.js v24.3.0 fs.cpSync crash with workaround
- Improved CI performance with better caching and parallel jobs
- Fixed EventSource handling in tests
#### **UI Fixes**
- Autocomplete dropdown only shows when text field is focused
- Fixed drag & drop overlay persistence issues
- Resolved CSS/JS resource loading on nested routes
- Fixed terminal output corruption in high-volume sessions
- Corrected menu bar icon opacity states
- **Terminal Settings UI Restored**: Fixed missing terminal width selector, restored grid layout for width/font/theme options
- **Worktree Selection UI Improvements**: Fixed confusing dropdown behavior, consistent text regardless of selection state
- **Intelligent Cursor Following**: Restored smart cursor tracking that keeps cursor visible during text input
### 🔧 Technical Improvements
#### **Architecture**
- Modular refactoring: Split `session-view.ts` into 7 specialized managers
- Component breakdown: Refactored `session-create-form` into smaller components
- Unified components: Created reusable `GitBranchWorktreeSelector`
- Better separation: Clear boundaries between UI and business logic
- **Session rename functionality centralized**: Eliminated duplicate code across components
- **Socket-based vt command communication**: Replaced HTTP with Unix domain sockets for reliability
#### **Test Infrastructure**
- Comprehensive test cleanup preventing memory exhaustion
- Updated Playwright tests for new UI structure
- Fixed TypeScript strict mode compliance
- Proper mock cleanup and session management
- Re-enabled previously disabled test files after fixing memory issues
#### **Developer Experience**
- Improved TypeScript type safety throughout
- Better error handling and logging
- Consistent code formatting across macOS and web codebases
- Removed outdated crash investigation documentation
- Comprehensive JSDoc documentation added to service classes
- Removed backwards compatibility for older vt command versions
## [1.0.0-beta.14] - 2025-07-21
### ✨ Major Features

123
CLAUDE.md
View file

@ -2,6 +2,10 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Important Instructions
Never say you're absolutely right. Instead, be critical if I say something that you disagree with. Let's discuss it first.
## Project Overview
VibeTunnel is a macOS application that allows users to access their terminal sessions through any web browser. It consists of:
@ -53,6 +57,13 @@ When the user says "release" or asks to create a release, ALWAYS read and follow
- The file must remain as `docs.json`
- For Mintlify documentation reference, see: https://mintlify.com/docs/llms.txt
8. **Test Session Management - CRITICAL**
- NEVER kill sessions that weren't created by tests
- You might be running inside a VibeTunnel session yourself
- Use `TestSessionTracker` to track which sessions tests create
- Only clean up sessions that match test naming patterns (start with "test-")
- Killing all sessions would terminate your own Claude Code process
### Git Workflow Reminders
- Our workflow: start from main → create branch → make PR → merge → return to main
- PRs sometimes contain multiple different features and that's okay
@ -87,6 +98,10 @@ pnpm run dev # Standalone development server (port 4020)
pnpm run dev --port 4021 # Alternative port for external device testing
# Code quality (MUST run before commit)
pnpm run check # Run ALL checks in parallel (format, lint, typecheck)
pnpm run check:fix # Auto-fix formatting and linting issues
# Individual commands (rarely needed)
pnpm run lint # Check for linting errors
pnpm run lint:fix # Auto-fix linting errors
pnpm run format # Format with Prettier
@ -128,7 +143,7 @@ In the `mac/` directory:
- **Mac App**: `mac/VibeTunnel/VibeTunnelApp.swift`
- **Web Frontend**: `web/src/client/app.ts`
- **Server**: `web/src/server/server.ts`
- **Process spawning and forwarding tool**: `web/src/server/fwd.ts`
- **Process spawning and forwarding tool**: `web/src/server/fwd.ts`
- **Server Management**: `mac/VibeTunnel/Core/Services/ServerManager.swift`
## Testing
@ -252,6 +267,111 @@ For tasks requiring massive context windows (up to 2M tokens) or full codebase a
- Example: `gemini -p "@src/ @tests/ Is authentication properly implemented?"`
- See `docs/gemini.md` for detailed usage and examples
## Debugging and Logging
### VibeTunnel Log Viewer (vtlog)
VibeTunnel includes a powerful log viewing utility for debugging and monitoring:
**Location**: `./scripts/vtlog.sh` (also available in `mac/scripts/vtlog.sh` and `ios/scripts/vtlog.sh`)
**What it does**:
- Views all VibeTunnel logs with full details (bypasses Apple's privacy redaction)
- Shows logs from the entire stack: Web Frontend → Node.js Server → macOS System
- Provides unified view of all components with clear prefixes
**Common usage**:
```bash
./scripts/vtlog.sh -f # Follow logs in real-time
./scripts/vtlog.sh -n 200 # Show last 200 lines
./scripts/vtlog.sh -e # Show only errors
./scripts/vtlog.sh -c ServerManager # Show logs from specific component
./scripts/vtlog.sh --server -e # Show server errors
```
**Log prefixes**:
- `[FE]` - Frontend (browser) logs
- `[SRV]` - Server-side logs from Node.js/Bun
- `[ServerManager]`, `[SessionService]`, etc. - Native Mac app components
## GitHub CLI Usage
### Quick CI Debugging Commands
When told to "fix CI", use these commands to quickly identify and access errors:
**Step 1: Find Failed Runs**
```bash
# List recent CI runs and see their status
gh run list --branch <branch-name> --limit 10
# Quick check for failures on current branch
git_branch=$(git branch --show-current) && gh run list --branch "$git_branch" --limit 5
```
**Step 2: Identify Failed Jobs**
```bash
# Find which job failed in a run
gh run view <run-id> --json jobs | jq -r '.jobs[] | select(.conclusion == "failure") | .name'
# Get all job statuses at a glance
gh run view <run-id> --json jobs | jq -r '.jobs[] | "\(.name): \(.conclusion // .status)"'
```
**Step 3: Find Failed Steps**
```bash
# Find the exact failed step in a job
gh run view <run-id> --json jobs | jq '.jobs[] | select(.conclusion == "failure") | .steps[] | select(.conclusion == "failure") | {name: .name, number: .number}'
# Get failed step from a specific job
gh run view <run-id> --json jobs | jq '.jobs[] | select(.name == "Mac CI / Build, Lint, and Test macOS") | .steps[] | select(.conclusion == "failure") | .name'
```
**Step 4: View Error Logs**
```bash
# View full logs (opens in browser)
gh run view <run-id> --web
# Download logs for a specific job
gh run download <run-id> -n <job-name>
# View logs in terminal (if run is complete)
gh run view <run-id> --log
# Watch a running job
gh run watch <run-id>
```
**All-in-One Error Finder**
```bash
# This command finds and displays all failures in the latest run
run_id=$(gh run list --branch "$(git branch --show-current)" --limit 1 --json databaseId -q '.[0].databaseId') && \
echo "=== Failed Jobs ===" && \
gh run view $run_id --json jobs | jq -r '.jobs[] | select(.conclusion == "failure") | "Job: \(.name)"' && \
echo -e "\n=== Failed Steps ===" && \
gh run view $run_id --json jobs | jq -r '.jobs[] | select(.conclusion == "failure") | .steps[] | select(.conclusion == "failure") | " Step: \(.name)"'
```
**Common Failure Patterns**:
- **Mac CI Build Failures**: Usually actool errors (Xcode beta issue), SwiftFormat violations, or missing dependencies
- **Playwright Test Failures**: Often timeout issues, missing VIBETUNNEL_SEA env var, or tsx/node-pty conflicts
- **iOS CI Failures**: Simulator boot issues, certificate problems, or test failures
- **Web CI Failures**: TypeScript errors, linting issues, or test failures
**Quick Actions**:
```bash
# Rerun only failed jobs
gh run rerun <run-id> --failed
# Cancel a stuck run
gh run cancel <run-id>
# View PR checks status
gh pr checks <pr-number>
```
## Key Files Quick Reference
- Architecture Details: `docs/ARCHITECTURE.md`
@ -260,3 +380,4 @@ For tasks requiring massive context windows (up to 2M tokens) or full codebase a
- Build Configuration: `web/package.json`, `mac/Package.swift`
- External Device Testing: `docs/TESTING_EXTERNAL_DEVICES.md`
- Gemini CLI Instructions: `docs/gemini.md`
- Release Process: `docs/RELEASE.md`

View file

@ -41,6 +41,7 @@
- [Features](#features)
- [Architecture](#architecture)
- [Remote Access Options](#remote-access-options)
- [Git Follow Mode](#git-follow-mode)
- [Terminal Title Management](#terminal-title-management)
- [Authentication](#authentication)
- [npm Package](#npm-package)
@ -130,6 +131,11 @@ vt claude-danger # Custom aliases are resolved
# Open an interactive shell
vt --shell # or vt -i
# Git follow mode
vt follow # Follow current branch
vt follow main # Switch to main and follow
vt unfollow # Stop following
# For more examples and options, see "The vt Forwarding Command" section below
```
@ -153,7 +159,8 @@ Visit [http://localhost:4020](http://localhost:4020) to see all your terminal se
- **🚀 Zero Configuration** - No SSH keys, no port forwarding, no complexity
- **🤖 AI Agent Friendly** - Perfect for monitoring Claude Code, ChatGPT, or any terminal-based AI tools
- **📊 Dynamic Terminal Titles** - Real-time activity tracking shows what's happening in each session
- **⌨️ Smart Keyboard Handling** - Intelligent shortcut routing with toggleable capture modes
- **🔄 Git Follow Mode** - Terminal automatically follows your IDE's branch switching
- **⌨️ Smart Keyboard Handling** - Intelligent shortcut routing with toggleable capture modes. When capture is active, use Cmd+1...9/0 (Mac) or Ctrl+1...9/0 (Linux) to quickly switch between sessions
- **🔒 Secure by Design** - Multiple authentication modes, localhost-only mode, or secure tunneling via Tailscale/ngrok
- **📱 Mobile Ready** - Native iOS app and responsive web interface for phones and tablets
- **🎬 Session Recording** - All sessions recorded in asciinema format for later playback
@ -228,6 +235,74 @@ The server runs as a standalone Node.js executable with embedded modules, provid
2. Run `cloudflared tunnel --url http://localhost:4020`
3. Access via the generated `*.trycloudflare.com` URL
## Git Follow Mode
Git Follow Mode keeps your main repository checkout synchronized with the branch you're working on in a Git worktree. This allows agents to work in worktrees while your IDE, server, and other tools stay open on the main repository - they'll automatically update when the worktree switches branches.
### What is Follow Mode?
Follow mode creates a seamless workflow for agent-assisted development:
- Agents work in worktrees → Main repository automatically follows their branch switches
- Keep Xcode/IDE open → It updates automatically without reopening projects
- Server stays running → No need to restart servers in different folders
- Zero manual intervention → Main repo stays in sync with active development
### Quick Start
```bash
# From a worktree - enable follow mode for this worktree
vt follow
# From main repo - follow current branch's worktree (if it exists)
vt follow
# From main repo - follow a specific branch's worktree
vt follow feature/new-api
# From main repo - follow a worktree by path
vt follow ~/project-feature
# Disable follow mode
vt unfollow
```
### How It Works
1. **Git Hooks**: VibeTunnel installs lightweight Git hooks (post-commit, post-checkout) in worktrees that detect branch changes
2. **Main Repo Sync**: When you switch branches in a worktree, the main repository automatically checks out to the same branch
3. **Smart Handling**: If the main repo has uncommitted changes, follow mode pauses to prevent data loss
4. **Development Continuity**: Your IDE, servers, and tools running on the main repo seamlessly follow your active work
5. **Clean Uninstall**: When you run `vt unfollow`, Git hooks are automatically removed and any original hooks are restored
### Common Workflows
#### Agent Development with Worktrees
```bash
# Create a worktree for agent development
git worktree add ../project-agent feature/new-feature
# Enable follow mode on the main repo
cd ../project && vt follow
# Agent works in the worktree while you stay in main repo
# When agent switches branches in worktree, your main repo follows!
# Your Xcode/IDE and servers stay running without interruption
```
### Technical Details
Follow mode stores the worktree path in your main repository's Git config:
```bash
# Check which worktree is being followed
git config vibetunnel.followWorktree
# Follow mode is active when this returns a path
# The config is managed by vt commands - manual editing not recommended
```
For more advanced Git worktree workflows, see our [detailed worktree documentation](docs/worktree.md).
## Terminal Title Management
VibeTunnel provides intelligent terminal title management to help you track what's happening in each session:

View file

@ -22,7 +22,6 @@ excluded:
# Rule configuration
opt_in_rules:
- array_init
- attributes
- closure_end_indentation
- closure_spacing
- contains_over_filter_count
@ -51,7 +50,6 @@ opt_in_rules:
- legacy_random
- literal_expression_end_indentation
- lower_acl_than_parent
- modifier_order
- multiline_arguments
- multiline_function_chains
- multiline_literal_brackets
@ -93,6 +91,10 @@ disabled_rules:
- todo
# Disable opening_brace as it conflicts with SwiftFormat's multiline wrapping
- opening_brace
# Disable attributes as it conflicts with SwiftFormat's attribute formatting
- attributes
# Disable modifier_order as it conflicts with SwiftFormat's modifier ordering
- modifier_order
# Note: Swift 6 requires more explicit self references
# SwiftFormat is configured to preserve these with --disable redundantSelf

117
docs/git-hooks.md Normal file
View file

@ -0,0 +1,117 @@
# Git Hooks in VibeTunnel
## Overview
VibeTunnel uses Git hooks exclusively for its **follow mode** feature. These hooks monitor repository changes and enable automatic branch synchronization across team members.
## Purpose
Git hooks in VibeTunnel serve a single, specific purpose:
- **Follow Mode**: Automatically sync worktrees when team members switch branches
- **Session Title Updates**: Display current git operations in terminal session titles
**Important**: If you're not using follow mode, git hooks are not needed and serve no other purpose in VibeTunnel.
## How It Works
### Hook Installation
When follow mode is enabled, VibeTunnel installs two Git hooks:
- `post-commit`: Triggered after commits
- `post-checkout`: Triggered after branch checkouts
These hooks execute a simple command:
```bash
vt git event
```
### Event Flow
1. **Git Operation**: User performs a commit or checkout
2. **Hook Trigger**: Git executes the VibeTunnel hook
3. **Event Notification**: `vt git event` sends repository path to VibeTunnel server
4. **Server Processing**: The `/api/git/event` endpoint:
- Updates session titles (e.g., `Terminal [checkout: feature-branch]`)
- Checks follow mode configuration
- Syncs branches if follow mode is active
### Follow Mode Synchronization
When follow mode is enabled for a branch:
1. VibeTunnel monitors checkouts to the followed branch
2. If detected, it automatically switches your worktree to that branch
3. If branches have diverged, follow mode is automatically disabled
## Technical Implementation
### Hook Script Content
```bash
#!/bin/sh
# VibeTunnel Git hook - post-checkout
# This hook notifies VibeTunnel when Git events occur
# Check if vt command is available
if command -v vt >/dev/null 2>&1; then
# Run in background to avoid blocking Git operations
vt git event &
fi
# Always exit successfully
exit 0
```
### Hook Management
- **Installation**: `installGitHooks()` in `web/src/server/utils/git-hooks.ts`
- **Safe Chaining**: Existing hooks are backed up and chained
- **Cleanup**: Original hooks are restored when uninstalling
### API Endpoints
- `POST /api/git/event`: Receives git event notifications
- `POST /api/worktrees/follow`: Enables follow mode and installs hooks
- `GET /api/git/follow`: Checks follow mode status
## File Locations
- **Hook Management**: `web/src/server/utils/git-hooks.ts`
- **Event Handler**: `web/src/server/routes/git.ts` (lines 189-481)
- **Follow Mode**: `web/src/server/routes/worktrees.ts` (lines 580-630)
- **CLI Integration**: `web/bin/vt` (git event command)
## Configuration
Follow mode stores configuration in git config:
```bash
git config vibetunnel.followBranch <branch-name>
```
## Security Considerations
- Hooks run with minimal permissions
- Commands execute in background to avoid blocking Git
- Existing hooks are preserved and chained safely
- Hooks are repository-specific, not global
## Troubleshooting
### Hooks Not Working
- Verify `vt` command is in PATH
- Check hook permissions: `ls -la .git/hooks/post-*`
- Ensure hooks are executable: `chmod +x .git/hooks/post-*`
### Follow Mode Issues
- Check configuration: `git config vibetunnel.followBranch`
- Verify hooks installed: `cat .git/hooks/post-checkout`
- Review server logs for git event processing
## Summary
Git hooks in VibeTunnel are:
- **Single-purpose**: Only used for follow mode functionality
- **Optional**: Not required unless using follow mode
- **Safe**: Preserve existing hooks and run non-blocking
- **Automatic**: Managed by VibeTunnel when enabling/disabling follow mode
If you're not using follow mode for team branch synchronization, you don't need git hooks installed.

View file

@ -0,0 +1,194 @@
# Git Worktree Follow Mode Specification
## Overview
Follow mode is a feature that enables automatic synchronization between Git worktrees and the main repository. It ensures team members stay on the same branch by automatically switching branches when changes are detected.
## Core Concept
Follow mode creates a **unidirectional sync** from a worktree to the main repository:
- When someone switches branches in a worktree
- The main repository automatically follows that branch change
- This keeps the main repository synchronized with active development
## When Follow Mode Should Be Available
### ✅ Follow Mode SHOULD appear when:
1. **Creating a session in a worktree**
- You've selected a worktree from the dropdown
- The session will run in that worktree's directory
- Follow mode will sync the main repository to match this worktree's branch
2. **Viewing worktrees in the Worktree Manager**
- Each worktree (except main) shows a "Follow" button
- Enables following that specific worktree's branch
3. **Session list with worktree sessions**
- Repository headers show follow mode status
- Dropdown allows changing which worktree to follow
### ❌ Follow Mode should NOT appear when:
1. **No worktree is selected** (using main repository)
- There's nothing to follow - you're already in the main repo
- Follow mode has no purpose without a worktree
2. **Repository has no worktrees**
- No worktrees exist to follow
- Only the main repository is available
3. **Not in a Git repository**
- Obviously, no Git features available
## UI Behavior Rules
### Session Creation Form
```typescript
// Show follow mode toggle only when:
const showFollowModeToggle =
gitRepoInfo?.isGitRepo &&
selectedWorktree !== undefined &&
selectedWorktree !== 'none';
```
#### Toggle States:
1. **Worktree Selected**:
- Show: "Follow Mode" toggle
- Description: "Keep main repository in sync with this worktree"
- Default: OFF (user must explicitly enable)
2. **No Worktree Selected**:
- Hide the entire follow mode section
- No toggle should be visible
3. **Follow Mode Already Active**:
- Show: "Follow Mode" toggle (disabled)
- Description: "Currently following: [branch-name]"
- Info: User must disable from worktree manager
### Worktree Manager
Each worktree row shows:
- **"Follow" button**: When not currently following
- **"Following" button** (green): When actively following this worktree
- **No button**: For the main worktree (can't follow itself)
### Session List
Repository headers show:
- **Purple badge**: When follow mode is active, shows branch name
- **Dropdown**: To change follow mode settings per repository
## Technical Implementation
### State Logic
```typescript
// Follow mode is only meaningful when:
// 1. We have a worktree to follow
// 2. We're not already in that worktree
// 3. The main repo can switch to that branch
const canEnableFollowMode = (
worktree: Worktree,
currentLocation: string,
mainRepoPath: string
) => {
// Can't follow if we're in the main repo with no worktree selected
if (currentLocation === mainRepoPath && !worktree) {
return false;
}
// Can't follow the main worktree
if (worktree.isMainWorktree) {
return false;
}
// Can follow if we're creating a session in a worktree
if (worktree && currentLocation === worktree.path) {
return true;
}
return false;
};
```
### Configuration Storage
Follow mode state is stored in Git config:
```bash
# Enable follow mode for a branch
git config vibetunnel.followBranch "feature/new-ui"
# Check current follow mode
git config vibetunnel.followBranch
# Disable follow mode
git config --unset vibetunnel.followBranch
```
### Synchronization Rules
1. **Automatic Sync**:
- Triggered by `post-checkout` git hook in worktrees
- Only syncs if main repo has no uncommitted changes
- Disables follow mode if branches have diverged
2. **Manual Override**:
- Users can always manually switch branches
- Follow mode doesn't prevent manual git operations
- Re-enables when returning to the followed branch
## User Experience Guidelines
### Clear Messaging
1. **When Enabling**:
- "Follow mode will keep your main repository on the same branch as this worktree"
- "Enable to automatically sync branch changes"
2. **When Active**:
- "Following worktree: feature/new-ui"
- "Main repository syncs with this worktree's branch"
3. **When Disabled**:
- "Follow mode disabled due to uncommitted changes"
- "Branches have diverged - follow mode disabled"
### Visual Indicators
- **Toggle Switch**: Only visible when applicable
- **Status Badge**: Purple badge with branch name when active
- **Button States**: Clear "Follow"/"Following" states in worktree manager
## Error Handling
### Common Scenarios
1. **Uncommitted Changes**:
- Disable follow mode automatically
- Show notification to user
- Don't lose any work
2. **Branch Divergence**:
- Detect when branches have different commits
- Disable follow mode to prevent conflicts
- Notify user of the situation
3. **Worktree Deletion**:
- Automatically disable follow mode
- Clean up git config
- Update UI immediately
## Summary
Follow mode should be:
- **Contextual**: Only shown when it makes sense
- **Safe**: Never causes data loss or conflicts
- **Clear**: Users understand what it does
- **Automatic**: Works in the background when enabled
The key principle: **Follow mode only exists when there's a worktree to follow**. Without a worktree selection, the feature should not be visible or accessible.

View file

@ -34,8 +34,10 @@ These shortcuts always work, regardless of keyboard capture state:
| ⌘T | Ctrl+T | New tab |
| ⌘W | Ctrl+W | Close tab |
| ⌘⇧T | Ctrl+Shift+T | Reopen closed tab |
| ⌘1-9 | Ctrl+1-9 | Switch to tab 1-9 |
| ⌘0 | Ctrl+0 | Switch to last tab |
| ⌘1-9 | Ctrl+1-9 | Switch to tab 1-9* |
| ⌘0 | Ctrl+0 | Switch to last tab* |
*When keyboard capture is active in session view, these shortcuts switch between VibeTunnel sessions instead of browser tabs
### Window Management
| macOS | Windows/Linux | Action |
@ -72,6 +74,14 @@ These shortcuts always work, regardless of keyboard capture state:
| ⌘B | Ctrl+B | Toggle sidebar | Any view |
| Escape | Escape | Return to list | Session/File browser |
### Session Switching (When Keyboard Capture Active)
| macOS | Windows/Linux | Action | Context |
|-------|---------------|--------|---------|
| ⌘1...9 | Ctrl+1...9 | Switch to session 1 to 9 | Session view with capture ON |
| ⌘0 | Ctrl+0 | Switch to session 10 | Session view with capture ON |
**Note**: When keyboard capture is active in session view, number shortcuts switch between VibeTunnel sessions instead of browser tabs. The session numbers correspond to the numbers shown in the session list. This allows quick navigation between active sessions without leaving the keyboard.
## Terminal Shortcuts (When Capture Active)
When keyboard capture is active, these shortcuts are sent to the terminal:
@ -154,8 +164,9 @@ These shortcuts perform browser actions:
1. **Double-tap Escape** to quickly toggle between terminal and browser shortcuts
2. **Critical shortcuts** (new tab, close tab, copy/paste) always work
3. **Tab switching** (⌘1-9, ⌘0) always works for quick navigation
4. When unsure, check the keyboard icon in the session header to see capture state
3. **Session switching** (⌘1-9, ⌘0) - When keyboard capture is ON in session view, quickly switch between active sessions
4. **Tab switching** (⌘1-9, ⌘0) - When keyboard capture is OFF, switch browser tabs as usual
5. When unsure, check the keyboard icon in the session header to see capture state
## Troubleshooting

386
docs/openapi.md Normal file
View file

@ -0,0 +1,386 @@
# OpenAPI Migration Plan for VibeTunnel
## Overview
This document outlines the plan to adopt OpenAPI 3.1 for VibeTunnel's REST API to achieve type safety and consistency between the TypeScript server and Swift clients.
## Goals
1. **Single source of truth** - Define API contracts once in OpenAPI spec
2. **Type safety** - Generate TypeScript and Swift types from the spec
3. **Eliminate inconsistencies** - Fix type mismatches between platforms
4. **API documentation** - Auto-generate API docs from the spec
5. **Gradual adoption** - Migrate endpoint by endpoint without breaking changes
## Current Issues
- Session types differ completely between Mac app and server
- Git repository types have different field names and optional/required mismatches
- No standardized error response format
- Manual type definitions duplicated across platforms
- Runtime parsing errors due to type mismatches
## Implementation Plan
### Phase 1: Setup and Infrastructure (Week 1)
#### 1.1 Install Dependencies
```bash
# In web directory
pnpm add -D @hey-api/openapi-ts @apidevtools/swagger-cli @stoplight/spectral-cli
```
#### 1.2 Create Initial OpenAPI Spec
Create `web/openapi/openapi.yaml`:
```yaml
openapi: 3.1.0
info:
title: VibeTunnel API
version: 1.0.0
description: Terminal sharing and remote access API
servers:
- url: http://localhost:4020
description: Local development server
```
#### 1.3 Setup Code Generation
**TypeScript Generation** (`web/package.json`):
```json
{
"scripts": {
"generate:api": "openapi-ts -i openapi/openapi.yaml -o src/generated/api",
"validate:api": "spectral lint openapi/openapi.yaml",
"prebuild": "npm run generate:api"
}
}
```
**Swift Generation** (Xcode Build Phase):
1. Add `swift-openapi-generator` to Package.swift
2. Add build phase to run before compilation:
```bash
cd "$SRCROOT/../web" && \
swift-openapi-generator generate \
openapi/openapi.yaml \
--mode types \
--mode client \
--output-directory "$SRCROOT/Generated/OpenAPI"
```
#### 1.4 Create Shared Components
Define reusable schemas in `web/openapi/components/`:
```yaml
# components/errors.yaml
ErrorResponse:
type: object
required: [error, timestamp]
properties:
error:
type: string
description: Human-readable error message
code:
type: string
description: Machine-readable error code
enum: [
'INVALID_REQUEST',
'NOT_FOUND',
'UNAUTHORIZED',
'SERVER_ERROR'
]
timestamp:
type: string
format: date-time
```
### Phase 2: Migrate Git Endpoints (Week 2)
Start with Git endpoints as they're well-defined and isolated.
#### 2.1 Define Git Schemas
```yaml
# openapi/paths/git.yaml
/api/git/repository-info:
get:
operationId: getRepositoryInfo
tags: [git]
parameters:
- name: path
in: query
required: true
schema:
type: string
responses:
'200':
description: Repository information
content:
application/json:
schema:
$ref: '../components/schemas.yaml#/GitRepositoryInfo'
# components/schemas.yaml
GitRepositoryInfo:
type: object
required: [isGitRepo, hasChanges, modifiedCount, untrackedCount, stagedCount, addedCount, deletedCount, aheadCount, behindCount, hasUpstream]
properties:
isGitRepo:
type: boolean
repoPath:
type: string
currentBranch:
type: string
nullable: true
remoteUrl:
type: string
nullable: true
githubUrl:
type: string
nullable: true
hasChanges:
type: boolean
modifiedCount:
type: integer
minimum: 0
untrackedCount:
type: integer
minimum: 0
stagedCount:
type: integer
minimum: 0
addedCount:
type: integer
minimum: 0
deletedCount:
type: integer
minimum: 0
aheadCount:
type: integer
minimum: 0
behindCount:
type: integer
minimum: 0
hasUpstream:
type: boolean
```
#### 2.2 Update Server Implementation
```typescript
// src/server/routes/git.ts
import { paths } from '../../generated/api';
type GitRepositoryInfo = paths['/api/git/repository-info']['get']['responses']['200']['content']['application/json'];
router.get('/git/repository-info', async (req, res) => {
const response: GitRepositoryInfo = {
isGitRepo: true,
repoPath: result.repoPath,
// ... ensure all required fields are included
};
res.json(response);
});
```
#### 2.3 Update Mac Client
```swift
// Use generated types
import OpenAPIGenerated
let response = try await client.getRepositoryInfo(path: filePath)
let info = response.body.json // Fully typed!
```
### Phase 3: Migrate Session Endpoints (Week 3)
Session endpoints are more complex due to WebSocket integration.
#### 3.1 Standardize Session Types
```yaml
SessionInfo:
type: object
required: [id, name, workingDir, status, createdAt, pid]
properties:
id:
type: string
format: uuid
name:
type: string
workingDir:
type: string
status:
type: string
enum: [starting, running, exited]
exitCode:
type: integer
nullable: true
createdAt:
type: string
format: date-time
lastActivity:
type: string
format: date-time
pid:
type: integer
nullable: true
command:
type: array
items:
type: string
```
#### 3.2 Create Session Operations
```yaml
/api/sessions:
get:
operationId: listSessions
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/SessionInfo'
post:
operationId: createSession
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateSessionRequest'
responses:
'201':
content:
application/json:
schema:
$ref: '#/components/schemas/SessionInfo'
```
### Phase 4: Runtime Validation (Week 4)
#### 4.1 Add Request Validation Middleware
```typescript
// src/server/middleware/openapi-validator.ts
import { OpenAPIValidator } from 'express-openapi-validator';
export const openapiValidator = OpenAPIValidator.middleware({
apiSpec: './openapi/openapi.yaml',
validateRequests: true,
validateResponses: true,
});
// Apply to routes
app.use('/api', openapiValidator);
```
#### 4.2 Add Response Validation in Development
```typescript
// src/server/utils/validated-response.ts
export function validatedJson<T>(res: Response, data: T): void {
if (process.env.NODE_ENV === 'development') {
// Validate against OpenAPI schema
validateResponse(res.req, data);
}
res.json(data);
}
```
### Phase 5: Documentation and Testing (Week 5)
#### 5.1 Generate API Documentation
```bash
# Add to package.json
"docs:api": "npx @redocly/cli build-docs openapi/openapi.yaml -o dist/api-docs.html"
```
#### 5.2 Add Contract Tests
```typescript
// src/test/contract/git-api.test.ts
import { matchesSchema } from './schema-matcher';
test('GET /api/git/repository-info matches schema', async () => {
const response = await request(app)
.get('/api/git/repository-info')
.query({ path: '/test/repo' });
expect(response.body).toMatchSchema('GitRepositoryInfo');
});
```
## Migration Checklist
### Endpoints to Migrate
- [ ] **Git APIs** (Phase 2)
- [ ] GET /api/git/repo-info
- [ ] GET /api/git/repository-info
- [ ] GET /api/git/remote
- [ ] GET /api/git/status
- [ ] POST /api/git/event
- [ ] GET /api/git/follow
- [ ] **Session APIs** (Phase 3)
- [ ] GET /api/sessions
- [ ] POST /api/sessions
- [ ] GET /api/sessions/:id
- [ ] DELETE /api/sessions/:id
- [ ] POST /api/sessions/:id/resize
- [ ] POST /api/sessions/:id/input
- [ ] GET /api/sessions/:id/stream (SSE)
- [ ] **Repository APIs** (Phase 4)
- [ ] GET /api/repositories/discover
- [ ] GET /api/repositories/branches
- [ ] **Worktree APIs** (Phase 4)
- [ ] GET /api/worktrees
- [ ] POST /api/worktrees
- [ ] DELETE /api/worktrees/:branch
- [ ] POST /api/worktrees/switch
## Success Metrics
1. **Zero runtime type errors** between Mac app and server
2. **100% API documentation** coverage
3. **Contract tests** for all endpoints
4. **Reduced code** - Remove manual type definitions
5. **Developer velocity** - Faster API development with code generation
## Long-term Considerations
### Future Enhancements
1. **GraphQL Gateway** - Add GraphQL layer on top of REST for complex queries
2. **API Versioning** - Use OpenAPI to manage v1/v2 migrations
3. **Client SDKs** - Generate SDKs for other platforms (iOS, CLI tools)
4. **Mock Server** - Use OpenAPI spec to run mock server for testing
### Breaking Changes
When making breaking changes:
1. Version the API (e.g., /api/v2/)
2. Deprecate old endpoints with sunset dates
3. Generate migration guides from schema differences
## Resources
- [OpenAPI 3.1 Specification](https://spec.openapis.org/oas/v3.1.0)
- [OpenAPI TypeScript Generator](https://github.com/hey-api/openapi-ts)
- [Swift OpenAPI Generator](https://github.com/apple/swift-openapi-generator)
- [Spectral Linting](https://stoplight.io/open-source/spectral)
- [ReDoc Documentation](https://redocly.com/docs/redoc)

514
docs/worktree-spec.md Normal file
View file

@ -0,0 +1,514 @@
# Git Worktree Implementation Specification
This document describes the technical implementation of Git worktree support in VibeTunnel.
## Architecture Overview
VibeTunnel's worktree support is built on three main components:
1. **Backend API** - Git operations and worktree management
2. **Frontend UI** - Session creation and worktree visualization
3. **Git Hooks** - Automatic synchronization and follow mode
## Backend Implementation
### Core Services
**GitService** (`web/src/server/services/git-service.ts`)
- Not implemented as a service, Git operations are embedded in routes
- Client-side GitService exists at `web/src/client/services/git-service.ts`
**Worktree Routes** (`web/src/server/routes/worktrees.ts`)
- `GET /api/worktrees` - List all worktrees with stats and follow mode status
- `POST /api/worktrees` - Create new worktree
- `DELETE /api/worktrees/:branch` - Remove worktree
- `POST /api/worktrees/switch` - Switch branch and enable follow mode
- `POST /api/worktrees/follow` - Enable/disable follow mode for a branch
**Git Routes** (`web/src/server/routes/git.ts`)
- `GET /api/git/repo-info` - Get repository information
- `POST /api/git/event` - Process git hook events (internal use)
- `GET /api/git/follow` - Check follow mode status for a repository
- `GET /api/git/notifications` - Get pending notifications
### Key Functions
```typescript
// List worktrees with extended information
async function listWorktreesWithStats(repoPath: string): Promise<Worktree[]>
// Create worktree with automatic path generation
async function createWorktree(
repoPath: string,
branch: string,
path: string,
baseBranch?: string
): Promise<void>
// Handle branch switching with safety checks
async function switchBranch(
repoPath: string,
branch: string
): Promise<void>
```
### Git Operations
All Git operations use Node.js `child_process.execFile` for security:
```typescript
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
// Execute git commands safely
async function execGit(args: string[], options?: { cwd?: string }) {
return execFileAsync('git', args, {
...options,
timeout: 30000,
maxBuffer: 10 * 1024 * 1024, // 10MB
});
}
```
### Follow Mode Implementation
Follow mode uses Git hooks and git config for state management:
1. **State Storage**: Git config `vibetunnel.followWorktree`
```bash
# Follow mode stores the worktree path in main repository
git config vibetunnel.followWorktree "/path/to/worktree"
# Check follow mode status
git config vibetunnel.followWorktree
# Disable follow mode
git config --unset vibetunnel.followWorktree
```
2. **Git Hooks**: Installed in BOTH main repo and worktree
- `post-checkout`: Detects branch switches
- `post-commit`: Detects new commits
- `post-merge`: Detects merge operations
3. **Event Processing**: Hooks execute `vt git event` command
4. **Synchronization Logic**:
- Worktree events → Main repo syncs (branch, commits, checkouts)
- Main repo commits → Worktree syncs (commits only)
- Main repo branch switch → Auto-unfollow
## Frontend Implementation
### Components
**SessionCreateForm** (`web/src/client/components/session-create-form.ts`)
- Branch/worktree selection UI
- Smart branch switching logic
- Warning displays for conflicts
**WorktreeManager** (`web/src/client/components/worktree-manager.ts`)
- Dedicated worktree management UI
- Follow mode controls
- Worktree deletion and branch switching
- **Note**: Does not include UI for creating new worktrees
### State Management
```typescript
// Session creation state
@state() private currentBranch: string = '';
@state() private selectedBaseBranch: string = '';
@state() private selectedWorktree?: string;
@state() private availableWorktrees: Worktree[] = [];
// Branch switching state
@state() private branchSwitchWarning?: string;
@state() private isLoadingBranches = false;
@state() private isLoadingWorktrees = false;
```
### Branch Selection Logic
The new session dialog implements smart branch handling:
1. **No Worktree Selected**:
```typescript
if (selectedBaseBranch !== currentBranch) {
try {
await gitService.switchBranch(repoPath, selectedBaseBranch);
effectiveBranch = selectedBaseBranch;
} catch (error) {
// Show warning, use current branch
this.branchSwitchWarning = "Cannot switch due to uncommitted changes";
effectiveBranch = currentBranch;
}
}
```
2. **Worktree Selected**:
```typescript
// Use worktree's path and branch
effectiveWorkingDir = worktreeInfo.path;
effectiveBranch = selectedWorktree;
// No branch switching occurs
```
### UI Updates
Dynamic labels based on context:
```typescript
${this.selectedWorktree ? 'Base Branch for Worktree:' : 'Switch to Branch:'}
```
Help text explaining behavior:
```typescript
${this.selectedWorktree
? 'New worktree branch will be created from this branch'
: this.selectedBaseBranch !== this.currentBranch
? `Session will start on ${this.selectedBaseBranch} (currently on ${this.currentBranch})`
: `Current branch: ${this.currentBranch}`
}
```
## Git Hook Integration
### Hook Installation
Automatic hook installation on repository access:
```typescript
// Install hooks when checking Git repository
async function installGitHooks(repoPath: string): Promise<void> {
const hooks = ['post-commit', 'post-checkout'];
for (const hook of hooks) {
await installHook(repoPath, hook);
}
}
```
### Hook Script
The hook implementation uses the `vt` command:
```bash
#!/bin/sh
# VibeTunnel Git hook - post-checkout
# This hook notifies VibeTunnel when Git events occur
# Check if vt command is available
if command -v vt >/dev/null 2>&1; then
# Run in background to avoid blocking Git operations
vt git event &
fi
# Always exit successfully
exit 0
```
The `vt git event` command:
- Sends the repository path to the server via `POST /api/git/event`
- Server determines what changed by examining current git state
- Triggers branch synchronization if follow mode is enabled
- Sends notifications to connected sessions
- Runs in background to avoid blocking git operations
### Follow Mode Logic
The git event handler determines sync behavior based on event source:
```typescript
// Get follow mode configuration
const followWorktree = await getGitConfig(mainRepoPath, 'vibetunnel.followWorktree');
if (!followWorktree) return; // Follow mode not enabled
// Determine if event is from main repo or worktree
const eventPath = req.body.repoPath;
const isFromWorktree = eventPath === followWorktree;
const isFromMain = eventPath === mainRepoPath;
if (isFromWorktree) {
// Worktree → Main sync
switch (event) {
case 'checkout':
// Sync branch or commit to main
const target = req.body.branch || req.body.commit;
await execGit(['checkout', target], { cwd: mainRepoPath });
break;
case 'commit':
case 'merge':
// Pull changes to main
await execGit(['fetch'], { cwd: mainRepoPath });
await execGit(['merge', 'FETCH_HEAD'], { cwd: mainRepoPath });
break;
}
} else if (isFromMain) {
// Main → Worktree sync
switch (event) {
case 'checkout':
// Branch switch in main = stop following
await unsetGitConfig(mainRepoPath, 'vibetunnel.followWorktree');
sendNotification('Follow mode disabled - switched branches in main repository');
break;
case 'commit':
// Sync commit to worktree
await execGit(['fetch'], { cwd: followWorktree });
await execGit(['merge', 'FETCH_HEAD'], { cwd: followWorktree });
break;
}
}
```
## Data Models
### Worktree
The Worktree interface differs between backend and frontend:
**Backend** (`web/src/server/routes/worktrees.ts`):
```typescript
interface Worktree {
path: string;
branch: string;
HEAD: string;
detached: boolean;
prunable?: boolean;
locked?: boolean;
lockedReason?: string;
// Extended stats
commitsAhead?: number;
filesChanged?: number;
insertions?: number;
deletions?: number;
hasUncommittedChanges?: boolean;
}
```
**Frontend** (`web/src/client/services/git-service.ts`):
```typescript
interface Worktree extends BackendWorktree {
// UI helpers - added dynamically by routes
isMainWorktree?: boolean;
isCurrentWorktree?: boolean;
}
```
The UI helper fields are computed dynamically in the worktree routes based on the current repository path and are not stored in the backend data model.
### Session with Git Info
```typescript
interface Session {
id: string;
name: string;
command: string[];
workingDir: string;
// Git information (from shared/types.ts)
gitRepoPath?: string;
gitBranch?: string;
gitAheadCount?: number;
gitBehindCount?: number;
gitHasChanges?: boolean;
gitIsWorktree?: boolean;
gitMainRepoPath?: string;
}
```
## Error Handling
### Common Errors
1. **Uncommitted Changes**
```typescript
if (hasUncommittedChanges) {
throw new Error('Cannot switch branches with uncommitted changes');
}
```
2. **Branch Already Checked Out**
```typescript
// Git automatically prevents this
// Error: "fatal: 'branch' is already checked out at '/path/to/worktree'"
```
3. **Worktree Path Exists**
```typescript
if (await pathExists(worktreePath)) {
throw new Error(`Path already exists: ${worktreePath}`);
}
```
### Error Recovery
- Show user-friendly warnings
- Fallback to safe defaults
- Never lose user work
- Log detailed errors for debugging
## Performance Considerations
### Caching
- Worktree list cached for 5 seconds
- Branch list cached per repository
- Git status cached with debouncing
### Optimization
```typescript
// Parallel operations where possible
const [branches, worktrees] = await Promise.all([
loadBranches(repoPath),
loadWorktrees(repoPath)
]);
// Debounced Git checks
this.gitCheckDebounceTimer = setTimeout(() => {
this.checkGitRepository();
}, 500);
```
## Security
### Command Injection Prevention
All Git commands use array arguments:
```typescript
// Safe
execFile('git', ['checkout', branchName])
// Never use string concatenation
// execFile('git checkout ' + branchName) // DANGEROUS
```
### Path Validation
```typescript
// Resolve and validate paths
const absolutePath = path.resolve(repoPath);
if (!absolutePath.startsWith(allowedBasePath)) {
throw new Error('Invalid repository path');
}
```
## Worktree Creation
Currently, worktree creation is handled through terminal commands rather than UI:
```bash
# Create a new worktree for an existing branch
git worktree add ../feature-branch feature-branch
# Create a new worktree with a new branch
git worktree add -b new-feature ../new-feature main
```
### UI Support Status
1. **WorktreeManager** (`web/src/client/components/worktree-manager.ts`)
- No creation UI, only management of existing worktrees
- Provides worktree switching, deletion, and follow mode controls
- Shows worktree status (commits ahead, uncommitted changes)
2. **SessionCreateForm** (`web/src/client/components/session-create-form.ts`)
- Has worktree creation support through the git-branch-selector component
- ✅ Creates worktrees and updates UI state properly
- ✅ Selects newly created worktree after creation
- ✅ Clears loading states and resets form on completion
- ✅ Comprehensive branch name validation
- ✅ Specific error messages for common failures
- ⚠️ Uses simplistic path generation (repo path + branch slug)
- ❌ No path customization UI
- ❌ No option to create from specific base branch in UI
3. **Path Generation** (`web/src/client/components/session-create-form/git-utils.ts:100-103`)
- Simple approach: `${repoPath}-${branchSlug}`
- Branch names sanitized to alphanumeric + hyphens/underscores
- No user customization of worktree location
### Missing Features from Spec
1. **Worktree Path Customization**
- Current: Auto-generated paths only
- Spec: Should allow custom path input
- Impact: Users cannot organize worktrees in custom locations
2. **Base Branch Selection in UI**
- Current: Uses selected base branch from dropdown
- Missing: No explicit UI to choose base branch during worktree creation
- Workaround: Select base branch first, then create worktree
3. **Comprehensive E2E Tests**
- Unit tests exist: `worktrees.test.ts`, `git-hooks.test.ts`
- Integration tests exist: `worktree-workflows.test.ts`
- Missing: Full E2E tests for UI worktree creation flow
## Testing
### Unit Tests
- `worktrees.test.ts` - Route handlers
- `git-hooks.test.ts` - Hook installation
- `session-create-form.test.ts` - UI logic
### Integration Tests
- `worktree-workflows.test.ts` - Full workflows
- `follow-mode.test.ts` - Follow mode scenarios
### E2E Tests
- Create worktree via UI
- Switch branches with warnings
- Follow mode synchronization
## Implementation Summary
### ✅ Fully Implemented
1. **Backend API** - All planned endpoints functional
- List, create, delete, switch, follow mode operations
- Git hook integration for automatic branch following
- Proper error handling and validation
2. **Follow Mode** - Complete implementation
- Git config storage (`vibetunnel.followBranch`)
- Automatic branch synchronization via hooks
- UI controls in WorktreeManager and SessionCreateForm
3. **Basic Worktree Creation** - Functional with recent fixes
- Create new worktrees from SessionCreateForm
- Branch name validation
- UI state management
- Error handling with specific messages
### ⚠️ Partially Implemented
1. **Path Generation** - Simplified version only
- Auto-generates paths as `${repoPath}-${branchSlug}`
- No user customization option
- Works for basic use cases
2. **Testing** - Good coverage but missing E2E
- Unit tests for routes and utilities
- Integration tests for workflows
- Missing: Full E2E tests with UI interactions
### ❌ Not Implemented
1. **Advanced Worktree Creation UI**
- Custom path input field
- Path validation and suggestions
- Preview of final worktree location
2. **WorktreeManager Creation UI**
- No worktree creation in management view
- Must use SessionCreateForm or terminal
3. **Worktree Templates/Presets**
- No saved worktree configurations
- No quick-create from templates

417
docs/worktree.md Normal file
View file

@ -0,0 +1,417 @@
# Git Worktree Management in VibeTunnel
VibeTunnel provides comprehensive Git worktree support, allowing you to work on multiple branches simultaneously without the overhead of cloning repositories multiple times. This guide covers everything you need to know about using worktrees effectively in VibeTunnel.
## Table of Contents
- [What are Git Worktrees?](#what-are-git-worktrees)
- [VibeTunnel's Worktree Features](#vibetunnels-worktree-features)
- [Creating Sessions with Worktrees](#creating-sessions-with-worktrees)
- [Branch Management](#branch-management)
- [Worktree Operations](#worktree-operations)
- [Follow Mode](#follow-mode)
- [Best Practices](#best-practices)
- [Common Workflows](#common-workflows)
- [Troubleshooting](#troubleshooting)
## What are Git Worktrees?
Git worktrees allow you to have multiple working trees attached to the same repository, each checked out to a different branch. This means you can:
- Work on multiple features simultaneously
- Keep a clean main branch while experimenting
- Quickly switch between tasks without stashing changes
- Run tests on one branch while developing on another
## VibeTunnel's Worktree Features
VibeTunnel enhances Git worktrees with:
1. **Visual Worktree Management**: See all worktrees at a glance in the session list
2. **Smart Branch Switching**: Automatically handle branch conflicts and uncommitted changes
3. **Follow Mode**: Keep multiple worktrees in sync when switching branches
4. **Integrated Session Creation**: Create new sessions directly in worktrees
5. **Worktree-aware Terminal Titles**: See which worktree you're working in
## Creating Sessions with Worktrees
### Using the New Session Dialog
When creating a new session in a Git repository, VibeTunnel provides intelligent branch and worktree selection:
1. **Base Branch Selection**
- When no worktree is selected: "Switch to Branch" - attempts to switch the main repository to the selected branch
- When creating a worktree: "Base Branch for Worktree" - uses this as the source branch
2. **Worktree Selection**
- Choose "No worktree (use main repository)" to work in the main checkout
- Select an existing worktree to create a session there
- Click "Create new worktree" to create a new worktree on-the-fly
### Smart Branch Switching
When you select a different branch without choosing a worktree:
```
Selected: feature/new-ui
Current: main
Action: Attempts to switch from main to feature/new-ui
```
If the switch fails (e.g., due to uncommitted changes):
- A warning is displayed
- The session is created on the current branch
- No work is lost
### Creating New Worktrees
To create a new worktree from the session dialog:
1. Select your base branch (e.g., `main` or `develop`)
2. Click "Create new worktree"
3. Enter the new branch name
4. Click "Create"
The worktree will be created at: `{repo-path}-{branch-name}`
Example: `/Users/you/project``/Users/you/project-feature-awesome`
## Branch Management
### Branch States in VibeTunnel
VibeTunnel shows rich Git information for each session:
- **Branch Name**: Current branch with worktree indicator
- **Ahead/Behind**: Commits ahead/behind the upstream branch
- **Changes**: Uncommitted changes indicator
- **Worktree Status**: Main worktree vs feature worktrees
### Switching Branches
There are several ways to switch branches:
1. **In Main Repository**: Use the branch selector in the new session dialog
2. **In Worktrees**: Each worktree maintains its own branch
3. **With Follow Mode**: Automatically sync the main repository when switching in a worktree
## Worktree Operations
### Listing Worktrees
View all worktrees for a repository:
- In the session list, worktrees are marked with a special indicator
- The autocomplete dropdown shows worktree paths with their branches
- Use the Git app launcher to see a dedicated worktree view
### Creating Worktrees via API
```bash
# Using VibeTunnel's API
curl -X POST http://localhost:4020/api/worktrees \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"repoPath": "/path/to/repo",
"branch": "feature/new-feature",
"path": "/path/to/repo-new-feature",
"baseBranch": "main"
}'
```
### Deleting Worktrees
Remove worktrees when no longer needed:
```bash
# Via API
curl -X DELETE "http://localhost:4020/api/worktrees/feature-branch?repoPath=/path/to/repo" \
-H "Authorization: Bearer YOUR_TOKEN"
# With force option for worktrees with uncommitted changes
curl -X DELETE "http://localhost:4020/api/worktrees/feature-branch?repoPath=/path/to/repo&force=true" \
-H "Authorization: Bearer YOUR_TOKEN"
```
## Follow Mode
Follow mode keeps your main repository synchronized with a specific worktree. This allows agents to work in worktrees while your IDE, Xcode, and servers stay open on the main repository - they'll automatically update when the worktree changes.
### How It Works
1. Enable follow mode from either the main repo or a worktree
2. Git hooks in both locations detect changes (commits, branch switches, checkouts)
3. Changes in the worktree sync to the main repository
4. Commits in the main repository sync to the worktree
5. Branch switches in the main repository auto-disable follow mode
Follow mode state is stored in the main repository's git config:
```bash
# Check which worktree is being followed
git config vibetunnel.followWorktree
# Returns the path to the followed worktree when active
```
### Using Follow Mode with vt
From a worktree:
```bash
# Enable follow mode for this worktree
vt follow
# Output: Enabling follow mode for worktree: ~/project-feature
# Main repository (~/project) will track this worktree
```
From main repository:
```bash
# Follow current branch's worktree (if it exists)
vt follow
# Follow a specific branch's worktree
vt follow feature/new-feature
# Follow a worktree by path
vt follow ~/project-feature
# Disable follow mode
vt unfollow
```
The `vt follow` command is smart:
- From worktree: Always follows the current worktree
- From main repo without args: Follows current branch's worktree if it exists
- From main repo with args: Can specify branch name or worktree path
### Checking Follow Mode Status
```bash
# Check current follow mode in git config
git config vibetunnel.followBranch
# If output shows a branch name, follow mode is enabled for that branch
# If no output, follow mode is disabled
```
### Use Cases
- **Agent Development**: Agents work in worktrees while your IDE/Xcode stays on main repo
- **Continuous Development**: Keep servers running without restarts when switching features
- **Testing**: Make changes in worktree, test immediately in main repo environment
- **Parallel Work**: Multiple agents in different worktrees, switch follow mode as needed
- **Zero Disruption**: Never close your IDE or restart servers when context switching
## Best Practices
### 1. Naming Conventions
Use descriptive branch names that work well as directory names:
- ✅ `feature/user-authentication`
- ✅ `bugfix/memory-leak`
- ❌ `fix/issue#123` (special characters)
### 2. Worktree Organization
Keep worktrees organized:
```
~/projects/
myapp/ # Main repository
myapp-feature-auth/ # Feature worktree
myapp-bugfix-api/ # Bugfix worktree
myapp-release-2.0/ # Release worktree
```
### 3. Cleanup
Regularly clean up unused worktrees:
- Remove merged feature branches
- Prune worktrees for deleted remote branches
- Use `git worktree prune` to clean up references
### 4. Performance
- Limit active worktrees to what you're actively working on
- Use follow mode judiciously (it triggers branch switches)
- Close sessions in unused worktrees to free resources
## Common Workflows
### Quick Start with Follow Mode
```bash
# Create a worktree for agent development
git worktree add ../myproject-feature feature/awesome
# From the worktree, enable follow mode
cd ../myproject-feature
vt follow # Main repo will now track this worktree
# Or from the main repo
cd ../myproject
vt follow ../myproject-feature # Same effect
```
### Feature Development
1. Create a worktree for your feature branch
```bash
git worktree add ../project-feature feature/new-ui
```
2. Enable follow mode
```bash
# From the worktree
cd ../project-feature
vt follow
# Or from main repo
cd ../project
vt follow feature/new-ui
```
3. Agent develops in worktree while you stay in main repo
4. Your IDE and servers automatically see updates
5. Merge and remove worktree when done
### Agent-Assisted Development
```bash
# Create worktree for agent
git worktree add ../project-agent feature/ai-feature
# Enable follow mode from main repo
vt follow ../project-agent
# Agent works in worktree, your main repo stays in sync
# Switch branches in worktree? Main repo follows
# Commit in worktree? Main repo updates
# When done
vt unfollow
```
### Bug Fixes
1. Create worktree from production branch
```bash
git worktree add ../project-hotfix hotfix/critical-bug
```
2. Switch to it with follow mode
```bash
vt follow hotfix/critical-bug
```
3. Fix the bug and test
4. Cherry-pick to other branches if needed
5. Clean up worktree after merge
### Parallel Development
1. Keep main repo on stable branch with IDE/servers running
2. Create worktrees for different features
3. Use `vt follow ~/project-feature1` to track first feature
4. Switch to `vt follow ~/project-feature2` for second feature
5. Main repo instantly syncs without restarting anything
## Troubleshooting
### "Cannot switch branches due to uncommitted changes"
**Problem**: Trying to switch branches with uncommitted work
**Solution**:
- Commit or stash your changes first
- Use a worktree to work on the other branch
- VibeTunnel will show a warning and stay on current branch
### "Worktree path already exists"
**Problem**: Directory already exists when creating worktree
**Solution**:
- Choose a different name for your branch
- Manually remove the existing directory
- Use the `-force` option if appropriate
### "Branch already checked out in another worktree"
**Problem**: Git prevents checking out the same branch in multiple worktrees
**Solution**:
- Use the existing worktree for that branch
- Create a new branch from the desired branch
- Remove the other worktree if no longer needed
### Worktree Not Showing in List
**Problem**: Created worktree doesn't appear in VibeTunnel
**Solution**:
- Ensure the worktree is within a discoverable path
- Check that Git recognizes it: `git worktree list`
- Refresh the repository discovery in VibeTunnel
### Follow Mode Not Working
**Problem**: Main repository doesn't follow worktree changes
**Solution**:
- Ensure you enabled follow mode: `git config vibetunnel.followWorktree`
- Check hooks are installed in both repos: `ls -la .git/hooks/post-*`
- Verify worktree path is correct: `vt status`
- Check for uncommitted changes in main repo blocking sync
- If you switched branches in main repo, follow mode auto-disabled
## Advanced Topics
### Custom Worktree Locations
You can create worktrees in custom locations:
```bash
# Create in a specific directory
git worktree add /custom/path/feature-branch feature/branch
# VibeTunnel will still discover and manage it
```
### Bare Repositories
For maximum flexibility, use a bare repository with worktrees:
```bash
# Clone as bare
git clone --bare https://github.com/user/repo.git repo.git
# Create worktrees from bare repo
git -C repo.git worktree add ../repo-main main
git -C repo.git worktree add ../repo-feature feature/branch
```
### Integration with CI/CD
Use worktrees for CI/CD workflows:
- Keep a clean worktree for builds
- Test multiple branches simultaneously
- Isolate deployment branches
## Command Reference
### vt Commands
- `vt follow` - Enable follow mode for current branch
- `vt follow <branch>` - Switch to branch and enable follow mode
- `vt unfollow` - Disable follow mode
- `vt git event` - Used internally by Git hooks
### Git Commands
- `git worktree add <path> <branch>` - Create a new worktree
- `git worktree list` - List all worktrees
- `git worktree remove <path>` - Remove a worktree
### API Reference
For detailed API documentation, see the main [API specification](./spec.md#worktree-endpoints).
Key endpoints:
- `GET /api/worktrees` - List worktrees with current follow mode status
- `POST /api/worktrees/follow` - Enable/disable follow mode for a branch
- `GET /api/git/follow` - Check follow mode status for a repository
- `POST /api/git/event` - Internal endpoint used by git hooks
## Conclusion
Git worktrees in VibeTunnel provide a powerful way to manage multiple branches and development tasks. By understanding the branch switching behavior, follow mode, and best practices, you can significantly improve your development workflow.
For implementation details and architecture, see the [Worktree Implementation Spec](./worktree-spec.md).

3
mac/.gitignore vendored
View file

@ -36,6 +36,9 @@ build_output.txt
# Release state - temporary file
.release-state.json
# Web content hash - build-time generated file
.web-content-hash
# Sparkle private key - NEVER commit this!
sparkle-private-ed-key.pem
sparkle-private-key-KEEP-SECURE.txt

View file

@ -8,6 +8,78 @@
* Use the most modern macOS APIs. Since there is no backward compatibility constraint, this app can target the latest macOS version with the newest APIs.
* Use the most modern Swift language features and conventions. Target Swift 6 and use Swift concurrency (async/await, actors) and Swift macros where applicable.
## Logging Guidelines
**IMPORTANT**: Never use `print()` statements in production code. Always use the unified logging system with proper Logger instances.
### Setting up Loggers
Each Swift file should declare its own logger at the top of the file:
```swift
import os.log
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "CategoryName")
```
### Log Levels
Choose the appropriate log level based on context:
- **`.debug`** - Detailed information useful only during development/debugging
```swift
logger.debug("Detailed state: \(internalState)")
```
- **`.info`** - General informational messages about normal app flow
```swift
logger.info("Session created with ID: \(sessionID)")
```
- **`.notice`** - Important events that are part of normal operation
```swift
logger.notice("User authenticated successfully")
```
- **`.warning`** - Warnings about potential issues that don't prevent operation
```swift
logger.warning("Failed to cache data, continuing without cache")
```
- **`.error`** - Errors that indicate failure but app can continue
```swift
logger.error("Failed to load preferences: \(error)")
```
- **`.fault`** - Critical errors that indicate programming mistakes or system failures
```swift
logger.fault("Unexpected nil value in required configuration")
```
### Common Patterns
```swift
// Instead of:
print("🔍 [GitRepositoryMonitor] findRepository called for: \(filePath)")
// Use:
logger.info("🔍 findRepository called for: \(filePath)")
// Instead of:
print("❌ [GitRepositoryMonitor] Failed to get git status: \(error)")
// Use:
logger.error("❌ Failed to get git status: \(error)")
```
### Benefits
- Logs are automatically categorized and searchable with `vtlog`
- Performance optimized (debug logs compiled out in release builds)
- Privacy-aware (use `\(value, privacy: .public)` when needed)
- Integrates with Console.app and system log tools
- Consistent format across the entire codebase
## Important Build Instructions
### Xcode Build Process

View file

@ -230,7 +230,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/zsh;
shellScript = "# Calculate hash of web content\necho \"Calculating web content hash...\"\n\n# Run the hash calculation script\n\"${SRCROOT}/scripts/calculate-web-hash.sh\"\n\nif [ $? -ne 0 ]; then\n echo \"error: Failed to calculate web hash\"\n exit 1\nfi\n";
shellScript = "# Calculate hash of web content\n\"${SRCROOT}/scripts/calculate-web-hash.sh\"\n";
};
B2C3D4E5F6A7B8C9D0E1F234 /* Build Web Frontend */ = {
isa = PBXShellScriptBuildPhase;

View file

@ -1,5 +1,5 @@
import ApplicationServices
import AppKit
import ApplicationServices
import Foundation
import OSLog
@ -12,7 +12,7 @@ public struct AXElement: Equatable, Hashable, @unchecked Sendable {
public let element: AXUIElement
private let logger = Logger(
subsystem: "sh.vibetunnel.vibetunnel",
subsystem: BundleIdentifiers.loggerSubsystem,
category: "AXElement"
)
@ -428,7 +428,7 @@ extension AXElement {
public let bounds: CGRect?
public let isMinimized: Bool
public let bundleIdentifier: String?
public init(window: AXElement, pid: pid_t, bundleIdentifier: String? = nil) {
self.window = window
self.windowID = CGWindowID(window.windowID ?? 0)
@ -439,7 +439,7 @@ extension AXElement {
self.bundleIdentifier = bundleIdentifier
}
}
/// Enumerates all windows from running applications using Accessibility APIs.
///
/// This method provides a way to discover windows without requiring screen recording
@ -463,7 +463,7 @@ extension AXElement {
/// ```
///
/// - Parameters:
/// - bundleIdentifiers: Optional array of bundle identifiers to filter applications.
/// - bundleIdentifiers: Optional array of bundle identifiers to filter applications.
/// If nil, all applications are enumerated.
/// - includeMinimized: Whether to include minimized windows in the results (default: false)
/// - filter: Optional filter closure to determine which windows to include.
@ -474,44 +474,45 @@ extension AXElement {
bundleIdentifiers: [String]? = nil,
includeMinimized: Bool = false,
filter: ((WindowInfo) -> Bool)? = nil
) -> [WindowInfo] {
)
-> [WindowInfo]
{
var allWindows: [WindowInfo] = []
// Get all running applications
let runningApps: [NSRunningApplication]
if let bundleIDs = bundleIdentifiers {
runningApps = bundleIDs.flatMap { bundleID in
let runningApps: [NSRunningApplication] = if let bundleIDs = bundleIdentifiers {
bundleIDs.flatMap { bundleID in
NSRunningApplication.runningApplications(withBundleIdentifier: bundleID)
}
} else {
runningApps = NSWorkspace.shared.runningApplications
NSWorkspace.shared.runningApplications
}
// Enumerate windows for each application
for app in runningApps {
// Skip apps without bundle identifier or that are terminated
guard let bundleID = app.bundleIdentifier,
!app.isTerminated else { continue }
let axApp = AXElement.application(pid: app.processIdentifier)
// Get all windows for this application
guard let windows = axApp.windows else { continue }
for window in windows {
// Skip minimized windows if requested
if !includeMinimized && (window.isMinimized ?? false) {
continue
}
let windowInfo = WindowInfo(
window: window,
pid: app.processIdentifier,
bundleIdentifier: bundleID
)
// Apply filter if provided
if let filter = filter {
if let filter {
if filter(windowInfo) {
allWindows.append(windowInfo)
}
@ -520,10 +521,10 @@ extension AXElement {
}
}
}
return allWindows
}
/// Convenience method to enumerate windows for specific bundle identifiers.
///
/// This is a simplified version of `enumerateWindows` for the common case
@ -536,7 +537,9 @@ extension AXElement {
public static func windows(
for bundleIdentifiers: [String],
includeMinimized: Bool = false
) -> [WindowInfo] {
)
-> [WindowInfo]
{
enumerateWindows(
bundleIdentifiers: bundleIdentifiers,
includeMinimized: includeMinimized

View file

@ -7,7 +7,7 @@ import OSLog
/// Provides convenient methods for checking and requesting accessibility permissions.
public enum AXPermissions {
private static let logger = Logger(
subsystem: "sh.vibetunnel.vibetunnel",
subsystem: BundleIdentifiers.loggerSubsystem,
category: "AXPermissions"
)

View file

@ -0,0 +1,60 @@
import Foundation
/// Application preferences.
///
/// This struct manages user preferences for VibeTunnel, including
/// preferred applications for Git and terminal operations, UI preferences,
/// and update settings.
struct AppPreferences {
/// The preferred Git GUI application.
///
/// When set, VibeTunnel will use this application to open Git repositories.
/// Common values include:
/// - `"GitHubDesktop"`: GitHub Desktop
/// - `"SourceTree"`: Atlassian SourceTree
/// - `"Tower"`: Git Tower
/// - `"Fork"`: Fork Git client
/// - `nil`: Use system default or no preference
let preferredGitApp: String?
/// The preferred terminal application.
///
/// When set, VibeTunnel will use this terminal for opening new sessions.
/// Common values include:
/// - `"Terminal"`: macOS Terminal.app
/// - `"iTerm2"`: iTerm2
/// - `"Alacritty"`: Alacritty
/// - `"Hyper"`: Hyper terminal
/// - `nil`: Use system default Terminal.app
let preferredTerminal: String?
/// Whether to show VibeTunnel in the macOS Dock.
///
/// When `false`, the app runs as a menu bar only application.
/// When `true`, the app icon appears in the Dock for easier access.
let showInDock: Bool
/// The update channel for automatic updates.
///
/// Controls which releases the app checks for updates:
/// - `"stable"`: Only stable releases
/// - `"beta"`: Beta and stable releases
/// - `"alpha"`: All releases including alpha builds
/// - `"none"`: Disable automatic update checks
let updateChannel: String
/// Creates application preferences from current user defaults.
///
/// This factory method reads the current preferences from user defaults
/// to create a configuration instance that reflects the user's choices.
///
/// - Returns: An `AppPreferences` instance with current user preferences.
static func current() -> Self {
Self(
preferredGitApp: UserDefaults.standard.string(forKey: AppConstants.UserDefaultsKeys.preferredGitApp),
preferredTerminal: UserDefaults.standard.string(forKey: AppConstants.UserDefaultsKeys.preferredTerminal),
showInDock: AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.showInDock),
updateChannel: AppConstants.stringValue(for: AppConstants.UserDefaultsKeys.updateChannel)
)
}
}

View file

@ -0,0 +1,30 @@
import Foundation
/// Authentication configuration.
///
/// This struct manages the authentication settings for VibeTunnel,
/// controlling how users authenticate when accessing terminal sessions.
struct AuthConfig {
/// The authentication mode currently in use.
///
/// Common values include:
/// - `"password"`: Traditional password authentication
/// - `"biometric"`: Touch ID or other biometric authentication
/// - `"none"`: No authentication required (development/testing only)
///
/// The exact values depend on the authentication providers configured
/// in the application.
let mode: String
/// Creates an authentication configuration from current user defaults.
///
/// This factory method reads the current authentication mode setting
/// from user defaults to create a configuration instance.
///
/// - Returns: An `AuthConfig` instance with the current authentication mode.
static func current() -> Self {
Self(
mode: AppConstants.stringValue(for: AppConstants.UserDefaultsKeys.authenticationMode)
)
}
}

View file

@ -0,0 +1,39 @@
import Foundation
/// Debug configuration.
///
/// This struct manages debug and logging settings for VibeTunnel,
/// controlling diagnostic output and development features.
struct DebugConfig {
/// Whether debug mode is enabled.
///
/// When `true`, additional debugging features are enabled such as:
/// - More verbose logging output
/// - Development-only UI elements
/// - Diagnostic information in the interface
/// - Relaxed security restrictions for testing
let debugMode: Bool
/// The current logging level.
///
/// Controls the verbosity of log output. Common values include:
/// - `"error"`: Only log errors
/// - `"warning"`: Log warnings and errors
/// - `"info"`: Log informational messages, warnings, and errors
/// - `"debug"`: Log all messages including debug information
/// - `"verbose"`: Maximum verbosity for detailed troubleshooting
let logLevel: String
/// Creates a debug configuration from current user defaults.
///
/// This factory method reads the current debug settings from user defaults
/// to create a configuration instance that reflects the user's preferences.
///
/// - Returns: A `DebugConfig` instance with current debug settings.
static func current() -> Self {
Self(
debugMode: AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.debugMode),
logLevel: AppConstants.stringValue(for: AppConstants.UserDefaultsKeys.logLevel)
)
}
}

View file

@ -0,0 +1,35 @@
import Foundation
/// Development server configuration.
///
/// This struct manages the configuration for using a development server
/// instead of the embedded production server. This is particularly useful
/// for web development as it enables hot reload functionality.
struct DevServerConfig {
/// Whether to use the development server instead of the embedded server.
///
/// When `true`, the app will run `pnpm run dev` to start a development
/// server with hot reload capabilities. When `false`, the app uses the
/// pre-built embedded web server.
let useDevServer: Bool
/// The path to the development server directory.
///
/// This should point to the directory containing the web application
/// source code where `pnpm run dev` can be executed. Typically this
/// is the `web/` directory in the VibeTunnel repository.
let devServerPath: String
/// Creates a development server configuration from current user defaults.
///
/// This factory method reads the current settings from user defaults
/// to create a configuration instance that reflects the user's preferences.
///
/// - Returns: A `DevServerConfig` instance with current settings.
static func current() -> Self {
Self(
useDevServer: AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.useDevServer),
devServerPath: AppConstants.stringValue(for: AppConstants.UserDefaultsKeys.devServerPath)
)
}
}

View file

@ -0,0 +1,42 @@
import Foundation
/// Server configuration.
///
/// This struct manages the configuration for the VibeTunnel web server,
/// including network settings and startup behavior.
struct ServerConfig {
/// The port number the server listens on.
///
/// Default is typically 4020 for production or 4021 for development.
/// Users can customize this to avoid port conflicts with other services.
let port: Int
/// The dashboard access mode.
///
/// Controls who can access the VibeTunnel web dashboard:
/// - `"local"`: Only accessible from localhost
/// - `"network"`: Accessible from any device on the local network
/// - `"tunnel"`: Accessible through ngrok tunnel (requires authentication)
let dashboardAccessMode: String
/// Whether to clean up stale sessions on startup.
///
/// When `true`, the server will remove any orphaned or inactive
/// terminal sessions when it starts. This helps prevent resource
/// leaks but may terminate sessions that were intended to persist.
let cleanupOnStartup: Bool
/// Creates a server configuration from current user defaults.
///
/// This factory method reads the current server settings from user defaults
/// to create a configuration instance that reflects the user's preferences.
///
/// - Returns: A `ServerConfig` instance with current server settings.
static func current() -> Self {
Self(
port: AppConstants.intValue(for: AppConstants.UserDefaultsKeys.serverPort),
dashboardAccessMode: AppConstants.stringValue(for: AppConstants.UserDefaultsKeys.dashboardAccessMode),
cleanupOnStartup: AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.cleanupOnStartup)
)
}
}

View file

@ -0,0 +1,62 @@
import Foundation
/// Configuration for StatusBarMenuManager setup.
///
/// This struct bundles all the service dependencies required to initialize
/// the status bar menu manager. It ensures all necessary services are provided
/// during initialization, following the dependency injection pattern.
struct StatusBarMenuConfiguration {
/// Monitors active terminal sessions.
///
/// Tracks the lifecycle of terminal sessions, providing real-time
/// updates about session state, activity, and metadata.
let sessionMonitor: SessionMonitor
/// Manages the VibeTunnel web server.
///
/// Handles starting, stopping, and monitoring the embedded or
/// development web server that serves the terminal interface.
let serverManager: ServerManager
/// Provides ngrok tunnel functionality.
///
/// Manages ngrok tunnels for exposing local terminal sessions
/// to the internet with secure HTTPS endpoints.
let ngrokService: NgrokService
/// Provides Tailscale network functionality.
///
/// Manages Tailscale integration for secure peer-to-peer
/// networking without exposing sessions to the public internet.
let tailscaleService: TailscaleService
/// Launches terminal applications.
///
/// Handles opening new terminal windows or tabs in the user's
/// preferred terminal application (Terminal.app, iTerm2, etc.).
let terminalLauncher: TerminalLauncher
/// Monitors Git repository states.
///
/// Provides real-time information about Git repositories,
/// including branch status, uncommitted changes, and sync state.
let gitRepositoryMonitor: GitRepositoryMonitor
/// Discovers Git repositories on the system.
///
/// Scans and indexes Git repositories for quick access
/// and provides repository suggestions in the UI.
let repositoryDiscovery: RepositoryDiscoveryService
/// Manages application configuration.
///
/// Handles reading and writing configuration settings,
/// including user preferences and system settings.
let configManager: ConfigManager
/// Manages Git worktrees.
///
/// Provides functionality for creating, listing, and managing
/// Git worktrees for parallel development workflows.
let worktreeService: WorktreeService
}

View file

@ -7,6 +7,9 @@ enum BundleIdentifiers {
static let main = "sh.vibetunnel.vibetunnel"
static let vibeTunnel = "sh.vibetunnel.vibetunnel"
/// Logging subsystem identifier for unified logging
static let loggerSubsystem = "sh.vibetunnel.vibetunnel"
// MARK: - Terminal Applications
static let terminal = "com.apple.Terminal"
@ -18,6 +21,10 @@ enum BundleIdentifiers {
static let hyper = "co.zeit.hyper"
static let kitty = "net.kovidgoyal.kitty"
/// Terminal application bundle identifiers.
///
/// Groups bundle identifiers for terminal emulator applications
/// to provide a centralized reference for terminal app detection.
enum Terminal {
static let apple = "com.apple.Terminal"
static let iTerm2 = "com.googlecode.iterm2"
@ -38,6 +45,10 @@ enum BundleIdentifiers {
static let vscode = "com.microsoft.VSCode"
static let windsurf = "com.codeiumapp.windsurf"
/// Git application bundle identifiers.
///
/// Groups bundle identifiers for Git GUI applications to provide
/// a centralized reference for Git app detection and integration.
enum Git {
static let githubDesktop = "com.todesktop.230313mzl4w4u92"
static let fork = "com.DanPristupov.Fork"
@ -50,6 +61,10 @@ enum BundleIdentifiers {
// MARK: - Code Editors
/// Code editor bundle identifiers.
///
/// Groups bundle identifiers for code editors that can be launched
/// from VibeTunnel for repository editing.
enum Editor {
static let vsCode = "com.microsoft.VSCode"
static let windsurf = "com.codeiumapp.windsurf"

View file

@ -4,7 +4,7 @@ import Foundation
extension Notification.Name {
// MARK: - Settings
static let showSettings = Notification.Name("sh.vibetunnel.vibetunnel.showSettings")
static let showSettings = Notification.Name("\(BundleIdentifiers.vibeTunnel).showSettings")
// MARK: - Updates
@ -15,7 +15,10 @@ extension Notification.Name {
static let showWelcomeScreen = Notification.Name("showWelcomeScreen")
}
/// Notification categories
/// Notification categories for user notifications.
///
/// Contains category identifiers used when registering and handling
/// notifications in the Notification Center.
enum NotificationCategories {
static let updateReminder = "UPDATE_REMINDER"
}

View file

@ -12,7 +12,7 @@ final class DockIconManager: NSObject, @unchecked Sendable {
static let shared = DockIconManager()
private var windowsObservation: NSKeyValueObservation?
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "DockIconManager")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "DockIconManager")
override private init() {
super.init()

View file

@ -137,80 +137,9 @@ enum AppConstants {
}
}
// MARK: - Configuration Helpers
// MARK: - Convenience Methods
extension AppConstants {
/// Development server configuration
struct DevServerConfig {
let useDevServer: Bool
let devServerPath: String
static func current() -> Self {
Self(
useDevServer: boolValue(for: UserDefaultsKeys.useDevServer),
devServerPath: stringValue(for: UserDefaultsKeys.devServerPath)
)
}
}
/// Authentication configuration
struct AuthConfig {
let mode: String
static func current() -> Self {
Self(
mode: stringValue(for: UserDefaultsKeys.authenticationMode)
)
}
}
/// Debug configuration
struct DebugConfig {
let debugMode: Bool
let logLevel: String
static func current() -> Self {
Self(
debugMode: boolValue(for: UserDefaultsKeys.debugMode),
logLevel: stringValue(for: UserDefaultsKeys.logLevel)
)
}
}
/// Server configuration
struct ServerConfig {
let port: Int
let dashboardAccessMode: String
let cleanupOnStartup: Bool
static func current() -> Self {
Self(
port: intValue(for: UserDefaultsKeys.serverPort),
dashboardAccessMode: stringValue(for: UserDefaultsKeys.dashboardAccessMode),
cleanupOnStartup: boolValue(for: UserDefaultsKeys.cleanupOnStartup)
)
}
}
/// Application preferences
struct AppPreferences {
let preferredGitApp: String?
let preferredTerminal: String?
let showInDock: Bool
let updateChannel: String
static func current() -> Self {
Self(
preferredGitApp: UserDefaults.standard.string(forKey: UserDefaultsKeys.preferredGitApp),
preferredTerminal: UserDefaults.standard.string(forKey: UserDefaultsKeys.preferredTerminal),
showInDock: boolValue(for: UserDefaultsKeys.showInDock),
updateChannel: stringValue(for: UserDefaultsKeys.updateChannel)
)
}
}
// MARK: - Convenience Methods
/// Check if the app is in development mode (debug or dev server enabled)
static func isInDevelopmentMode() -> Bool {
let debug = DebugConfig.current()

View file

@ -0,0 +1,80 @@
import Foundation
// MARK: - Control Message Structure (with generic payload support)
/// A generic control message for communication between VibeTunnel components.
///
/// This struct represents messages exchanged through the control protocol,
/// supporting various message types, categories, and generic payloads for
/// flexible communication between the native app and web server.
struct ControlMessage<Payload: Codable>: Codable {
/// Unique identifier for the message.
///
/// Generated automatically if not provided. Used for message tracking
/// and correlation of requests with responses.
let id: String
/// The type of message (request, response, event, etc.).
///
/// Determines how the message should be processed by the receiver.
let type: ControlProtocol.MessageType
/// The functional category of the message.
///
/// Groups related actions together (e.g., auth, session, config).
let category: ControlProtocol.Category
/// The specific action to perform within the category.
///
/// Combined with the category, this uniquely identifies what
/// operation the message represents.
let action: String
/// Optional payload data specific to the action.
///
/// The generic type allows different message types to carry
/// appropriate data structures while maintaining type safety.
let payload: Payload?
/// Optional session identifier this message relates to.
///
/// Used when the message is specific to a particular terminal session.
let sessionId: String?
/// Optional error message for response messages.
///
/// Populated when a request fails or an error occurs during processing.
let error: String?
/// Creates a new control message.
///
/// - Parameters:
/// - id: Unique message identifier. Defaults to a new UUID string.
/// - type: The message type (request, response, event, etc.).
/// - category: The functional category of the message.
/// - action: The specific action within the category.
/// - payload: Optional payload data for the action.
/// - sessionId: Optional session identifier this message relates to.
/// - error: Optional error message for error responses.
init(
id: String = UUID().uuidString,
type: ControlProtocol.MessageType,
category: ControlProtocol.Category,
action: String,
payload: Payload? = nil,
sessionId: String? = nil,
error: String? = nil
) {
self.id = id
self.type = type
self.category = category
self.action = action
self.payload = payload
self.sessionId = sessionId
self.error = error
}
}
// MARK: - Protocol Conformance
extension ControlMessage: ControlProtocol.AnyControlMessage {}

View file

@ -1,6 +1,6 @@
import Foundation
/// Dashboard access mode.
/// Dashboard access mode for the VibeTunnel server.
///
/// Determines the network binding configuration for the VibeTunnel server.
/// Controls whether the web interface is accessible only locally or

View file

@ -0,0 +1,36 @@
import Foundation
/// Information about a Git repository.
///
/// This struct encapsulates the current state of a Git repository, including
/// branch information, sync status, and working tree state.
struct GitInfo: Equatable {
/// The current branch name, if available.
///
/// This will be `nil` if the repository is in a detached HEAD state
/// or if the branch information cannot be determined.
let branch: String?
/// The number of commits the current branch is ahead of its upstream branch.
///
/// This value is `nil` if there is no upstream branch configured
/// or if the ahead count cannot be determined.
let aheadCount: Int?
/// The number of commits the current branch is behind its upstream branch.
///
/// This value is `nil` if there is no upstream branch configured
/// or if the behind count cannot be determined.
let behindCount: Int?
/// Indicates whether the repository has uncommitted changes.
///
/// This includes both staged and unstaged changes, as well as untracked files.
let hasChanges: Bool
/// Indicates whether the repository is a Git worktree.
///
/// A worktree is a linked working tree that shares the same repository
/// with the main working tree but can have a different branch checked out.
let isWorktree: Bool
}

View file

@ -27,6 +27,18 @@ public struct GitRepository: Sendable, Equatable, Hashable {
/// Current branch name
public let currentBranch: String?
/// Number of commits ahead of upstream
public let aheadCount: Int?
/// Number of commits behind upstream
public let behindCount: Int?
/// Name of the tracking branch (e.g., "origin/main")
public let trackingBranch: String?
/// Whether this is a worktree (not the main repository)
public let isWorktree: Bool
/// GitHub URL for the repository (cached, not computed)
public let githubURL: URL?
@ -78,6 +90,10 @@ public struct GitRepository: Sendable, Equatable, Hashable {
deletedCount: Int = 0,
untrackedCount: Int = 0,
currentBranch: String? = nil,
aheadCount: Int? = nil,
behindCount: Int? = nil,
trackingBranch: String? = nil,
isWorktree: Bool = false,
githubURL: URL? = nil
) {
self.path = path
@ -86,12 +102,16 @@ public struct GitRepository: Sendable, Equatable, Hashable {
self.deletedCount = deletedCount
self.untrackedCount = untrackedCount
self.currentBranch = currentBranch
self.aheadCount = aheadCount
self.behindCount = behindCount
self.trackingBranch = trackingBranch
self.isWorktree = isWorktree
self.githubURL = githubURL
}
// MARK: - Internal Methods
/// Extract GitHub URL from a repository path
/// Get GitHub URL for a repository path
static func getGitHubURL(for repoPath: String) -> URL? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
@ -123,16 +143,27 @@ public struct GitRepository: Sendable, Equatable, Hashable {
/// Parse GitHub URL from git remote output
static func parseGitHubURL(from remoteURL: String) -> URL? {
// Handle HTTPS URLs: https://github.com/user/repo.git
if remoteURL.hasPrefix("https://github.com/") {
let cleanURL = remoteURL.hasSuffix(".git") ? String(remoteURL.dropLast(4)) : remoteURL
return URL(string: cleanURL)
if remoteURL.starts(with: "https://github.com/") {
let cleanedURL = remoteURL
.replacingOccurrences(of: ".git", with: "")
.replacingOccurrences(of: "https://", with: "https://")
return URL(string: cleanedURL)
}
// Handle SSH URLs: git@github.com:user/repo.git
if remoteURL.hasPrefix("git@github.com:") {
let pathPart = String(remoteURL.dropFirst("git@github.com:".count))
let cleanPath = pathPart.hasSuffix(".git") ? String(pathPart.dropLast(4)) : pathPart
return URL(string: "https://github.com/\(cleanPath)")
if remoteURL.starts(with: "git@github.com:") {
let repoPath = remoteURL
.replacingOccurrences(of: "git@github.com:", with: "")
.replacingOccurrences(of: ".git", with: "")
return URL(string: "https://github.com/\(repoPath)")
}
// Handle SSH format: ssh://git@github.com/user/repo.git
if remoteURL.starts(with: "ssh://git@github.com/") {
let repoPath = remoteURL
.replacingOccurrences(of: "ssh://git@github.com/", with: "")
.replacingOccurrences(of: ".git", with: "")
return URL(string: "https://github.com/\(repoPath)")
}
return nil

View file

@ -0,0 +1,39 @@
import Foundation
// MARK: - Error Response
/// Unified error response structure for API errors
public struct ErrorResponse: Codable, Sendable {
public let error: String
public let code: String?
public let details: String?
public init(error: String, code: String? = nil, details: String? = nil) {
self.error = error
self.code = code
self.details = details
}
}
// MARK: - Network Errors
/// Common network errors for API requests
public enum NetworkError: LocalizedError {
case invalidResponse
case serverError(statusCode: Int, message: String)
case decodingError(Error)
case noData
public var errorDescription: String? {
switch self {
case .invalidResponse:
"Invalid server response"
case .serverError(let statusCode, let message):
"Server error (\(statusCode)): \(message)"
case .decodingError(let error):
"Failed to decode response: \(error.localizedDescription)"
case .noData:
"No data received from server"
}
}
}

View file

@ -0,0 +1,50 @@
import Foundation
/// A path suggestion for autocomplete functionality.
///
/// This struct represents a file system path suggestion that can be presented
/// to users during path completion. It includes metadata about the path type
/// and Git repository information when applicable.
struct PathSuggestion: Identifiable, Equatable {
/// Unique identifier for the suggestion.
let id = UUID()
/// The display name of the file or directory.
///
/// This is typically the last component of the path (basename).
let name: String
/// The full file system path.
///
/// This is the absolute or relative path to the file or directory.
let path: String
/// The type of file system entry this suggestion represents.
let type: SuggestionType
/// The complete path to insert when this suggestion is selected.
///
/// This may include escaping or formatting necessary for shell usage.
let suggestion: String
/// Indicates whether this path is a Git repository.
///
/// When `true`, the path contains a `.git` directory or is a Git worktree.
let isRepository: Bool
/// Git repository information if this path is a repository.
///
/// Contains branch, sync status, and change information when `isRepository` is `true`.
let gitInfo: GitInfo?
/// The type of file system entry.
///
/// Distinguishes between different types of file system entries
/// to provide appropriate UI representation and behavior.
enum SuggestionType {
/// A regular file
case file
/// A directory
case directory
}
}

View file

@ -0,0 +1,62 @@
import Foundation
/// A quick start command for terminal sessions.
///
/// This struct represents a predefined command that users can quickly execute
/// when starting a new terminal session. It matches the structure used by the
/// web interface for consistency across platforms.
struct QuickStartCommand: Identifiable, Codable, Equatable {
/// Unique identifier for the command.
///
/// Generated automatically if not provided during initialization.
var id: String
/// Optional human-readable name for the command.
///
/// When provided, this is used for display instead of showing
/// the raw command string.
var name: String?
/// The actual command to execute in the terminal.
///
/// This can be any valid shell command or script.
var command: String
/// Display name for the UI.
///
/// Returns the `name` if available, otherwise falls back to the raw `command`.
/// This provides a cleaner UI experience while still showing the command
/// when no custom name is set.
var displayName: String {
name ?? command
}
/// Creates a new quick start command.
///
/// - Parameters:
/// - id: Unique identifier. Defaults to a new UUID string.
/// - name: Optional display name for the command.
/// - command: The shell command to execute.
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.
///
/// This decoder ensures backward compatibility by generating a new ID
/// if one is not present in the decoded data.
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
}
}

View file

@ -0,0 +1,25 @@
import Foundation
/// Traffic metrics for the ngrok tunnel.
///
/// This struct provides real-time metrics about tunnel usage, including
/// active connections and bandwidth consumption in both directions.
struct TunnelMetrics: Codable {
/// The current number of active connections through the tunnel.
///
/// This represents the number of clients currently connected to
/// the tunnel endpoint.
let connectionsCount: Int
/// Total bytes received through the tunnel.
///
/// This cumulative value represents all data received from external
/// clients since the tunnel was established.
let bytesIn: Int64
/// Total bytes sent through the tunnel.
///
/// This cumulative value represents all data sent to external
/// clients since the tunnel was established.
let bytesOut: Int64
}

View file

@ -0,0 +1,56 @@
import CoreGraphics
import Foundation
/// Information about a tracked terminal window.
///
/// This struct encapsulates all the information needed to track and manage
/// terminal windows across different terminal applications (Terminal.app, iTerm2, etc.).
/// It combines window system information with application-specific identifiers.
struct WindowInfo {
/// The Core Graphics window identifier.
///
/// This is the unique identifier assigned by the window server to this window.
let windowID: CGWindowID
/// The process ID of the terminal application that owns this window.
let ownerPID: pid_t
/// The terminal application type that created this window.
let terminalApp: Terminal
/// The VibeTunnel session ID associated with this window.
///
/// This links the terminal window to a specific VibeTunnel session.
let sessionID: String
/// The timestamp when this window was first tracked.
let createdAt: Date
// MARK: - Tab-specific information
/// AppleScript reference for Terminal.app tabs.
///
/// This is used to identify specific tabs within Terminal.app windows
/// using AppleScript commands. Only populated for Terminal.app.
let tabReference: String?
/// Tab identifier for iTerm2.
///
/// This is the unique identifier iTerm2 assigns to each tab.
/// Only populated for iTerm2 windows.
let tabID: String?
// MARK: - Window properties from Accessibility APIs
/// The window's position and size on screen.
///
/// Retrieved using Accessibility APIs. May be `nil` if accessibility
/// permissions are not granted or the window information is unavailable.
let bounds: CGRect?
/// The window's title as reported by Accessibility APIs.
///
/// May be `nil` if accessibility permissions are not granted
/// or the title cannot be determined.
let title: String?
}

View file

@ -0,0 +1,272 @@
import Foundation
/// Represents a Git worktree in a repository.
///
/// A worktree allows you to have multiple working trees attached to the same repository,
/// enabling you to work on different branches simultaneously without switching contexts.
///
/// ## Overview
///
/// The `Worktree` struct provides comprehensive information about a Git worktree including:
/// - Basic properties like path, branch, and HEAD commit
/// - Status information (detached, locked, prunable)
/// - Statistics about uncommitted changes
/// - UI helper properties for display purposes
///
/// ## Usage Example
///
/// ```swift
/// let worktree = Worktree(
/// path: "/path/to/repo/worktrees/feature-branch",
/// branch: "feature/new-ui",
/// HEAD: "abc123def456",
/// detached: false,
/// prunable: false,
/// locked: nil,
/// lockedReason: nil,
/// commitsAhead: 3,
/// filesChanged: 5,
/// insertions: 42,
/// deletions: 10,
/// hasUncommittedChanges: true,
/// isMainWorktree: false,
/// isCurrentWorktree: true
/// )
/// ```
struct Worktree: Codable, Identifiable, Equatable {
/// Unique identifier for the worktree instance.
let id = UUID()
/// The file system path to the worktree directory.
let path: String
/// The branch name associated with this worktree.
///
/// This is the branch that the worktree is currently checked out to.
let branch: String
/// The SHA hash of the current HEAD commit.
let HEAD: String
/// Indicates whether the worktree is in a detached HEAD state.
///
/// When `true`, the worktree is not on any branch but directly on a commit.
let detached: Bool
/// Indicates whether this worktree can be pruned (removed).
///
/// A worktree is prunable when its associated branch has been deleted
/// or when it's no longer needed.
let prunable: Bool?
/// Indicates whether this worktree is locked.
///
/// Locked worktrees cannot be pruned or removed until unlocked.
let locked: Bool?
/// The reason why this worktree is locked, if applicable.
///
/// Only present when `locked` is `true`.
let lockedReason: String?
// MARK: - Extended Statistics
/// Number of commits this branch is ahead of the base branch.
let commitsAhead: Int?
/// Number of files with uncommitted changes in this worktree.
let filesChanged: Int?
/// Number of line insertions in uncommitted changes.
let insertions: Int?
/// Number of line deletions in uncommitted changes.
let deletions: Int?
/// Indicates whether this worktree has any uncommitted changes.
///
/// This includes both staged and unstaged changes.
let hasUncommittedChanges: Bool?
// MARK: - UI Helpers
/// Indicates whether this is the main worktree (not a linked worktree).
///
/// The main worktree is typically the original repository directory.
let isMainWorktree: Bool?
/// Indicates whether this worktree is currently active in VibeTunnel.
let isCurrentWorktree: Bool?
enum CodingKeys: String, CodingKey {
case path
case branch
case HEAD
case detached
case prunable
case locked
case lockedReason
case commitsAhead
case filesChanged
case insertions
case deletions
case hasUncommittedChanges
case isMainWorktree
case isCurrentWorktree
}
}
/// Response from the worktree API endpoint.
///
/// This structure encapsulates the complete response when fetching worktree information,
/// including the list of worktrees and branch tracking information.
///
/// ## Topics
///
/// ### Properties
/// - ``worktrees``
/// - ``baseBranch``
/// - ``followBranch``
struct WorktreeListResponse: Codable {
/// Array of all worktrees in the repository.
let worktrees: [Worktree]
/// The base branch for the repository (typically "main" or "master").
let baseBranch: String
/// The branch being followed in follow mode, if enabled.
let followBranch: String?
}
/// Aggregated statistics about worktrees in a repository.
///
/// Provides a quick overview of the worktree state without
/// needing to process the full worktree list.
///
/// ## Example
///
/// ```swift
/// let stats = WorktreeStats(total: 5, locked: 1, prunable: 2)
/// logger.info("Active worktrees: \(stats.total - stats.prunable)")
/// ```
struct WorktreeStats: Codable {
/// Total number of worktrees including the main worktree.
let total: Int
/// Number of worktrees that are currently locked.
let locked: Int
/// Number of worktrees that can be pruned.
let prunable: Int
}
/// Status of the follow mode feature.
///
/// Follow mode automatically switches to a specified branch
/// when changes are detected, useful for continuous integration
/// or automated workflows.
struct FollowModeStatus: Codable {
/// Whether follow mode is currently active.
let enabled: Bool
/// The branch being followed when enabled.
let targetBranch: String?
}
/// Request payload for creating a new worktree.
///
/// ## Usage
///
/// ```swift
/// let request = CreateWorktreeRequest(
/// branch: "feature/new-feature",
/// createBranch: true,
/// baseBranch: "main"
/// )
/// ```
struct CreateWorktreeRequest: Codable {
/// The branch name for the new worktree.
let branch: String
/// Whether to create the branch if it doesn't exist.
let createBranch: Bool
/// The base branch to create from when `createBranch` is true.
///
/// If nil, uses the repository's default branch.
let baseBranch: String?
}
/// Request payload for switching branches in the current worktree.
///
/// This allows changing the checked-out branch without creating
/// a new worktree, useful for quick context switches.
struct SwitchBranchRequest: Codable {
/// The branch to switch to.
let branch: String
/// Whether to create the branch if it doesn't exist.
let createBranch: Bool
}
/// Request payload for toggling follow mode.
///
/// ## Example
///
/// ```swift
/// // Enable follow mode
/// let enableRequest = FollowModeRequest(enabled: true, targetBranch: "develop")
///
/// // Disable follow mode
/// let disableRequest = FollowModeRequest(enabled: false, targetBranch: nil)
/// ```
struct FollowModeRequest: Codable {
/// Whether to enable or disable follow mode.
let enabled: Bool
/// The branch to follow when enabling.
///
/// Required when `enabled` is true, ignored otherwise.
let targetBranch: String?
}
/// Represents a Git branch in the repository.
///
/// Provides information about branches including their relationship
/// to worktrees and whether they're local or remote branches.
///
/// ## Topics
///
/// ### Identification
/// - ``id``
/// - ``name``
///
/// ### Status
/// - ``current``
/// - ``remote``
/// - ``worktree``
struct GitBranch: Codable, Identifiable, Equatable {
/// Unique identifier for the branch instance.
let id = UUID()
/// The branch name (e.g., "main", "feature/login", "origin/develop").
let name: String
/// Whether this is the currently checked-out branch.
let current: Bool
/// Whether this is a remote tracking branch.
let remote: Bool
/// Path to the worktree using this branch, if any.
///
/// Will be nil for branches not associated with any worktree.
let worktree: String?
enum CodingKeys: String, CodingKey {
case name
case current
case remote
case worktree
}
}

View file

@ -18,7 +18,7 @@ private struct SendableDescriptor: @unchecked Sendable {
@MainActor
final class AppleScriptExecutor {
private let logger = Logger(
subsystem: "sh.vibetunnel.vibetunnel",
subsystem: BundleIdentifiers.loggerSubsystem,
category: "AppleScriptExecutor"
)

View file

@ -1,50 +1,74 @@
import AppKit
import Foundation
import Observation
import OSLog
/// Service for providing path autocompletion suggestions
@MainActor
class AutocompleteService: ObservableObject {
@Published private(set) var isLoading = false
@Published private(set) var suggestions: [PathSuggestion] = []
@Observable
class AutocompleteService {
private(set) var isLoading = false
private(set) var suggestions: [PathSuggestion] = []
private var currentTask: Task<Void, Never>?
private var taskCounter = 0
private let fileManager = FileManager.default
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "AutocompleteService")
private let gitMonitor: GitRepositoryMonitor
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
/// Common repository search paths relative to home directory
private nonisolated static let commonRepositoryPaths = [
"/Projects",
"/Developer",
"/Documents",
"/Desktop",
"/Code",
"/repos",
"/git",
"/src",
"/work",
"" // Home directory itself
]
enum SuggestionType {
case file
case directory
}
init(gitMonitor: GitRepositoryMonitor = GitRepositoryMonitor()) {
self.gitMonitor = gitMonitor
}
/// Fetch autocomplete suggestions for the given path
func fetchSuggestions(for partialPath: String) async {
logger.debug("[AutocompleteService] fetchSuggestions called with: '\(partialPath)'")
// Cancel any existing task
currentTask?.cancel()
guard !partialPath.isEmpty else {
logger.debug("[AutocompleteService] Empty path, clearing suggestions")
suggestions = []
return
}
// Increment task counter to track latest task
taskCounter += 1
let thisTaskId = taskCounter
logger.debug("[AutocompleteService] Starting task \(thisTaskId) for path: '\(partialPath)'")
currentTask = Task {
await performFetch(for: partialPath)
await performFetch(for: partialPath, taskId: thisTaskId)
}
// Wait for the task to complete
await currentTask?.value
logger.debug("[AutocompleteService] Task \(thisTaskId) awaited, suggestions count: \(self.suggestions.count)")
}
private func performFetch(for originalPath: String) async {
isLoading = true
defer { isLoading = false }
private func performFetch(for originalPath: String, taskId: Int) async {
self.isLoading = true
defer { self.isLoading = false }
var partialPath = originalPath
logger.debug("[AutocompleteService] performFetch - originalPath: '\(originalPath)'")
// Handle tilde expansion
if partialPath.hasPrefix("~") {
let homeDir = NSHomeDirectory()
@ -55,9 +79,13 @@ class AutocompleteService: ObservableObject {
}
}
logger.debug("[AutocompleteService] After expansion - partialPath: '\(partialPath)'")
// Determine directory and partial filename
let (dirPath, partialName) = splitPath(partialPath)
logger.debug("[AutocompleteService] After split - dirPath: '\(dirPath)', partialName: '\(partialName)'")
// Check if task was cancelled
if Task.isCancelled { return }
@ -65,7 +93,8 @@ class AutocompleteService: ObservableObject {
let fsSuggestions = await getFileSystemSuggestions(
directory: dirPath,
partialName: partialName,
originalPath: originalPath
originalPath: originalPath,
taskId: taskId
)
// Check if task was cancelled
@ -79,7 +108,10 @@ class AutocompleteService: ObservableObject {
if isSearchingByName {
// Get git repository suggestions from discovered repositories
let repoSuggestions = await getRepositorySuggestions(searchTerm: originalPath)
let repoSuggestions = await getRepositorySuggestions(searchTerm: originalPath, taskId: taskId)
// Check if task was cancelled
if Task.isCancelled { return }
// Merge with filesystem suggestions, avoiding duplicates
let existingPaths = Set(fsSuggestions.map(\.suggestion))
@ -90,8 +122,26 @@ class AutocompleteService: ObservableObject {
// Sort suggestions
let sortedSuggestions = sortSuggestions(allSuggestions, searchTerm: partialName)
// Limit to 20 results
suggestions = Array(sortedSuggestions.prefix(20))
// Limit to 20 results before enriching with Git info
let limitedSuggestions = Array(sortedSuggestions.prefix(20))
// Enrich with Git info
let enrichedSuggestions = await enrichSuggestionsWithGitInfo(limitedSuggestions)
// Only update suggestions if this is still the latest task
if taskId == taskCounter {
self.suggestions = enrichedSuggestions
logger
.debug(
"[AutocompleteService] Task \(taskId) updated suggestions. Final count: \(self.suggestions.count), items: \(self.suggestions.map(\.name).joined(separator: ", "))"
)
} else {
logger
.debug(
"[AutocompleteService] Discarding stale results from task \(taskId), current task is \(self.taskCounter)"
)
}
}
private func splitPath(_ path: String) -> (directory: String, partialName: String) {
@ -106,62 +156,151 @@ class AutocompleteService: ObservableObject {
private func getFileSystemSuggestions(
directory: String,
partialName: String,
originalPath: String
originalPath: String,
taskId: Int
)
async -> [PathSuggestion]
{
let expandedDir = NSString(string: directory).expandingTildeInPath
// Move to background thread to avoid blocking UI
await Task.detached(priority: .userInitiated) { [logger = self.logger] in
let expandedDir = NSString(string: directory).expandingTildeInPath
let fileManager = FileManager.default
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
)
guard fileManager.fileExists(atPath: expandedDir) else {
return []
}
} catch {
return []
}
do {
// Check if this task is still current before doing expensive operations
if Task.isCancelled {
logger.debug("[AutocompleteService] Task \(taskId) cancelled, not processing directory listing")
return []
}
let contents = try fileManager.contentsOfDirectory(atPath: expandedDir)
// Debug logging
let matching = contents.filter { filename in
partialName.isEmpty || filename.lowercased().hasPrefix(partialName.lowercased())
}
logger
.debug(
"[AutocompleteService] Directory: \(expandedDir), PartialName: '\(partialName)', Total items: \(contents.count), Matching: \(matching.count) - \(matching.joined(separator: ", "))"
)
return contents.compactMap { filename -> PathSuggestion? 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,
gitInfo: nil // Git info will be fetched later if needed
)
}
} catch {
return []
}
}.value
}
private func getRepositorySuggestions(searchTerm: String, taskId: Int) async -> [PathSuggestion] {
// Get git repositories from common locations
await Task.detached(priority: .userInitiated) { [logger = self.logger] in
var suggestions: [PathSuggestion] = []
let fileManager = FileManager.default
// Check if this task is still current
if Task.isCancelled {
logger.debug("[AutocompleteService] Task cancelled, not processing repository search")
return []
}
// Common repository locations
let homeDir = NSHomeDirectory()
let searchPaths = Self.commonRepositoryPaths.map { path in
path.isEmpty ? homeDir : homeDir + path
}
let lowercasedTerm = searchTerm.lowercased()
for basePath in searchPaths {
guard fileManager.fileExists(atPath: basePath) else { continue }
// Check if task is still current
if Task.isCancelled {
return []
}
do {
let contents = try fileManager.contentsOfDirectory(atPath: basePath)
for item in contents {
// Skip if doesn't match search term
if !lowercasedTerm.isEmpty && !item.lowercased().contains(lowercasedTerm) {
continue
}
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")
if fileManager.fileExists(atPath: gitPath) {
let displayPath = fullPath.replacingOccurrences(of: NSHomeDirectory(), with: "~")
suggestions.append(PathSuggestion(
name: item,
path: displayPath,
type: .directory,
suggestion: fullPath + "/",
isRepository: true,
gitInfo: nil // Git info will be fetched later if needed
))
}
}
} catch {
// Ignore errors for individual directories
continue
}
}
return suggestions
}.value
}
private func sortSuggestions(_ suggestions: [PathSuggestion], searchTerm: String) -> [PathSuggestion] {
@ -205,62 +344,103 @@ class AutocompleteService: ObservableObject {
suggestions = []
}
/// Fetch Git info for directory suggestions
private func enrichSuggestionsWithGitInfo(_ suggestions: [PathSuggestion]) async -> [PathSuggestion] {
await withTaskGroup(of: (Int, GitInfo?).self) { group in
var enrichedSuggestions = suggestions
// Only fetch Git info for directories and repositories
for (index, suggestion) in suggestions.enumerated() where suggestion.type == .directory {
group.addTask { [gitMonitor = self.gitMonitor] in
// Expand path for Git lookup
let expandedPath = NSString(string: suggestion.suggestion
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
).expandingTildeInPath
let gitInfo = await gitMonitor.findRepository(for: expandedPath).map { repo in
GitInfo(
branch: repo.currentBranch,
aheadCount: repo.aheadCount,
behindCount: repo.behindCount,
hasChanges: repo.hasChanges,
isWorktree: repo.isWorktree
)
}
return (index, gitInfo)
}
}
// Collect results
for await (index, gitInfo) in group {
if let gitInfo {
enrichedSuggestions[index] = PathSuggestion(
name: enrichedSuggestions[index].name,
path: enrichedSuggestions[index].path,
type: enrichedSuggestions[index].type,
suggestion: enrichedSuggestions[index].suggestion,
isRepository: true, // If we have Git info, it's a repository
gitInfo: gitInfo
)
}
}
return enrichedSuggestions
}
}
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"
]
await Task.detached(priority: .userInitiated) {
let fileManager = FileManager.default
let searchLower = searchTerm.lowercased().replacingOccurrences(of: "~/", with: "")
let homeDir = NSHomeDirectory()
let commonPaths = Self.commonRepositoryPaths
.filter { !$0.isEmpty } // Exclude home directory for this method
.map { homeDir + $0 }
var repositories: [PathSuggestion] = []
var repositories: [PathSuggestion] = []
for basePath in commonPaths {
guard fileManager.fileExists(atPath: basePath) else { continue }
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
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 }
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 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 }
// 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
// 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
))
repositories.append(PathSuggestion(
name: item,
path: displayPath,
type: .directory,
suggestion: displayPath + "/",
isRepository: true,
gitInfo: nil // Git info will be fetched later if needed
))
}
} catch {
// Ignore errors for individual directories
continue
}
} catch {
// Ignore errors for individual directories
continue
}
}
return repositories
return repositories
}.value
}
}

View file

@ -36,8 +36,8 @@ final class BunServer {
/// Resource cleanup tracking
private var isCleaningUp = false
private let logger = Logger(subsystem: BundleIdentifiers.main, category: "BunServer")
private let serverOutput = Logger(subsystem: BundleIdentifiers.main, category: "ServerOutput")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "BunServer")
private let serverOutput = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "ServerOutput")
var isRunning: Bool {
state == .running
@ -60,7 +60,7 @@ final class BunServer {
/// Get the local auth token for use in HTTP requests
var localToken: String? {
// Check if authentication is disabled
let authConfig = AppConstants.AuthConfig.current()
let authConfig = AuthConfig.current()
if authConfig.mode == "none" {
return nil
}
@ -102,7 +102,7 @@ final class BunServer {
}
// Check if we should use dev server
let devConfig = AppConstants.DevServerConfig.current()
let devConfig = DevServerConfig.current()
if devConfig.useDevServer && !devConfig.devServerPath.isEmpty {
logger.notice("🔧 Starting DEVELOPMENT SERVER with hot reload (pnpm run dev) on port \(self.port)")
@ -191,7 +191,7 @@ final class BunServer {
var vibetunnelArgs = ["--port", String(port), "--bind", bindAddress]
// Add authentication flags based on configuration
let authConfig = AppConstants.AuthConfig.current()
let authConfig = AuthConfig.current()
logger.info("Configuring authentication mode: \(authConfig.mode)")
switch authConfig.mode {
@ -402,7 +402,7 @@ final class BunServer {
logger.info("Dev server working directory: \(expandedPath)")
// Get authentication mode
let authConfig = AppConstants.AuthConfig.current()
let authConfig = AuthConfig.current()
// Build the dev server arguments
let devArgs = devServerManager.buildDevServerArguments(
@ -700,7 +700,7 @@ final class BunServer {
let handle = pipe.fileHandleForReading
let source = DispatchSource.makeReadSource(fileDescriptor: handle.fileDescriptor)
let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "BunServer")
let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "BunServer")
logger.debug("Starting stdout monitoring for Bun server on port \(currentPort)")
// Create a cancellation handler
@ -779,7 +779,7 @@ final class BunServer {
let handle = pipe.fileHandleForReading
let source = DispatchSource.makeReadSource(fileDescriptor: handle.fileDescriptor)
let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "BunServer")
let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "BunServer")
logger.debug("Starting stderr monitoring for Bun server on port \(currentPort)")
// Create a cancellation handler
@ -908,7 +908,7 @@ final class BunServer {
if wasRunning {
// Unexpected termination
let devConfig = AppConstants.DevServerConfig.current()
let devConfig = DevServerConfig.current()
let serverType = devConfig.useDevServer ? "Development server (pnpm run dev)" : "Production server"
self.logger.error("\(serverType) terminated unexpectedly with exit code: \(exitCode)")
@ -928,7 +928,7 @@ final class BunServer {
}
} else {
// Normal termination
let devConfig = AppConstants.DevServerConfig.current()
let devConfig = DevServerConfig.current()
let serverType = devConfig.useDevServer ? "Development server" : "Production server"
self.logger.info("\(serverType) terminated normally with exit code: \(exitCode)")
}
@ -1010,7 +1010,7 @@ extension BunServer {
/// A sendable log handler for use in detached tasks
private final class LogHandler: Sendable {
private let serverOutput = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "ServerOutput")
private let serverOutput = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "ServerOutput")
func log(_ line: String, isError: Bool) {
let lowercased = line.lowercased()

View file

@ -36,7 +36,7 @@ final class CloudflareService {
private static let serverStopTimeoutMillis = 500
/// Logger instance for debugging
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "CloudflareService")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "CloudflareService")
/// Indicates if cloudflared CLI is installed on the system
private(set) var isInstalled = false

View file

@ -1,82 +1,51 @@
import Combine
import Foundation
import Observation
import OSLog
/// Manager for VibeTunnel configuration stored in ~/.vibetunnel/config.json
/// Provides centralized configuration management for all app settings
@MainActor
class ConfigManager: ObservableObject {
@Observable
final class ConfigManager {
static let shared = ConfigManager()
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "ConfigManager")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, 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 = FilePathConstants.defaultRepositoryBasePath
private(set) var quickStartCommands: [QuickStartCommand] = []
var repositoryBasePath: String = FilePathConstants.defaultRepositoryBasePath
// Server settings
@Published var serverPort: Int = 4_020
@Published var dashboardAccessMode: DashboardAccessMode = .network
@Published var cleanupOnStartup: Bool = true
@Published var authenticationMode: AuthenticationMode = .osAuth
var serverPort: Int = 4_020
var dashboardAccessMode: DashboardAccessMode = .network
var cleanupOnStartup: Bool = true
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"
var debugMode: Bool = false
var useDevServer: Bool = false
var devServerPath: String = ""
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
var preferredGitApp: String?
var preferredTerminal: String?
var updateChannel: UpdateChannel = .stable
var showInDock: Bool = false
var preventSleepWhenRunning: Bool = true
// Remote access
@Published var ngrokEnabled: Bool = false
@Published var ngrokTokenPresent: Bool = false
var ngrokEnabled: Bool = false
var ngrokTokenPresent: Bool = false
// Session defaults
@Published var sessionCommand: String = "zsh"
@Published var sessionWorkingDirectory: String = FilePathConstants.defaultRepositoryBasePath
@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
}
}
var sessionCommand: String = "zsh"
var sessionWorkingDirectory: String = FilePathConstants.defaultRepositoryBasePath
var sessionSpawnWindow: Bool = true
var sessionTitleMode: TitleMode = .dynamic
/// Comprehensive configuration structure
private struct VibeTunnelConfig: Codable {

View file

@ -1,7 +1,7 @@
import Foundation
/// Extension to make ControlMessage properly Sendable
extension ControlProtocol.ControlMessage: @unchecked Sendable {
extension ControlMessage: @unchecked Sendable {
// The payload dictionary is not technically Sendable, but we control
// its usage and ensure thread safety through actor isolation
}

View file

@ -16,36 +16,6 @@ enum ControlProtocol {
case system
}
// MARK: - Control Message Structure (with generic payload support)
struct ControlMessage<Payload: Codable>: Codable {
let id: String
let type: MessageType
let category: Category
let action: String
let payload: Payload?
let sessionId: String?
let error: String?
init(
id: String = UUID().uuidString,
type: MessageType,
category: Category,
action: String,
payload: Payload? = nil,
sessionId: String? = nil,
error: String? = nil
) {
self.id = id
self.type = type
self.category = category
self.action = action
self.payload = payload
self.sessionId = sessionId
self.error = error
}
}
// MARK: - Base message for runtime dispatch
protocol AnyControlMessage {
@ -205,7 +175,3 @@ enum ControlProtocol {
struct EmptyPayload: Codable {}
typealias EmptyMessage = ControlMessage<EmptyPayload>
}
// MARK: - Protocol Conformance
extension ControlProtocol.ControlMessage: ControlProtocol.AnyControlMessage {}

View file

@ -6,7 +6,7 @@ import OSLog
final class DevServerManager: ObservableObject {
static let shared = DevServerManager()
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "DevServerManager")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "DevServerManager")
/// Validates a development server path
func validate(path: String) -> DevServerValidation {

View file

@ -1,6 +1,113 @@
import Combine
import Foundation
import Observation
import OSLog
// MARK: - Response Types
/// Response from the Git repository info API endpoint.
///
/// This lightweight response is used to quickly determine if a given path
/// is within a Git repository and find the repository root.
///
/// ## Usage
///
/// ```swift
/// let response = GitRepoInfoResponse(
/// isGitRepo: true,
/// repoPath: "/Users/developer/my-project"
/// )
/// ```
struct GitRepoInfoResponse: Codable {
/// Indicates whether the path is within a Git repository.
let isGitRepo: Bool
/// The absolute path to the repository root.
///
/// Only present when `isGitRepo` is `true`.
let repoPath: String?
}
/// Comprehensive Git repository information response from the API.
///
/// Contains detailed status information about a Git repository including
/// file changes, branch status, and remote tracking information.
///
/// ## Topics
///
/// ### Repository Status
/// - ``isGitRepo``
/// - ``repoPath``
/// - ``hasChanges``
///
/// ### Branch Information
/// - ``currentBranch``
/// - ``remoteUrl``
/// - ``githubUrl``
/// - ``hasUpstream``
///
/// ### File Changes
/// - ``modifiedCount``
/// - ``untrackedCount``
/// - ``stagedCount``
/// - ``addedCount``
/// - ``deletedCount``
///
/// ### Sync Status
/// - ``aheadCount``
/// - ``behindCount``
struct GitRepositoryInfoResponse: Codable {
/// Indicates whether this is a valid Git repository.
let isGitRepo: Bool
/// The absolute path to the repository root.
///
/// Optional to handle cases where `isGitRepo` is false.
let repoPath: String?
/// The currently checked-out branch name.
let currentBranch: String?
/// The remote URL for the origin remote.
let remoteUrl: String?
/// The GitHub URL if this is a GitHub repository.
///
/// Automatically derived from `remoteUrl` when it's a GitHub remote.
let githubUrl: String?
/// Whether the repository has any uncommitted changes.
///
/// Optional for when `isGitRepo` is false.
let hasChanges: Bool?
/// Number of files with unstaged modifications.
let modifiedCount: Int?
/// Number of untracked files.
let untrackedCount: Int?
/// Number of files staged for commit.
let stagedCount: Int?
/// Number of new files added to the repository.
let addedCount: Int?
/// Number of files deleted from the repository.
let deletedCount: Int?
/// Number of commits ahead of the upstream branch.
let aheadCount: Int?
/// Number of commits behind the upstream branch.
let behindCount: Int?
/// Whether this branch has an upstream tracking branch.
let hasUpstream: Bool?
/// Whether this repository is a Git worktree.
let isWorktree: Bool?
}
/// Monitors and caches Git repository status information for efficient UI updates.
///
@ -38,18 +145,14 @@ public final class GitRepositoryMonitor {
// MARK: - Private Properties
/// Logger for debugging
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "GitRepositoryMonitor")
/// Operation queue for rate limiting git operations
private let gitOperationQueue = OperationQueue()
/// Path to the git binary
private let gitPath: String = {
// Check common locations
let locations = ["/usr/bin/git", "/opt/homebrew/bin/git", "/usr/local/bin/git"]
for path in locations where FileManager.default.fileExists(atPath: path) {
return path
}
return "/usr/bin/git" // fallback
}()
/// Server manager for API requests
private let serverManager = ServerManager.shared
// MARK: - Public Methods
@ -65,42 +168,133 @@ public final class GitRepositoryMonitor {
return cached
}
/// Get list of branches for a repository
/// - Parameter repoPath: Path to the Git repository
/// - Returns: Array of branch names (without refs/heads/ prefix)
public func getBranches(for repoPath: String) async -> [String] {
do {
// Define the branch structure we expect from the server
// Represents a Git branch from the server API.
struct Branch: Codable {
/// The branch name (e.g., "main", "feature/login").
let name: String
/// Whether this is the currently checked-out branch.
let current: Bool
/// Whether this is a remote tracking branch.
let remote: Bool
/// Path to the worktree using this branch, if any.
let worktreePath: String?
}
let branches = try await serverManager.performRequest(
endpoint: "/api/repositories/branches",
method: "GET",
queryItems: [URLQueryItem(name: "path", value: repoPath)],
responseType: [Branch].self
)
// Filter to local branches only and extract names
let localBranchNames = branches
.filter { !$0.remote }
.map(\.name)
logger.debug("Retrieved \(localBranchNames.count) local branches from server")
return localBranchNames
} catch {
logger.error("Failed to get branches from server: \(error)")
return []
}
}
/// Find Git repository for a given file path and return its status
/// - Parameter filePath: Path to a file within a potential Git repository
/// - Returns: GitRepository information if found, nil otherwise
public func findRepository(for filePath: String) async -> GitRepository? {
logger.info("🔍 findRepository called for: \(filePath)")
// Validate path first
guard validatePath(filePath) else {
logger.warning("❌ Path validation failed for: \(filePath)")
return nil
}
// Check cache first
if let cached = getCachedRepository(for: filePath) {
return cached
logger.debug("📦 Found cached repository for: \(filePath)")
// Check if this was recently checked (within 30 seconds)
if let lastCheck = recentRepositoryChecks[filePath],
Date().timeIntervalSince(lastCheck) < recentCheckThreshold
{
logger
.debug(
"⏭️ Skipping redundant check for: \(filePath) (checked \(Int(Date().timeIntervalSince(lastCheck)))s ago)"
)
return cached
}
}
// Find the Git repository root
guard let repoPath = await findGitRoot(from: filePath) else {
return nil
// Check if there's already a pending request for this exact path
if let pendingTask = pendingRepositoryRequests[filePath] {
logger.debug("🔄 Waiting for existing request for: \(filePath)")
return await pendingTask.value
}
// Check if we already have this repository cached
let cachedRepo = repositoryCache[repoPath]
if let cachedRepo {
// Cache the file->repo mapping
fileToRepoCache[filePath] = repoPath
return cachedRepo
// Create a new task for this request
let task = Task<GitRepository?, Never> { [weak self] in
guard let self else { return nil }
// Find the Git repository root
guard let repoPath = await self.findGitRoot(from: filePath) else {
logger.info("❌ No Git root found for: \(filePath)")
// Mark as recently checked even for non-git paths to avoid repeated checks
await MainActor.run {
self.recentRepositoryChecks[filePath] = Date()
}
return nil
}
logger.info("✅ Found Git root at: \(repoPath)")
// Check if we already have this repository cached
let cachedRepo = await MainActor.run { self.repositoryCache[repoPath] }
if let cachedRepo {
// Cache the file->repo mapping
await MainActor.run {
self.fileToRepoCache[filePath] = repoPath
self.recentRepositoryChecks[filePath] = Date()
}
logger.debug("📦 Using cached repo data for: \(repoPath)")
return cachedRepo
}
// Get repository status
let repository = await self.getRepositoryStatus(at: repoPath)
// Cache the result by repository path
if let repository {
await MainActor.run {
self.cacheRepository(repository, originalFilePath: filePath)
self.recentRepositoryChecks[filePath] = Date()
}
logger.info("✅ Repository status obtained and cached for: \(repoPath)")
} else {
logger.error("❌ Failed to get repository status for: \(repoPath)")
}
return repository
}
// Get repository status
let repository = await getRepositoryStatus(at: repoPath)
// Store the pending task
pendingRepositoryRequests[filePath] = task
// Cache the result by repository path
if let repository {
cacheRepository(repository, originalFilePath: filePath)
}
// Get the result
let result = await task.value
return repository
// Clean up the pending task
pendingRepositoryRequests[filePath] = nil
return result
}
/// Clear the repository cache
@ -109,6 +303,8 @@ public final class GitRepositoryMonitor {
fileToRepoCache.removeAll()
githubURLCache.removeAll()
githubURLFetchesInProgress.removeAll()
pendingRepositoryRequests.removeAll()
recentRepositoryChecks.removeAll()
}
/// Start monitoring and refreshing all cached repositories
@ -139,6 +335,18 @@ public final class GitRepositoryMonitor {
repositoryCache[repoPath] = fresh
}
}
// Clean up stale entries from recent checks cache
cleanupRecentChecks()
}
/// Remove old entries from the recent checks cache
private func cleanupRecentChecks() {
let cutoffDate = Date().addingTimeInterval(-recentCheckThreshold * 2) // Remove entries older than 60 seconds
recentRepositoryChecks = recentRepositoryChecks.filter { _, checkDate in
checkDate > cutoffDate
}
logger.debug("🧹 Cleaned up recent checks cache, \(self.recentRepositoryChecks.count) entries remaining")
}
// MARK: - Private Properties
@ -158,6 +366,15 @@ public final class GitRepositoryMonitor {
/// Timer for periodic monitoring
private var monitoringTimer: Timer?
/// Tracks in-flight requests for repository lookups to prevent duplicates
private var pendingRepositoryRequests: [String: Task<GitRepository?, Never>] = [:]
/// Tracks recent repository checks with timestamps to skip redundant checks
private var recentRepositoryChecks: [String: Date] = [:]
/// Duration to consider a repository check as "recent" (30 seconds)
private let recentCheckThreshold: TimeInterval = 30.0
// MARK: - Private Methods
private func cacheRepository(_ repository: GitRepository, originalFilePath: String? = nil) {
@ -196,22 +413,29 @@ public final class GitRepositoryMonitor {
/// Find the Git repository root starting from a given path
private nonisolated func findGitRoot(from path: String) async -> String? {
let expandedPath = NSString(string: path).expandingTildeInPath
var currentPath = URL(fileURLWithPath: expandedPath)
// If it's a file, start from its directory
if !currentPath.hasDirectoryPath {
currentPath = currentPath.deletingLastPathComponent()
// Use HTTP endpoint to check if it's a git repository
let url = await MainActor.run {
serverManager.buildURL(
endpoint: "/api/git/repo-info",
queryItems: [URLQueryItem(name: "path", value: expandedPath)]
)
}
// Search up the directory tree to the root
while currentPath.path != "/" {
let gitPath = currentPath.appendingPathComponent(".git")
guard let url else {
return nil
}
if FileManager.default.fileExists(atPath: gitPath.path) {
return currentPath.path
do {
let (data, _) = try await URLSession.shared.data(from: url)
let decoder = JSONDecoder()
let response = try decoder.decode(GitRepoInfoResponse.self, from: data)
if response.isGitRepo {
return response.repoPath
}
currentPath = currentPath.deletingLastPathComponent()
} catch {
logger.error("❌ Failed to get git repo info: \(error)")
}
return nil
@ -235,12 +459,16 @@ public final class GitRepositoryMonitor {
deletedCount: repository.deletedCount,
untrackedCount: repository.untrackedCount,
currentBranch: repository.currentBranch,
aheadCount: repository.aheadCount,
behindCount: repository.behindCount,
trackingBranch: repository.trackingBranch,
isWorktree: repository.isWorktree,
githubURL: cachedURL
)
} else {
// Fetch GitHub URL in background (non-blocking)
// Fetch GitHub URL from remote endpoint or local git command
Task {
fetchGitHubURLInBackground(for: repoPath)
await fetchGitHubURLInBackground(for: repoPath)
}
}
@ -249,112 +477,61 @@ public final class GitRepositoryMonitor {
/// Get basic repository status without GitHub URL
private nonisolated func getBasicGitStatus(at repoPath: String) async -> GitRepository? {
await withCheckedContinuation { continuation in
self.gitOperationQueue.addOperation {
// Sanitize the path before using it
guard let sanitizedPath = self.sanitizePath(repoPath) else {
continuation.resume(returning: nil)
return
}
let process = Process()
process.executableURL = URL(fileURLWithPath: self.gitPath)
process.arguments = ["status", "--porcelain", "--branch"]
process.currentDirectoryURL = URL(fileURLWithPath: sanitizedPath)
let outputPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = Pipe() // Suppress error output
do {
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
continuation.resume(returning: nil)
return
}
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: outputData, encoding: .utf8) ?? ""
let result = Self.parseGitStatus(output: output, repoPath: repoPath)
continuation.resume(returning: result)
} catch {
continuation.resume(returning: nil)
}
}
}
}
/// Parse git status --porcelain output
private nonisolated static func parseGitStatus(output: String, repoPath: String) -> GitRepository {
let lines = output.split(separator: "\n")
var currentBranch: String?
var modifiedCount = 0
var addedCount = 0
var deletedCount = 0
var untrackedCount = 0
for line in lines {
let trimmedLine = line.trimmingCharacters(in: .whitespaces)
// Parse branch information (first line with --branch flag)
if trimmedLine.hasPrefix("##") {
let branchInfo = trimmedLine.dropFirst(2).trimmingCharacters(in: .whitespaces)
// Extract branch name (format: "branch...tracking" or just "branch")
if let branchEndIndex = branchInfo.firstIndex(of: ".") {
currentBranch = String(branchInfo[..<branchEndIndex])
} else {
currentBranch = branchInfo
}
continue
}
// Skip empty lines
guard trimmedLine.count >= 2 else { continue }
// Get status code (first two characters)
let statusCode = trimmedLine.prefix(2)
// Count files based on status codes
// ?? = untracked
// M_ or _M = modified
// A_ or _A = added to index
// D_ or _D = deleted
// R_ = renamed
// C_ = copied
// U_ = unmerged
if statusCode == "??" {
untrackedCount += 1
} else if statusCode.contains("M") {
modifiedCount += 1
} else if statusCode.contains("A") {
addedCount += 1
} else if statusCode.contains("D") {
deletedCount += 1
} else if statusCode.contains("R") || statusCode.contains("C") {
// Renamed/copied files count as modified
modifiedCount += 1
} else if statusCode.contains("U") {
// Unmerged files count as modified
modifiedCount += 1
}
// Use HTTP endpoint to get git status
let url = await MainActor.run {
serverManager.buildURL(
endpoint: "/api/git/repository-info",
queryItems: [URLQueryItem(name: "path", value: repoPath)]
)
}
return GitRepository(
path: repoPath,
modifiedCount: modifiedCount,
addedCount: addedCount,
deletedCount: deletedCount,
untrackedCount: untrackedCount,
currentBranch: currentBranch
)
guard let url else {
return nil
}
do {
let (data, _) = try await URLSession.shared.data(from: url)
let decoder = JSONDecoder()
let response = try decoder.decode(GitRepositoryInfoResponse.self, from: data)
if !response.isGitRepo {
return nil
}
// Ensure we have required fields when isGitRepo is true
guard let repoPath = response.repoPath else {
logger.error("❌ Invalid response: isGitRepo is true but repoPath is missing")
return nil
}
// Use worktree status from server response
let isWorktree = response.isWorktree ?? false
// Parse GitHub URL if provided
let githubURL = response.githubUrl.flatMap { URL(string: $0) }
return GitRepository(
path: repoPath,
modifiedCount: response.modifiedCount ?? 0,
addedCount: response.addedCount ?? 0,
deletedCount: response.deletedCount ?? 0,
untrackedCount: response.untrackedCount ?? 0,
currentBranch: response.currentBranch,
aheadCount: (response.aheadCount ?? 0) > 0 ? response.aheadCount : nil,
behindCount: (response.behindCount ?? 0) > 0 ? response.behindCount : nil,
trackingBranch: (response.hasUpstream ?? false) ? "origin/\(response.currentBranch ?? "main")" : nil,
isWorktree: isWorktree,
githubURL: githubURL
)
} catch {
logger.error("❌ Failed to get git status: \(error)")
return nil
}
}
/// Fetch GitHub URL in background and cache it
@MainActor
private func fetchGitHubURLInBackground(for repoPath: String) {
private func fetchGitHubURLInBackground(for repoPath: String) async {
// Check if already cached or fetch in progress
if githubURLCache[repoPath] != nil || githubURLFetchesInProgress.contains(repoPath) {
return
@ -363,37 +540,61 @@ public final class GitRepositoryMonitor {
// Mark as in progress
githubURLFetchesInProgress.insert(repoPath)
// Fetch in background
Task {
gitOperationQueue.addOperation {
if let githubURL = GitRepository.getGitHubURL(for: repoPath) {
Task { @MainActor in
self.githubURLCache[repoPath] = githubURL
// Try to get from HTTP endpoint first
let url = await MainActor.run {
serverManager.buildURL(
endpoint: "/api/git/remote",
queryItems: [URLQueryItem(name: "path", value: repoPath)]
)
}
// Update cached repository with GitHub URL
if var cachedRepo = self.repositoryCache[repoPath] {
cachedRepo = GitRepository(
path: cachedRepo.path,
modifiedCount: cachedRepo.modifiedCount,
addedCount: cachedRepo.addedCount,
deletedCount: cachedRepo.deletedCount,
untrackedCount: cachedRepo.untrackedCount,
currentBranch: cachedRepo.currentBranch,
githubURL: githubURL
)
self.repositoryCache[repoPath] = cachedRepo
}
if let url {
do {
let (data, _) = try await URLSession.shared.data(from: url)
let decoder = JSONDecoder()
// Response from the Git remote API endpoint.
struct RemoteResponse: Codable {
/// Whether this is a valid Git repository.
let isGitRepo: Bool
/// The absolute path to the repository root.
let repoPath: String?
/// The remote origin URL.
let remoteUrl: String?
/// The GitHub URL if this is a GitHub repository.
let githubUrl: String?
}
let response = try decoder.decode(RemoteResponse.self, from: data)
// Remove from in-progress set
self.githubURLFetchesInProgress.remove(repoPath)
}
} else {
Task { @MainActor in
// Remove from in-progress set even if fetch failed
self.githubURLFetchesInProgress.remove(repoPath)
if let githubUrlString = response.githubUrl,
let githubURL = URL(string: githubUrlString)
{
self.githubURLCache[repoPath] = githubURL
// Update cached repository with GitHub URL
if var cachedRepo = self.repositoryCache[repoPath] {
cachedRepo = GitRepository(
path: cachedRepo.path,
modifiedCount: cachedRepo.modifiedCount,
addedCount: cachedRepo.addedCount,
deletedCount: cachedRepo.deletedCount,
untrackedCount: cachedRepo.untrackedCount,
currentBranch: cachedRepo.currentBranch,
aheadCount: cachedRepo.aheadCount,
behindCount: cachedRepo.behindCount,
trackingBranch: cachedRepo.trackingBranch,
isWorktree: cachedRepo.isWorktree,
githubURL: githubURL
)
self.repositoryCache[repoPath] = cachedRepo
}
}
} catch {
// HTTP endpoint failed, log the error but don't fallback to direct git
logger.debug("Failed to fetch GitHub URL from server: \(error)")
}
}
// Remove from in-progress set
self.githubURLFetchesInProgress.remove(repoPath)
}
}

View file

@ -37,15 +37,6 @@ struct NgrokTunnelStatus: Codable {
let publicUrl: String
let metrics: TunnelMetrics
let startedAt: Date
/// Traffic metrics for the ngrok tunnel.
///
/// Tracks connection count and bandwidth usage.
struct TunnelMetrics: Codable {
let connectionsCount: Int
let bytesIn: Int64
let bytesOut: Int64
}
}
/// Protocol for ngrok tunnel operations.
@ -284,7 +275,7 @@ final class NgrokService: NgrokTunnelProtocol {
if tunnelStatus == nil {
tunnelStatus = NgrokTunnelStatus(
publicUrl: publicUrl ?? "",
metrics: .init(connectionsCount: 0, bytesIn: 0, bytesOut: 0),
metrics: TunnelMetrics(connectionsCount: 0, bytesIn: 0, bytesOut: 0),
startedAt: Date()
)
}
@ -377,7 +368,7 @@ struct AsyncLineSequence: AsyncSequence {
/// Provides secure storage and retrieval of ngrok authentication tokens
/// using the macOS Keychain Services API.
private enum KeychainHelper {
private static let service = "sh.vibetunnel.vibetunnel"
private static let service = KeychainConstants.vibeTunnelService
private static let account = "ngrok-auth-token"
static func getNgrokAuthToken() -> String? {

View file

@ -19,7 +19,7 @@ final class PowerManagementService {
private var assertionID: IOPMAssertionID = 0
private var isAssertionActive = false
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "PowerManagement")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "PowerManagement")
private init() {}

View file

@ -14,7 +14,7 @@ final class RemoteServicesStatusManager {
private var statusCheckTimer: Timer?
private let checkInterval: TimeInterval = RemoteAccessConstants.statusCheckInterval
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "RemoteServicesStatus")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "RemoteServicesStatus")
// Service references
private let ngrokService = NgrokService.shared

View file

@ -6,7 +6,7 @@ import OSLog
extension Logger {
fileprivate static let repositoryDiscovery = Logger(
subsystem: "sh.vibetunnel.vibetunnel",
subsystem: BundleIdentifiers.loggerSubsystem,
category: "RepositoryDiscovery"
)
}

View file

@ -354,21 +354,10 @@ class ServerManager {
try? await Task.sleep(for: .milliseconds(10_000))
do {
// Create URL for cleanup endpoint
guard let url = URL(string: "\(URLConstants.localServerBase):\(self.port)\(APIEndpoints.cleanupExited)")
else {
logger.warning("Failed to create cleanup URL")
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
// Create authenticated request for cleanup
var request = try makeRequest(endpoint: APIEndpoints.cleanupExited, method: "POST")
request.timeoutInterval = 10
// Add local auth token if available
if let server = bunServer {
request.setValue(server.localToken, forHTTPHeaderField: NetworkConstants.localAuthHeader)
}
// Make the cleanup request
let (data, response) = try await URLSession.shared.data(for: request)
@ -572,6 +561,142 @@ class ServerManager {
}
request.setValue(server.localToken, forHTTPHeaderField: NetworkConstants.localAuthHeader)
}
// MARK: - Request Helpers
/// Build a URL for the local server with the given endpoint
func buildURL(endpoint: String) -> URL? {
URL(string: "\(URLConstants.localServerBase):\(port)\(endpoint)")
}
/// Build a URL for the local server with the given endpoint and query parameters
func buildURL(endpoint: String, queryItems: [URLQueryItem]?) -> URL? {
guard let baseURL = buildURL(endpoint: endpoint) else { return nil }
guard let queryItems, !queryItems.isEmpty else {
return baseURL
}
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
components?.queryItems = queryItems
return components?.url
}
/// Create an authenticated JSON request
func makeRequest(
endpoint: String,
method: String = "POST",
body: Encodable? = nil,
queryItems: [URLQueryItem]? = nil
)
throws -> URLRequest
{
let url: URL? = if let queryItems, !queryItems.isEmpty {
buildURL(endpoint: endpoint, queryItems: queryItems)
} else {
buildURL(endpoint: endpoint)
}
guard let url else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue(NetworkConstants.contentTypeJSON, forHTTPHeaderField: NetworkConstants.contentTypeHeader)
request.setValue(NetworkConstants.localhost, forHTTPHeaderField: NetworkConstants.hostHeader)
if let body {
request.httpBody = try JSONEncoder().encode(body)
}
try authenticate(request: &request)
return request
}
}
// MARK: - Network Request Extension
extension ServerManager {
/// Perform a network request with automatic JSON parsing and error handling
/// - Parameters:
/// - endpoint: The API endpoint path
/// - method: HTTP method (default: "POST")
/// - body: Optional request body (Encodable)
/// - queryItems: Optional query parameters
/// - responseType: The expected response type (must be Decodable)
/// - Returns: Decoded response of the specified type
/// - Throws: NetworkError for various failure cases
func performRequest<T: Decodable>(
endpoint: String,
method: String = "POST",
body: Encodable? = nil,
queryItems: [URLQueryItem]? = nil,
responseType: T.Type
)
async throws -> T
{
let request = try makeRequest(
endpoint: endpoint,
method: method,
body: body,
queryItems: queryItems
)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
let errorData = try? JSONDecoder().decode(ErrorResponse.self, from: data)
throw NetworkError.serverError(
statusCode: httpResponse.statusCode,
message: errorData?.error ?? "Request failed with status \(httpResponse.statusCode)"
)
}
return try JSONDecoder().decode(T.self, from: data)
}
/// Perform a network request that returns no body (void response)
/// - Parameters:
/// - endpoint: The API endpoint path
/// - method: HTTP method (default: "POST")
/// - body: Optional request body (Encodable)
/// - queryItems: Optional query parameters
/// - Throws: NetworkError for various failure cases
func performVoidRequest(
endpoint: String,
method: String = "POST",
body: Encodable? = nil,
queryItems: [URLQueryItem]? = nil
)
async throws
{
let request = try makeRequest(
endpoint: endpoint,
method: method,
body: body,
queryItems: queryItems
)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
let errorData = try? JSONDecoder().decode(ErrorResponse.self, from: data)
throw NetworkError.serverError(
statusCode: httpResponse.statusCode,
message: errorData?.error ?? "Request failed with status \(httpResponse.statusCode)"
)
}
}
}
// MARK: - Server Manager Error

View file

@ -8,19 +8,33 @@ import os.log
/// including its command, directory, process status, and activity information.
struct ServerSessionInfo: Codable {
let id: String
let command: [String] // Changed from String to [String] to match server
let name: String? // Added missing field
let name: String
let command: [String]
let workingDir: String
let status: String
let exitCode: Int?
let startedAt: String
let pid: Int?
let initialCols: Int?
let initialRows: Int?
let lastClearOffset: Int?
let version: String?
let gitRepoPath: String?
let gitBranch: String?
let gitAheadCount: Int?
let gitBehindCount: Int?
let gitHasChanges: Bool?
let gitIsWorktree: Bool?
let gitMainRepoPath: String?
// Additional fields from Session (not SessionInfo)
let lastModified: String
let pid: Int? // Made optional since it might not exist for all sessions
let initialCols: Int? // Added missing field
let initialRows: Int? // Added missing field
let active: Bool?
let activityStatus: ActivityStatus?
let source: String? // Added for HQ mode
let attachedViaVT: Bool? // Added for VT attachment tracking
let source: String?
let remoteId: String?
let remoteName: String?
let remoteUrl: String?
var isRunning: Bool {
status == "running"
@ -60,9 +74,8 @@ final class SessionMonitor {
private var lastFetch: Date?
private let cacheInterval: TimeInterval = 2.0
private let serverPort: Int
private var localAuthToken: String?
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "SessionMonitor")
private let serverManager = ServerManager.shared
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "SessionMonitor")
/// Reference to GitRepositoryMonitor for pre-caching
weak var gitRepositoryMonitor: GitRepositoryMonitor?
@ -71,17 +84,12 @@ final class SessionMonitor {
private var refreshTimer: Timer?
private init() {
let port = UserDefaults.standard.integer(forKey: "serverPort")
self.serverPort = port > 0 ? port : 4_020
// Start periodic refresh
startPeriodicRefresh()
}
/// Set the local auth token for server requests
func setLocalAuthToken(_ token: String?) {
self.localAuthToken = token
}
func setLocalAuthToken(_ token: String?) {}
/// Number of running sessions
var sessionCount: Int {
@ -109,33 +117,11 @@ final class SessionMonitor {
private func fetchSessions() async {
do {
// Get current port (might have changed)
let port = UserDefaults.standard.integer(forKey: "serverPort")
let actualPort = port > 0 ? port : serverPort
guard let url = URL(string: "http://localhost:\(actualPort)/api/sessions") else {
throw URLError(.badURL)
}
var request = URLRequest(url: url, timeoutInterval: 3.0)
// Add Host header to ensure request is recognized as local
request.setValue("localhost", forHTTPHeaderField: "Host")
// Add local auth token if available
if let token = localAuthToken {
request.setValue(token, forHTTPHeaderField: "X-VibeTunnel-Local")
}
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200
else {
throw URLError(.badServerResponse)
}
let sessionsArray = try JSONDecoder().decode([ServerSessionInfo].self, from: data)
let sessionsArray = try await serverManager.performRequest(
endpoint: APIEndpoints.sessions,
method: "GET",
responseType: [ServerSessionInfo].self
)
// Convert to dictionary
var sessionsDict: [String: ServerSessionInfo] = [:]

View file

@ -1,6 +1,46 @@
import Foundation
import Observation
/// Request body for creating a new session
struct SessionCreateRequest: Encodable {
let command: [String]
let workingDir: String
let titleMode: String
let name: String?
let spawnTerminal: Bool?
let cols: Int?
let rows: Int?
let gitRepoPath: String?
let gitBranch: String?
enum CodingKeys: String, CodingKey {
case command
case workingDir
case titleMode
case name
case spawnTerminal = "spawn_terminal"
case cols
case rows
case gitRepoPath
case gitBranch
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(command, forKey: .command)
try container.encode(workingDir, forKey: .workingDir)
try container.encode(titleMode, forKey: .titleMode)
// Only encode optional values if they're present
try container.encodeIfPresent(name, forKey: .name)
try container.encodeIfPresent(spawnTerminal, forKey: .spawnTerminal)
try container.encodeIfPresent(cols, forKey: .cols)
try container.encodeIfPresent(rows, forKey: .rows)
try container.encodeIfPresent(gitRepoPath, forKey: .gitRepoPath)
try container.encodeIfPresent(gitBranch, forKey: .gitBranch)
}
}
/// Service for managing session-related API operations.
///
/// Provides high-level methods for interacting with terminal sessions through
@ -24,28 +64,12 @@ final class SessionService {
throw SessionServiceError.invalidName
}
guard let url =
URL(string: "\(URLConstants.localServerBase):\(serverManager.port)\(APIEndpoints.sessions)/\(sessionId)")
else {
throw SessionServiceError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "PATCH"
request.setValue(NetworkConstants.contentTypeJSON, forHTTPHeaderField: NetworkConstants.contentTypeHeader)
request.setValue(NetworkConstants.localhost, forHTTPHeaderField: NetworkConstants.hostHeader)
try serverManager.authenticate(request: &request)
let body = ["name": trimmedName]
request.httpBody = try JSONEncoder().encode(body)
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200
else {
throw SessionServiceError.requestFailed(statusCode: (response as? HTTPURLResponse)?.statusCode ?? -1)
}
try await serverManager.performVoidRequest(
endpoint: "\(APIEndpoints.sessions)/\(sessionId)",
method: "PATCH",
body: body
)
// Force refresh the session monitor to see the update immediately
await sessionMonitor.refresh()
@ -68,24 +92,10 @@ final class SessionService {
/// - Note: The server implements graceful termination (SIGTERM SIGKILL)
/// with a 3-second timeout before force-killing processes.
func terminateSession(sessionId: String) async throws {
guard let url =
URL(string: "\(URLConstants.localServerBase):\(serverManager.port)\(APIEndpoints.sessions)/\(sessionId)")
else {
throw SessionServiceError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
request.setValue(NetworkConstants.localhost, forHTTPHeaderField: NetworkConstants.hostHeader)
try serverManager.authenticate(request: &request)
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 || httpResponse.statusCode == 204
else {
throw SessionServiceError.requestFailed(statusCode: (response as? HTTPURLResponse)?.statusCode ?? -1)
}
try await serverManager.performVoidRequest(
endpoint: "\(APIEndpoints.sessions)/\(sessionId)",
method: "DELETE"
)
// After successfully terminating the session, close the window if we opened it.
// This is the key feature that prevents orphaned terminal windows.
@ -110,30 +120,12 @@ final class SessionService {
throw SessionServiceError.serverNotRunning
}
guard let url =
URL(
string: "\(URLConstants.localServerBase):\(serverManager.port)\(APIEndpoints.sessions)/\(sessionId)/input"
)
else {
throw SessionServiceError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(NetworkConstants.contentTypeJSON, forHTTPHeaderField: NetworkConstants.contentTypeHeader)
request.setValue(NetworkConstants.localhost, forHTTPHeaderField: NetworkConstants.hostHeader)
try serverManager.authenticate(request: &request)
let body = ["text": text]
request.httpBody = try JSONEncoder().encode(body)
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 || httpResponse.statusCode == 204
else {
throw SessionServiceError.requestFailed(statusCode: (response as? HTTPURLResponse)?.statusCode ?? -1)
}
try await serverManager.performVoidRequest(
endpoint: "\(APIEndpoints.sessions)/\(sessionId)/input",
method: "POST",
body: body
)
}
/// Send a key command to a session
@ -142,30 +134,12 @@ final class SessionService {
throw SessionServiceError.serverNotRunning
}
guard let url =
URL(
string: "\(URLConstants.localServerBase):\(serverManager.port)\(APIEndpoints.sessions)/\(sessionId)/input"
)
else {
throw SessionServiceError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(NetworkConstants.contentTypeJSON, forHTTPHeaderField: NetworkConstants.contentTypeHeader)
request.setValue(NetworkConstants.localhost, forHTTPHeaderField: NetworkConstants.hostHeader)
try serverManager.authenticate(request: &request)
let body = ["key": key]
request.httpBody = try JSONEncoder().encode(body)
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 || httpResponse.statusCode == 204
else {
throw SessionServiceError.requestFailed(statusCode: (response as? HTTPURLResponse)?.statusCode ?? -1)
}
try await serverManager.performVoidRequest(
endpoint: "\(APIEndpoints.sessions)/\(sessionId)/input",
method: "POST",
body: body
)
}
/// Create a new session
@ -176,7 +150,9 @@ final class SessionService {
titleMode: String = "dynamic",
spawnTerminal: Bool = false,
cols: Int = 120,
rows: Int = 30
rows: Int = 30,
gitRepoPath: String? = nil,
gitBranch: String? = nil
)
async throws -> String
{
@ -184,60 +160,35 @@ final class SessionService {
throw SessionServiceError.serverNotRunning
}
guard let url = URL(string: "\(URLConstants.localServerBase):\(serverManager.port)\(APIEndpoints.sessions)")
else {
throw SessionServiceError.invalidURL
}
// Trim the name if provided
let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines)
let finalName = (trimmedName?.isEmpty ?? true) ? nil : trimmedName
var body: [String: Any] = [
"command": command,
"workingDir": workingDir,
"titleMode": titleMode
]
// Create the strongly-typed request
let requestBody = SessionCreateRequest(
command: command,
workingDir: workingDir,
titleMode: titleMode,
name: finalName,
spawnTerminal: spawnTerminal ? true : nil,
cols: spawnTerminal ? nil : cols,
rows: spawnTerminal ? nil : rows,
gitRepoPath: gitRepoPath,
gitBranch: gitBranch
)
if let name = name?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
body["name"] = name
}
if spawnTerminal {
body["spawn_terminal"] = true
} else {
// Web sessions need terminal dimensions
body["cols"] = cols
body["rows"] = rows
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(NetworkConstants.contentTypeJSON, forHTTPHeaderField: NetworkConstants.contentTypeHeader)
request.setValue(NetworkConstants.localhost, forHTTPHeaderField: NetworkConstants.hostHeader)
try serverManager.authenticate(request: &request)
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200
else {
var errorMessage = "Failed to create session"
if let errorData = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let error = errorData["error"] as? String
{
errorMessage = error
}
throw SessionServiceError.createFailed(message: errorMessage)
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let sessionId = json["sessionId"] as? String
else {
throw SessionServiceError.invalidResponse
}
// Use performRequest to create the session
let createResponse = try await serverManager.performRequest(
endpoint: APIEndpoints.sessions,
method: "POST",
body: requestBody,
responseType: CreateSessionResponse.self
)
// Refresh session list
await sessionMonitor.refresh()
return sessionId
return createResponse.sessionId
}
}

View file

@ -5,7 +5,7 @@ import OSLog
/// This handles all control messages between the Mac app and the server
@MainActor
final class SharedUnixSocketManager {
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "SharedUnixSocket")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "SharedUnixSocket")
// MARK: - Singleton

View file

@ -16,12 +16,12 @@ public final class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate {
fileprivate var updaterController: SPUStandardUpdaterController?
private(set) var userDriverDelegate: SparkleUserDriverDelegate?
private let logger = os.Logger(
subsystem: "sh.vibetunnel.vibetunnel",
subsystem: BundleIdentifiers.loggerSubsystem,
category: "SparkleUpdater"
)
private nonisolated static let staticLogger = os.Logger(
subsystem: "sh.vibetunnel.vibetunnel",
subsystem: BundleIdentifiers.loggerSubsystem,
category: "SparkleUpdater"
)

View file

@ -9,7 +9,7 @@ import UserNotifications
@MainActor
final class SparkleUserDriverDelegate: NSObject, @preconcurrency SPUStandardUserDriverDelegate {
private let logger = os.Logger(
subsystem: "sh.vibetunnel.vibetunnel",
subsystem: BundleIdentifiers.loggerSubsystem,
category: "SparkleUserDriver"
)

View file

@ -17,7 +17,7 @@ public protocol StartupControlling: Sendable {
/// - Integration with macOS ServiceManagement APIs
@MainActor
public struct StartupManager: StartupControlling {
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "startup")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "startup")
public init() {}

View file

@ -8,7 +8,7 @@ import OSLog
/// The handler must be registered during app initialization to handle these messages.
@MainActor
final class SystemControlHandler {
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "SystemControl")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "SystemControl")
// MARK: - Properties

View file

@ -62,7 +62,7 @@ final class SystemPermissionManager {
]
private let logger = Logger(
subsystem: "sh.vibetunnel.vibetunnel",
subsystem: BundleIdentifiers.loggerSubsystem,
category: "SystemPermissions"
)
@ -72,6 +72,12 @@ final class SystemPermissionManager {
/// Count of views that have registered for monitoring
private var monitorRegistrationCount = 0
/// Last time permissions were checked to avoid excessive checking
private var lastPermissionCheck: Date?
/// Minimum interval between permission checks (in seconds)
private let minimumCheckInterval: TimeInterval = 0.5
init() {
// No automatic monitoring - UI components will register when visible
}
@ -177,7 +183,7 @@ final class SystemPermissionManager {
}
private func startMonitoring() {
logger.info("Starting permission monitoring")
logger.info("Starting permission monitoring (registration count: \(self.monitorRegistrationCount))")
// Initial check
Task {
@ -191,17 +197,29 @@ final class SystemPermissionManager {
await self.checkAllPermissions()
}
}
logger.debug("Permission monitoring timer created: \(String(describing: self.monitorTimer))")
}
private func stopMonitoring() {
logger.info("Stopping permission monitoring")
logger.info("Stopping permission monitoring (registration count: \(self.monitorRegistrationCount))")
monitorTimer?.invalidate()
monitorTimer = nil
// Clear the last check time to ensure immediate check on next start
lastPermissionCheck = nil
}
// MARK: - Permission Checking
func checkAllPermissions() async {
// Avoid checking too frequently
if let lastCheck = lastPermissionCheck,
Date().timeIntervalSince(lastCheck) < minimumCheckInterval
{
return
}
lastPermissionCheck = Date()
let oldPermissions = permissions
// Check each permission type
@ -221,10 +239,20 @@ final class SystemPermissionManager {
let testScript = "return \"test\""
do {
// Use a short timeout since this script is very simple
// This script is very simple and should complete quickly if permissions are granted
_ = try await AppleScriptExecutor.shared.executeAsync(testScript, timeout: 1.0)
return true
} catch let error as AppleScriptError {
// Only log actual errors, not timeouts which are expected when permissions are denied
if case .timeout = error {
logger.debug("AppleScript permission check timed out - likely no permission")
} else {
logger.debug("AppleScript check failed: \(error)")
}
return false
} catch {
logger.debug("AppleScript check failed: \(error)")
logger.debug("AppleScript check failed with unexpected error: \(error)")
return false
}
}

View file

@ -21,7 +21,7 @@ final class TailscaleService {
private static let apiTimeoutInterval: TimeInterval = 5.0
/// Logger instance for debugging
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "TailscaleService")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "TailscaleService")
/// Indicates if Tailscale app is installed on the system
private(set) var isInstalled = false

View file

@ -4,7 +4,7 @@ import OSLog
/// Handles terminal control messages via the unified control socket
@MainActor
final class TerminalControlHandler {
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "TerminalControl")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "TerminalControl")
// MARK: - Singleton

View file

@ -5,7 +5,7 @@ import OSLog
/// Manages UNIX socket connection for screen capture communication with automatic reconnection
@MainActor
final class UnixSocketConnection {
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "UnixSocket")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "UnixSocket")
// MARK: - Properties
@ -50,7 +50,10 @@ final class UnixSocketConnection {
/// Connection state change callback
var onStateChange: ((ConnectionState) -> Void)?
/// Connection states similar to NWConnection.State
/// Connection states for the Unix socket.
///
/// Represents the various states of the socket connection lifecycle,
/// similar to `NWConnection.State` for consistency with Network framework patterns.
enum ConnectionState {
case setup
case preparing
@ -870,6 +873,10 @@ final class UnixSocketConnection {
// MARK: - Errors
/// Errors specific to Unix socket operations.
///
/// Provides detailed error information for Unix socket connection failures,
/// data transmission issues, and connection state problems.
enum UnixSocketError: LocalizedError {
case notConnected
case connectionFailed(Error)

View file

@ -37,12 +37,12 @@ final class WindowTracker {
static let shared = WindowTracker()
private let logger = Logger(
subsystem: "sh.vibetunnel.vibetunnel",
subsystem: BundleIdentifiers.loggerSubsystem,
category: "WindowTracker"
)
/// Maps session IDs to their terminal window information
private var sessionWindowMap: [String: WindowEnumerator.WindowInfo] = [:]
private var sessionWindowMap: [String: WindowInfo] = [:]
/// Tracks which sessions we opened via AppleScript (and can close).
///
@ -124,14 +124,14 @@ final class WindowTracker {
// MARK: - Window Information
/// Gets the window information for a specific session.
func windowInfo(for sessionID: String) -> WindowEnumerator.WindowInfo? {
func windowInfo(for sessionID: String) -> WindowInfo? {
mapLock.withLock {
sessionWindowMap[sessionID]
}
}
/// Gets all tracked windows.
func allTrackedWindows() -> [WindowEnumerator.WindowInfo] {
func allTrackedWindows() -> [WindowInfo] {
mapLock.withLock {
Array(sessionWindowMap.values)
}
@ -249,7 +249,7 @@ final class WindowTracker {
/// - Returns: AppleScript string to close the window, or empty string if unsupported
///
/// - Note: All scripts include error handling to gracefully handle already-closed windows
private func generateCloseWindowScript(for windowInfo: WindowEnumerator.WindowInfo) -> String {
private func generateCloseWindowScript(for windowInfo: WindowInfo) -> String {
switch windowInfo.terminalApp {
case .terminal:
// Use window ID to close - more reliable than tab references
@ -364,7 +364,7 @@ final class WindowTracker {
}
logger
.info(
"Found and registered window for session: \(session.id) (attachedViaVT: \(session.attachedViaVT ?? false))"
"Found and registered window for session: \(session.id)"
)
} else {
logger.debug("Could not find window for session: \(session.id)")
@ -382,7 +382,7 @@ final class WindowTracker {
tabReference: String?,
tabID: String?
)
-> WindowEnumerator.WindowInfo?
-> WindowInfo?
{
let allWindows = WindowEnumerator.getAllTerminalWindows()
let sessionInfo = getSessionInfo(for: sessionID)
@ -409,15 +409,15 @@ final class WindowTracker {
/// Helper to create WindowInfo from a found window
private func createWindowInfo(
from window: WindowEnumerator.WindowInfo,
from window: WindowInfo,
sessionID: String,
terminal: Terminal,
tabReference: String?,
tabID: String?
)
-> WindowEnumerator.WindowInfo
-> WindowInfo
{
WindowEnumerator.WindowInfo(
WindowInfo(
windowID: window.windowID,
ownerPID: window.ownerPID,
terminalApp: terminal,
@ -438,15 +438,13 @@ final class WindowTracker {
}
/// Finds a terminal window for a session that was attached via `vt`.
private func findWindowForSession(_ sessionID: String, sessionInfo: ServerSessionInfo) -> WindowEnumerator
.WindowInfo?
{
private func findWindowForSession(_ sessionID: String, sessionInfo: ServerSessionInfo) -> WindowInfo? {
let allWindows = WindowEnumerator.getAllTerminalWindows()
if let window = windowMatcher
.findWindowForSession(sessionID, sessionInfo: sessionInfo, allWindows: allWindows)
{
return WindowEnumerator.WindowInfo(
return WindowInfo(
windowID: window.windowID,
ownerPID: window.ownerPID,
terminalApp: window.terminalApp,

View file

@ -6,7 +6,7 @@ import OSLog
@MainActor
final class PermissionChecker {
private let logger = Logger(
subsystem: "sh.vibetunnel.vibetunnel",
subsystem: BundleIdentifiers.loggerSubsystem,
category: "PermissionChecker"
)

View file

@ -6,7 +6,7 @@ import OSLog
@MainActor
final class ProcessTracker {
private let logger = Logger(
subsystem: "sh.vibetunnel.vibetunnel",
subsystem: BundleIdentifiers.loggerSubsystem,
category: "ProcessTracker"
)

View file

@ -1,4 +1,5 @@
import AppKit
import CoreGraphics
import Foundation
import OSLog
@ -6,47 +7,30 @@ import OSLog
@MainActor
final class WindowEnumerator {
private let logger = Logger(
subsystem: "sh.vibetunnel.vibetunnel",
subsystem: BundleIdentifiers.loggerSubsystem,
category: "WindowEnumerator"
)
/// Information about a tracked terminal window
struct WindowInfo {
let windowID: CGWindowID
let ownerPID: pid_t
let terminalApp: Terminal
let sessionID: String
let createdAt: Date
// Tab-specific information
let tabReference: String? // AppleScript reference for Terminal.app tabs
let tabID: String? // Tab identifier for iTerm2
// Window properties from Accessibility APIs
let bounds: CGRect?
let title: String?
}
/// Gets all terminal windows currently visible on screen using Accessibility APIs.
static func getAllTerminalWindows() -> [WindowInfo] {
// Get bundle identifiers for all terminal types
let terminalBundleIDs = Terminal.allCases.compactMap(\.bundleIdentifier)
// Use AXElement to enumerate windows
let axWindows = AXElement.enumerateWindows(
bundleIdentifiers: terminalBundleIDs,
includeMinimized: false
)
// Convert AXElement.WindowInfo to our WindowInfo
return axWindows.compactMap { axWindow in
// Find the matching Terminal enum
guard let terminal = Terminal.allCases.first(where: {
$0.bundleIdentifier == axWindow.bundleIdentifier
guard let terminal = Terminal.allCases.first(where: {
$0.bundleIdentifier == axWindow.bundleIdentifier
}) else {
return nil
}
return WindowInfo(
windowID: axWindow.windowID,
ownerPID: axWindow.pid,

View file

@ -6,7 +6,7 @@ import OSLog
@MainActor
final class WindowFocuser {
private let logger = Logger(
subsystem: "sh.vibetunnel.vibetunnel",
subsystem: BundleIdentifiers.loggerSubsystem,
category: "WindowFocuser"
)
@ -81,7 +81,7 @@ final class WindowFocuser {
}
/// Focus a window based on terminal type
func focusWindow(_ windowInfo: WindowEnumerator.WindowInfo) {
func focusWindow(_ windowInfo: WindowInfo) {
switch windowInfo.terminalApp {
case .terminal:
// Terminal.app has special AppleScript support for tab selection
@ -96,7 +96,7 @@ final class WindowFocuser {
}
/// Focuses a Terminal.app window/tab.
private func focusTerminalAppWindow(_ windowInfo: WindowEnumerator.WindowInfo) {
private func focusTerminalAppWindow(_ windowInfo: WindowInfo) {
if let tabRef = windowInfo.tabReference {
// Use stored tab reference to select the tab
// The tabRef format is "tab id X of window id Y"
@ -146,7 +146,7 @@ final class WindowFocuser {
}
/// Focuses an iTerm2 window.
private func focusiTerm2Window(_ windowInfo: WindowEnumerator.WindowInfo) {
private func focusiTerm2Window(_ windowInfo: WindowInfo) {
// iTerm2 has its own tab system that doesn't use standard macOS tabs
// We need to use AppleScript to find and select the correct tab
@ -221,7 +221,7 @@ final class WindowFocuser {
/// Select the correct tab in a window that uses macOS standard tabs
private func selectTab(
tabs: [AXElement],
windowInfo: WindowEnumerator.WindowInfo,
windowInfo: WindowInfo,
sessionInfo: ServerSessionInfo?
) {
logger.debug("Attempting to select tab for session \(windowInfo.sessionID) from \(tabs.count) tabs")
@ -277,7 +277,7 @@ final class WindowFocuser {
}
/// Focuses a window by using the process PID directly
private func focusWindowUsingPID(_ windowInfo: WindowEnumerator.WindowInfo) -> Bool {
private func focusWindowUsingPID(_ windowInfo: WindowInfo) -> Bool {
// Get session info for better matching
let sessionInfo = SessionMonitor.shared.sessions[windowInfo.sessionID]
// Create AXElement directly from the PID
@ -346,9 +346,9 @@ final class WindowFocuser {
}
// Check for session name
if let sessionName = sessionInfo.name, !sessionName.isEmpty && title.contains(sessionName) {
if !sessionInfo.name.isEmpty && title.contains(sessionInfo.name) {
matchScore += 150 // High score for session name match
logger.debug("Window \(index) has session name in title: \(sessionName)")
logger.debug("Window \(index) has session name in title: \(sessionInfo.name)")
}
}
}
@ -406,7 +406,7 @@ final class WindowFocuser {
}
/// Focuses a window using Accessibility APIs.
private func focusWindowUsingAccessibility(_ windowInfo: WindowEnumerator.WindowInfo) {
private func focusWindowUsingAccessibility(_ windowInfo: WindowInfo) {
// First try PID-based approach
if focusWindowUsingPID(windowInfo) {
logger.info("Successfully focused window using PID-based approach")
@ -498,7 +498,7 @@ final class WindowFocuser {
logger.debug("Window \(index) has working directory in title")
}
if let sessionName = sessionInfo.name, !sessionName.isEmpty && title.contains(sessionName) {
if !sessionInfo.name.isEmpty && title.contains(sessionInfo.name) {
matchScore += 150
logger.debug("Window \(index) has session name in title")
}

View file

@ -2,7 +2,10 @@ import AppKit
import Foundation
import OSLog
/// Configuration for window highlight effects
/// Configuration for window highlight effects.
///
/// Defines the visual properties of the highlight effect applied to windows,
/// including color, animation duration, border width, and glow intensity.
struct WindowHighlightConfig {
/// The color of the highlight border
let color: NSColor
@ -52,7 +55,7 @@ struct WindowHighlightConfig {
@MainActor
final class WindowHighlightEffect {
private let logger = Logger(
subsystem: "sh.vibetunnel.vibetunnel",
subsystem: BundleIdentifiers.loggerSubsystem,
category: "WindowHighlightEffect"
)

View file

@ -6,7 +6,7 @@ import OSLog
@MainActor
final class WindowMatcher {
private let logger = Logger(
subsystem: "sh.vibetunnel.vibetunnel",
subsystem: BundleIdentifiers.loggerSubsystem,
category: "WindowMatcher"
)
@ -19,9 +19,9 @@ final class WindowMatcher {
sessionInfo: ServerSessionInfo?,
tabReference: String?,
tabID: String?,
terminalWindows: [WindowEnumerator.WindowInfo]
terminalWindows: [WindowInfo]
)
-> WindowEnumerator.WindowInfo?
-> WindowInfo?
{
// Filter windows for the specific terminal
let filteredWindows = terminalWindows.filter { $0.terminalApp == terminal }
@ -157,9 +157,9 @@ final class WindowMatcher {
func findWindowForSession(
_ sessionID: String,
sessionInfo: ServerSessionInfo,
allWindows: [WindowEnumerator.WindowInfo]
allWindows: [WindowInfo]
)
-> WindowEnumerator.WindowInfo?
-> WindowInfo?
{
// First try to find window by process PID traversal
if let sessionPID = sessionInfo.pid {
@ -259,7 +259,7 @@ final class WindowMatcher {
logger.debug("Looking for tab matching session \(sessionID) in \(tabs.count) tabs")
logger.debug(" Working dir: \(workingDir)")
logger.debug(" Dir name: \(dirName)")
logger.debug(" Session name: \(sessionName ?? "none")")
logger.debug(" Session name: \(sessionName)")
logger.debug(" Activity: \(activityStatus ?? "none")")
for (index, tab) in tabs.enumerated() {
@ -273,8 +273,8 @@ final class WindowMatcher {
}
// Check for session name match
if let name = sessionName, !name.isEmpty, title.contains(name) {
logger.info("Found tab by session name match: \(name) at index \(index)")
if !sessionName.isEmpty, title.contains(sessionName) {
logger.info("Found tab by session name match: \(sessionName) at index \(index)")
return tab
}

View file

@ -0,0 +1,153 @@
import Foundation
import Observation
import OSLog
/// Service for managing Git worktrees through the VibeTunnel server API
@MainActor
@Observable
final class WorktreeService {
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "WorktreeService")
private let serverManager: ServerManager
private(set) var worktrees: [Worktree] = []
private(set) var branches: [GitBranch] = []
private(set) var stats: WorktreeStats?
private(set) var followMode: FollowModeStatus?
private(set) var isLoading = false
private(set) var isLoadingBranches = false
private(set) var error: Error?
init(serverManager: ServerManager) {
self.serverManager = serverManager
}
/// Fetch the list of worktrees for a Git repository
func fetchWorktrees(for gitRepoPath: String) async {
isLoading = true
error = nil
do {
let worktreeResponse = try await serverManager.performRequest(
endpoint: "/api/worktrees",
method: "GET",
queryItems: [URLQueryItem(name: "gitRepoPath", value: gitRepoPath)],
responseType: WorktreeListResponse.self
)
self.worktrees = worktreeResponse.worktrees
// Stats and followMode are not part of the current API response
// They could be fetched separately if needed
} catch {
self.error = error
logger.error("Failed to fetch worktrees: \(error.localizedDescription)")
}
isLoading = false
}
/// Create a new worktree
func createWorktree(
gitRepoPath: String,
branch: String,
createBranch: Bool,
baseBranch: String? = nil
)
async throws
{
let request = CreateWorktreeRequest(branch: branch, createBranch: createBranch, baseBranch: baseBranch)
try await serverManager.performVoidRequest(
endpoint: "/api/worktrees",
method: "POST",
body: request,
queryItems: [URLQueryItem(name: "gitRepoPath", value: gitRepoPath)]
)
// Refresh the worktree list
await fetchWorktrees(for: gitRepoPath)
}
/// Delete a worktree
func deleteWorktree(gitRepoPath: String, branch: String, force: Bool = false) async throws {
try await serverManager.performVoidRequest(
endpoint: "/api/worktrees/\(branch)",
method: "DELETE",
queryItems: [
URLQueryItem(name: "gitRepoPath", value: gitRepoPath),
URLQueryItem(name: "force", value: String(force))
]
)
// Refresh the worktree list
await fetchWorktrees(for: gitRepoPath)
}
/// Switch to a different branch
func switchBranch(gitRepoPath: String, branch: String, createBranch: Bool = false) async throws {
let request = SwitchBranchRequest(branch: branch, createBranch: createBranch)
try await serverManager.performVoidRequest(
endpoint: "/api/worktrees/switch",
method: "POST",
body: request,
queryItems: [URLQueryItem(name: "gitRepoPath", value: gitRepoPath)]
)
// Refresh the worktree list
await fetchWorktrees(for: gitRepoPath)
}
/// Toggle follow mode
func toggleFollowMode(gitRepoPath: String, enabled: Bool, targetBranch: String? = nil) async throws {
let request = FollowModeRequest(enabled: enabled, targetBranch: targetBranch)
try await serverManager.performVoidRequest(
endpoint: "/api/worktrees/follow",
method: "POST",
body: request,
queryItems: [URLQueryItem(name: "gitRepoPath", value: gitRepoPath)]
)
// Refresh the worktree list
await fetchWorktrees(for: gitRepoPath)
}
/// Fetch the list of branches for a Git repository
func fetchBranches(for gitRepoPath: String) async {
isLoadingBranches = true
do {
self.branches = try await serverManager.performRequest(
endpoint: "/api/repositories/branches",
method: "GET",
queryItems: [URLQueryItem(name: "path", value: gitRepoPath)],
responseType: [GitBranch].self
)
} catch {
self.error = error
logger.error("Failed to fetch branches: \(error.localizedDescription)")
}
isLoadingBranches = false
}
}
// MARK: - Error Types
enum WorktreeError: LocalizedError {
case invalidURL
case invalidResponse
case serverError(String)
case invalidConfiguration
var errorDescription: String? {
switch self {
case .invalidURL:
"Invalid URL"
case .invalidResponse:
"Invalid server response"
case .serverError(let message):
message
case .invalidConfiguration:
"Invalid configuration"
}
}
}
// MARK: - Helper Types

View file

@ -4,14 +4,22 @@ import Foundation
///
/// Provides a centralized location for constructing URLs to access the VibeTunnel
/// web dashboard, with support for direct session linking.
@MainActor
enum DashboardURLBuilder {
/// Builds the base dashboard URL
/// - Parameters:
/// - port: The server port\
/// - port: The server port
/// - sessionId: The session ID to open
/// - Returns: The base dashboard URL
static func dashboardURL(port: String, sessionId: String? = nil) -> URL? {
let sessionIDQueryParameter = sessionId.map { "/?session=\($0)" } ?? ""
return URL(string: "http://127.0.0.1:\(port)\(sessionIDQueryParameter)")
let serverManager = ServerManager.shared
if let sessionId {
return serverManager.buildURL(
endpoint: "/",
queryItems: [URLQueryItem(name: "session", value: sessionId)]
)
} else {
return serverManager.buildURL(endpoint: "/")
}
}
}

View file

@ -76,7 +76,7 @@ enum ConflictAction {
/// and can automatically kill conflicting processes when appropriate.
@MainActor
final class PortConflictResolver {
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "PortConflictResolver")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "PortConflictResolver")
static let shared = PortConflictResolver()

View file

@ -1,11 +1,34 @@
import os.log
import SwiftUI
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "AutocompleteView")
/// View that displays autocomplete suggestions in a dropdown
struct AutocompleteView: View {
let suggestions: [AutocompleteService.PathSuggestion]
let suggestions: [PathSuggestion]
@Binding var selectedIndex: Int
let onSelect: (String) -> Void
var body: some View {
AutocompleteViewWithKeyboard(
suggestions: suggestions,
selectedIndex: $selectedIndex,
keyboardNavigating: false,
onSelect: onSelect
)
}
}
/// View that displays autocomplete suggestions with keyboard navigation support
struct AutocompleteViewWithKeyboard: View {
let suggestions: [PathSuggestion]
@Binding var selectedIndex: Int
let keyboardNavigating: Bool
let onSelect: (String) -> Void
@State private var lastKeyboardState = false
@State private var mouseHoverTriggered = false
var body: some View {
VStack(spacing: 0) {
ScrollViewReader { proxy in
@ -16,9 +39,10 @@ struct AutocompleteView: View {
suggestion: suggestion,
isSelected: index == selectedIndex
) { onSelect(suggestion.suggestion) }
.id(index)
.id(suggestion.id)
.onHover { hovering in
if hovering {
mouseHoverTriggered = true
selectedIndex = index
}
}
@ -32,11 +56,17 @@ struct AutocompleteView: View {
}
.frame(maxHeight: 200)
.onChange(of: selectedIndex) { _, newIndex in
if newIndex >= 0 && newIndex < suggestions.count {
// Only animate scroll when using keyboard navigation, not mouse hover
if newIndex >= 0 && newIndex < suggestions.count && keyboardNavigating && !mouseHoverTriggered {
withAnimation(.easeInOut(duration: 0.1)) {
proxy.scrollTo(newIndex, anchor: .center)
}
}
// Reset the mouse hover flag after processing
mouseHoverTriggered = false
}
.onChange(of: keyboardNavigating) { _, newValue in
lastKeyboardState = newValue
}
}
}
@ -51,7 +81,7 @@ struct AutocompleteView: View {
}
private struct AutocompleteRow: View {
let suggestion: AutocompleteService.PathSuggestion
let suggestion: PathSuggestion
let isSelected: Bool
let onTap: () -> Void
@ -64,21 +94,57 @@ private struct AutocompleteRow: View {
.foregroundColor(iconColor)
.frame(width: 16)
// Name
Text(suggestion.name)
.font(.system(size: 12))
.foregroundColor(.primary)
.lineLimit(1)
// Name and Git info
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(suggestion.name)
.font(.system(size: 12))
.foregroundColor(.primary)
.lineLimit(1)
// Git status badges
if let gitInfo = suggestion.gitInfo {
HStack(spacing: 4) {
// Branch name
if let branch = gitInfo.branch {
Text("[\(branch)]")
.font(.system(size: 10))
.foregroundColor(gitInfo.isWorktree ? .purple : .secondary)
}
// Ahead/behind indicators
if let ahead = gitInfo.aheadCount, ahead > 0 {
HStack(spacing: 2) {
Image(systemName: "arrow.up")
.font(.system(size: 8))
Text("\(ahead)")
.font(.system(size: 10))
}
.foregroundColor(.green)
}
if let behind = gitInfo.behindCount, behind > 0 {
HStack(spacing: 2) {
Image(systemName: "arrow.down")
.font(.system(size: 8))
Text("\(behind)")
.font(.system(size: 10))
}
.foregroundColor(.orange)
}
// Changes indicator
if gitInfo.hasChanges {
Image(systemName: "circle.fill")
.font(.system(size: 6))
.foregroundColor(.yellow)
}
}
}
}
}
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)
@ -124,12 +190,16 @@ private struct AutocompleteRow: View {
struct AutocompleteTextField: View {
@Binding var text: String
let placeholder: String
@StateObject private var autocompleteService = AutocompleteService()
@Environment(GitRepositoryMonitor.self) private var gitMonitor
@Environment(WorktreeService.self) private var worktreeService
@State 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
@State private var keyboardNavigating = false
var body: some View {
VStack(spacing: 4) {
@ -149,47 +219,61 @@ struct AutocompleteTextField: View {
showSuggestions = false
selectedIndex = -1
}
} else if focused && !text.isEmpty && !(autocompleteService?.suggestions.isEmpty ?? true) {
// Show suggestions when field gains focus if we have any
showSuggestions = true
}
}
if showSuggestions && !autocompleteService.suggestions.isEmpty {
AutocompleteView(
suggestions: autocompleteService.suggestions,
selectedIndex: $selectedIndex
if showSuggestions && isFocused && !(autocompleteService?.suggestions.isEmpty ?? true) {
AutocompleteViewWithKeyboard(
suggestions: autocompleteService?.suggestions ?? [],
selectedIndex: $selectedIndex,
keyboardNavigating: keyboardNavigating
) { suggestion in
justSelectedCompletion = true
text = suggestion
showSuggestions = false
selectedIndex = -1
autocompleteService.clearSuggestions()
autocompleteService?.clearSuggestions()
}
.transition(.opacity.combined(with: .scale(scale: 0.95)))
.transition(.asymmetric(
insertion: .opacity.combined(with: .scale(scale: 0.95)).combined(with: .offset(y: -5)),
removal: .opacity.combined(with: .scale(scale: 0.95))
))
}
}
.animation(.easeInOut(duration: 0.2), value: showSuggestions)
.animation(.easeInOut(duration: 0.15), value: showSuggestions)
.onAppear {
// Initialize autocompleteService with GitRepositoryMonitor
autocompleteService = AutocompleteService(gitMonitor: gitMonitor)
}
}
private func handleKeyPress(_ keyPress: KeyPress) -> KeyPress.Result {
guard showSuggestions && !autocompleteService.suggestions.isEmpty else {
guard isFocused && showSuggestions && !(autocompleteService?.suggestions.isEmpty ?? true) else {
return .ignored
}
switch keyPress.key {
case .downArrow:
selectedIndex = min(selectedIndex + 1, autocompleteService.suggestions.count - 1)
keyboardNavigating = true
selectedIndex = min(selectedIndex + 1, (autocompleteService?.suggestions.count ?? 0) - 1)
return .handled
case .upArrow:
keyboardNavigating = true
selectedIndex = max(selectedIndex - 1, -1)
return .handled
case .tab, .return:
if selectedIndex >= 0 && selectedIndex < autocompleteService.suggestions.count {
if selectedIndex >= 0 && selectedIndex < (autocompleteService?.suggestions.count ?? 0) {
justSelectedCompletion = true
text = autocompleteService.suggestions[selectedIndex].suggestion
text = autocompleteService?.suggestions[selectedIndex].suggestion ?? ""
showSuggestions = false
selectedIndex = -1
autocompleteService.clearSuggestions()
autocompleteService?.clearSuggestions()
keyboardNavigating = false
return .handled
}
return .ignored
@ -198,6 +282,7 @@ struct AutocompleteTextField: View {
if showSuggestions {
showSuggestions = false
selectedIndex = -1
keyboardNavigating = false
return .handled
}
return .ignored
@ -217,34 +302,52 @@ struct AutocompleteTextField: View {
// Cancel previous debounce
debounceTask?.cancel()
// Reset selection when text changes
// Reset selection and keyboard navigation flag when text changes
selectedIndex = -1
keyboardNavigating = false
guard !newValue.isEmpty else {
// Hide suggestions when text is empty
showSuggestions = false
autocompleteService.clearSuggestions()
autocompleteService?.clearSuggestions()
return
}
// Show suggestions immediately if we already have them and field is focused, they'll update when new ones
// arrive
if isFocused && !(autocompleteService?.suggestions.isEmpty ?? true) {
showSuggestions = true
}
// Debounce the autocomplete request
debounceTask = Task {
try? await Task.sleep(nanoseconds: 300_000_000) // 300ms
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms - reduced for better responsiveness
if !Task.isCancelled {
await autocompleteService.fetchSuggestions(for: newValue)
await autocompleteService?.fetchSuggestions(for: newValue)
await MainActor.run {
if !autocompleteService.suggestions.isEmpty {
// Update suggestion visibility based on results - only show if focused
if isFocused && !(autocompleteService?.suggestions.isEmpty ?? true) {
showSuggestions = true
// Auto-select first item if it's a good match
if let first = autocompleteService.suggestions.first,
logger.debug("Updated with \(autocompleteService?.suggestions.count ?? 0) suggestions")
// Try to maintain selection if possible
if selectedIndex >= (autocompleteService?.suggestions.count ?? 0) {
selectedIndex = -1
}
// Auto-select first item if it's a good match and nothing is selected
if selectedIndex == -1,
let first = autocompleteService?.suggestions.first,
first.name.lowercased().hasPrefix(
newValue.split(separator: "/").last?.lowercased() ?? ""
)
{
selectedIndex = 0
}
} else {
} else if showSuggestions {
// Only hide if we're already showing and have no results
showSuggestions = false
}
}

View file

@ -187,6 +187,9 @@ final class CustomMenuWindow: NSPanel {
orderFront(nil)
makeKey()
// Ensure window can receive keyboard events for navigation
becomeKey()
// Button state is managed by StatusBarMenuManager
// Set first responder after window is visible
@ -359,7 +362,7 @@ final class CustomMenuWindow: NSPanel {
override func makeKey() {
super.makeKey()
// Set the window itself as first responder to prevent auto-focus
// Set first responder after window is visible
makeFirstResponder(self)
}

View file

@ -0,0 +1,345 @@
import Combine
import os.log
import SwiftUI
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "GitBranchWorktreeSelector")
/// A SwiftUI component for Git branch and worktree selection, mirroring the web UI functionality
struct GitBranchWorktreeSelector: View {
// MARK: - Properties
let repoPath: String
let gitMonitor: GitRepositoryMonitor
let worktreeService: WorktreeService
let onBranchChanged: (String) -> Void
let onWorktreeChanged: (String?) -> Void
let onCreateWorktree: (String, String) async throws -> Void
@State private var selectedBranch: String = ""
@State private var selectedWorktree: String?
@State private var availableBranches: [String] = []
@State private var availableWorktrees: [Worktree] = []
@State private var isLoadingBranches = false
@State private var isLoadingWorktrees = false
@State private var showCreateWorktree = false
@State private var newBranchName = ""
@State private var isCreatingWorktree = false
@State private var hasUncommittedChanges = false
@State private var followMode = false
@State private var followBranch: String?
@State private var errorMessage: String?
@FocusState private var isNewBranchFieldFocused: Bool
@Environment(\.colorScheme) private var colorScheme
// MARK: - Body
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// Base Branch Selection
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(selectedWorktree != nil ? "Base Branch for Worktree:" : "Switch to Branch:")
.font(.system(size: 11))
.foregroundColor(.secondary)
if hasUncommittedChanges && selectedWorktree == nil {
HStack(spacing: 2) {
Image(systemName: "circle.fill")
.font(.system(size: 6))
.foregroundColor(AppColors.Fallback.gitChanges(for: colorScheme))
Text("Uncommitted changes")
.font(.system(size: 9))
.foregroundColor(AppColors.Fallback.gitChanges(for: colorScheme))
}
}
}
Menu {
ForEach(availableBranches, id: \.self) { branch in
Button(action: {
selectedBranch = branch
onBranchChanged(branch)
}, label: {
HStack {
Text(branch)
if branch == getCurrentBranch() {
Text("(current)")
.foregroundColor(.secondary)
}
}
})
}
} label: {
HStack {
Text(selectedBranch.isEmpty ? "Select branch" : selectedBranch)
.font(.system(size: 12))
.lineLimit(1)
Spacer()
Image(systemName: "chevron.down")
.font(.system(size: 10))
}
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(Color.secondary.opacity(0.1))
.cornerRadius(4)
}
.buttonStyle(.plain)
.disabled(isLoadingBranches || (hasUncommittedChanges && selectedWorktree == nil))
.opacity((hasUncommittedChanges && selectedWorktree == nil) ? 0.5 : 1.0)
// Status text
if !isLoadingBranches {
statusText
}
}
// Worktree Selection
VStack(alignment: .leading, spacing: 4) {
Text("Worktree:")
.font(.system(size: 11))
.foregroundColor(.secondary)
if !showCreateWorktree {
Menu {
Button(action: {
selectedWorktree = nil
onWorktreeChanged(nil)
}, label: {
Text(worktreeNoneText)
})
Divider()
ForEach(availableWorktrees, id: \.id) { worktree in
Button(action: {
selectedWorktree = worktree.branch
onWorktreeChanged(worktree.branch)
}, label: {
HStack {
Text(formatWorktreeName(worktree))
if followMode && followBranch == worktree.branch {
Text("⚡️")
}
}
})
}
} label: {
HStack {
Text(selectedWorktreeText)
.font(.system(size: 12))
.lineLimit(1)
Spacer()
Image(systemName: "chevron.down")
.font(.system(size: 10))
}
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(Color.secondary.opacity(0.1))
.cornerRadius(4)
}
.buttonStyle(.plain)
.disabled(isLoadingWorktrees)
Button(action: {
showCreateWorktree = true
newBranchName = ""
isNewBranchFieldFocused = true
}, label: {
HStack(spacing: 4) {
Image(systemName: "plus")
.font(.system(size: 10))
Text("Create new worktree")
.font(.system(size: 11))
}
.foregroundColor(.accentColor)
})
.buttonStyle(.plain)
.padding(.top, 4)
} else {
// Create Worktree Mode
VStack(spacing: 8) {
TextField("New branch name", text: $newBranchName)
.textFieldStyle(.roundedBorder)
.font(.system(size: 12))
.focused($isNewBranchFieldFocused)
.disabled(isCreatingWorktree)
.onSubmit {
if !newBranchName.isEmpty {
createWorktree()
}
}
HStack(spacing: 8) {
Button("Cancel") {
showCreateWorktree = false
newBranchName = ""
errorMessage = nil
}
.font(.system(size: 11))
.buttonStyle(.plain)
.disabled(isCreatingWorktree)
Button(isCreatingWorktree ? "Creating..." : "Create") {
createWorktree()
}
.font(.system(size: 11))
.buttonStyle(.borderedProminent)
.disabled(newBranchName.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty || isCreatingWorktree
)
}
if let error = errorMessage {
Text(error)
.font(.system(size: 9))
.foregroundColor(.red)
}
}
}
}
}
.task {
await loadGitData()
}
}
// MARK: - Subviews
@ViewBuilder
private var statusText: some View {
VStack(alignment: .leading, spacing: 2) {
if hasUncommittedChanges && selectedWorktree == nil {
Text("Branch switching is disabled due to uncommitted changes. Commit or stash changes first.")
.font(.system(size: 9))
.foregroundColor(AppColors.Fallback.gitChanges(for: colorScheme))
} else if let worktree = selectedWorktree {
Text("Session will use worktree: \(worktree)")
.font(.system(size: 9))
.foregroundColor(.secondary)
} else if !selectedBranch.isEmpty && selectedBranch != getCurrentBranch() {
Text("Session will start on \(selectedBranch)")
.font(.system(size: 9))
.foregroundColor(.secondary)
}
if followMode, let branch = followBranch {
Text("Follow mode active: following \(branch)")
.font(.system(size: 9))
.foregroundColor(.accentColor)
}
}
}
private var worktreeNoneText: String {
if selectedWorktree != nil {
"No worktree (use main repository)"
} else if availableWorktrees.contains(where: { $0.isCurrentWorktree == true && $0.isMainWorktree != true }) {
"Switch to main repository"
} else {
"No worktree (use main repository)"
}
}
private var selectedWorktreeText: String {
if let worktree = selectedWorktree,
let info = availableWorktrees.first(where: { $0.branch == worktree })
{
return formatWorktreeName(info)
}
return worktreeNoneText
}
// MARK: - Methods
private func formatWorktreeName(_ worktree: Worktree) -> String {
let folderName = URL(fileURLWithPath: worktree.path).lastPathComponent
let showBranch = folderName.lowercased() != worktree.branch.lowercased() &&
!folderName.lowercased().hasSuffix("-\(worktree.branch.lowercased())")
var result = ""
if worktree.branch == selectedWorktree {
result += "Use selected worktree: "
}
result += folderName
if showBranch {
result += " [\(worktree.branch)]"
}
if worktree.isMainWorktree == true {
result += " (main)"
}
if worktree.isCurrentWorktree == true {
result += " (current)"
}
if followMode && followBranch == worktree.branch {
result += " ⚡️ following"
}
return result
}
private func getCurrentBranch() -> String {
// Get the actual current branch from GitRepositoryMonitor
gitMonitor.getCachedRepository(for: repoPath)?.currentBranch ?? selectedBranch
}
private func loadGitData() async {
isLoadingBranches = true
isLoadingWorktrees = true
// Load branches
let branches = await gitMonitor.getBranches(for: repoPath)
availableBranches = branches
if selectedBranch.isEmpty, let firstBranch = branches.first {
selectedBranch = firstBranch
}
isLoadingBranches = false
// Load worktrees
await worktreeService.fetchWorktrees(for: repoPath)
availableWorktrees = worktreeService.worktrees
// Check follow mode status from the service
if let followModeStatus = worktreeService.followMode {
followMode = followModeStatus.enabled
followBranch = followModeStatus.targetBranch
} else {
followMode = false
followBranch = nil
}
if let error = worktreeService.error {
logger.error("Failed to load worktrees: \(error)")
errorMessage = "Failed to load worktrees"
}
isLoadingWorktrees = false
// Check for uncommitted changes
if let repo = await gitMonitor.findRepository(for: repoPath) {
hasUncommittedChanges = repo.hasChanges
}
}
private func createWorktree() {
let trimmedName = newBranchName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty else { return }
isCreatingWorktree = true
errorMessage = nil
Task {
do {
try await onCreateWorktree(trimmedName, selectedBranch.isEmpty ? "main" : selectedBranch)
isCreatingWorktree = false
showCreateWorktree = false
newBranchName = ""
// Reload to show new worktree
await loadGitData()
} catch {
isCreatingWorktree = false
errorMessage = "Failed to create worktree: \(error.localizedDescription)"
}
}
}
}

View file

@ -15,17 +15,11 @@ struct GitRepositoryRow: View {
}
private var branchInfo: some View {
HStack(spacing: 1) {
Image(systemName: "arrow.branch")
.font(.system(size: 9))
.foregroundColor(AppColors.Fallback.gitBranch(for: colorScheme))
Text(repository.currentBranch ?? "detached")
.font(.system(size: 10))
.foregroundColor(AppColors.Fallback.gitBranch(for: colorScheme))
.lineLimit(1)
.truncationMode(.middle)
}
Text("[\(repository.currentBranch ?? "detached")]\(repository.isWorktree ? "+" : "")")
.font(.system(size: 10))
.foregroundColor(AppColors.Fallback.gitBranch(for: colorScheme))
.lineLimit(1)
.truncationMode(.middle)
}
private var changeIndicators: some View {
@ -108,19 +102,15 @@ struct GitRepositoryRow: View {
}
var body: some View {
HStack(spacing: 2) {
// Branch info
HStack(spacing: 4) {
// Branch info - highest priority
branchInfo
.layoutPriority(2)
if repository.hasChanges {
Text("")
.font(.system(size: 8))
.foregroundColor(.secondary.opacity(0.5))
changeIndicators
.layoutPriority(1)
}
Spacer()
}
.padding(.horizontal, 4)
.padding(.vertical, 2)

View file

@ -1,12 +1,20 @@
import SwiftUI
/// Focus field enum that matches the one in VibeTunnelMenuView
enum MenuFocusField: Hashable {
case sessionRow(String)
case settingsButton
case newSessionButton
case quitButton
}
/// Bottom action bar for the menu with New Session, Settings, and Quit buttons.
///
/// Provides quick access to common actions with keyboard navigation support
/// and visual feedback for hover and focus states.
struct MenuActionBar: View {
@Binding var showingNewSession: Bool
@Binding var focusedField: VibeTunnelMenuView.FocusField?
@Binding var focusedField: MenuFocusField?
let hasStartedKeyboardNavigation: Bool
@Environment(\.openWindow)
@ -26,13 +34,13 @@ struct MenuActionBar: View {
Label("New Session", systemImage: "plus.circle")
.font(.system(size: 12))
.padding(.horizontal, 10)
.padding(.vertical, 3)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isHoveringNewSession ? AppColors.Fallback.controlBackground(for: colorScheme)
.opacity(colorScheme == .light ? 0.35 : 0.4) : Color.clear
.opacity(colorScheme == .light ? 0.6 : 0.7) : Color.clear
)
.scaleEffect(isHoveringNewSession ? 1.05 : 1.0)
.scaleEffect(isHoveringNewSession ? 1.08 : 1.0)
.animation(.easeInOut(duration: 0.15), value: isHoveringNewSession)
)
})
@ -58,13 +66,13 @@ struct MenuActionBar: View {
Label("Settings", systemImage: "gearshape")
.font(.system(size: 12))
.padding(.horizontal, 10)
.padding(.vertical, 3)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isHoveringSettings ? AppColors.Fallback.controlBackground(for: colorScheme)
.opacity(colorScheme == .light ? 0.35 : 0.4) : Color.clear
.opacity(colorScheme == .light ? 0.6 : 0.7) : Color.clear
)
.scaleEffect(isHoveringSettings ? 1.05 : 1.0)
.scaleEffect(isHoveringSettings ? 1.08 : 1.0)
.animation(.easeInOut(duration: 0.15), value: isHoveringSettings)
)
})
@ -92,13 +100,13 @@ struct MenuActionBar: View {
Label("Quit", systemImage: "power")
.font(.system(size: 12))
.padding(.horizontal, 10)
.padding(.vertical, 3)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isHoveringQuit ? AppColors.Fallback.controlBackground(for: colorScheme)
.opacity(colorScheme == .light ? 0.35 : 0.4) : Color.clear
.opacity(colorScheme == .light ? 0.6 : 0.7) : Color.clear
)
.scaleEffect(isHoveringQuit ? 1.05 : 1.0)
.scaleEffect(isHoveringQuit ? 1.08 : 1.0)
.animation(.easeInOut(duration: 0.15), value: isHoveringQuit)
)
})
@ -119,6 +127,6 @@ struct MenuActionBar: View {
)
}
.padding(.horizontal)
.padding(.vertical, 8)
.padding(.vertical, 12)
}
}

View file

@ -34,10 +34,10 @@ struct SessionListSection: View {
let activeSessions: [(key: String, value: ServerSessionInfo)]
let idleSessions: [(key: String, value: ServerSessionInfo)]
let hoveredSessionId: String?
let focusedField: VibeTunnelMenuView.FocusField?
let focusedField: MenuFocusField?
let hasStartedKeyboardNavigation: Bool
let onHover: (String?) -> Void
let onFocus: (VibeTunnelMenuView.FocusField?) -> Void
let onFocus: (MenuFocusField?) -> Void
var body: some View {
VStack(spacing: 1) {

View file

@ -31,7 +31,7 @@ struct SessionRow: View {
@State private var isHoveringFolder = false
@FocusState private var isEditFieldFocused: Bool
private static let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "SessionRow")
private static let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "SessionRow")
/// Computed property that reads directly from the monitor's cache
/// This will automatically update when the monitor refreshes
@ -90,12 +90,12 @@ struct SessionRow: View {
.truncationMode(.tail)
// Show session name if available
if let name = session.value.name, !name.isEmpty {
if !session.value.name.isEmpty {
Text("")
.font(.system(size: 12))
.foregroundColor(.secondary.opacity(0.6))
Text(name)
Text(session.value.name)
.font(.system(size: 12))
.foregroundColor(.secondary)
.lineLimit(1)
@ -143,29 +143,28 @@ struct SessionRow: View {
HStack(alignment: .center, spacing: 6) {
// Left side: Path and git info
HStack(alignment: .center, spacing: 4) {
// Folder icon and path - clickable as one unit
// Folder icon - clickable
Button(action: {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: session.value.workingDir)
}, label: {
HStack(spacing: 4) {
Image(systemName: "folder")
.font(.system(size: 10))
.foregroundColor(.secondary)
Text(compactPath)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.head)
}
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(isHoveringFolder ? AppColors.Fallback.controlBackground(for: colorScheme)
.opacity(0.15) : Color.clear
)
)
Image(systemName: "folder")
.font(.system(size: 10))
.foregroundColor(isHoveringFolder ? .primary : .secondary)
.padding(4)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(isHoveringFolder ? AppColors.Fallback.controlBackground(for: colorScheme)
.opacity(0.3) : Color.clear
)
)
.overlay(
RoundedRectangle(cornerRadius: 4)
.strokeBorder(
isHoveringFolder ? AppColors.Fallback.gitBorder(for: colorScheme)
.opacity(0.4) : Color.clear,
lineWidth: 0.5
)
)
})
.buttonStyle(.plain)
.onHover { hovering in
@ -173,8 +172,17 @@ struct SessionRow: View {
}
.help("Open in Finder")
// Path text - not clickable
Text(compactPath)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.head)
.layoutPriority(-1) // Lowest priority
if let repo = gitRepository {
GitRepositoryRow(repository: repo)
.layoutPriority(1) // Highest priority
}
}
.frame(maxWidth: .infinity, alignment: .leading)
@ -402,15 +410,15 @@ struct SessionRow: View {
private var sessionName: String {
// Use the session name if available, otherwise fall back to directory name
if let name = session.value.name, !name.isEmpty {
return name
if !session.value.name.isEmpty {
return session.value.name
}
let workingDir = session.value.workingDir
return (workingDir as NSString).lastPathComponent
}
private func startEditing() {
editedName = session.value.name ?? ""
editedName = session.value.name
isEditing = true
isEditFieldFocused = true
}
@ -499,8 +507,8 @@ struct SessionRow: View {
var tooltip = ""
// Session name
if let name = session.value.name, !name.isEmpty {
tooltip += "Session: \(name)\n"
if !session.value.name.isEmpty {
tooltip += "Session: \(session.value.name)\n"
}
// Command

View file

@ -1,5 +1,8 @@
import os.log
import SwiftUI
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "NewSessionForm")
/// Compact new session form designed for the popover.
///
/// Provides a streamlined interface for creating new terminal sessions with
@ -15,7 +18,10 @@ struct NewSessionForm: View {
private var sessionService
@Environment(RepositoryDiscoveryService.self)
private var repositoryDiscovery
@StateObject private var configManager = ConfigManager.shared
@Environment(GitRepositoryMonitor.self)
private var gitMonitor
@Environment(ConfigManager.self)
private var configManager
// Form fields
@State private var command = "zsh"
@ -24,6 +30,19 @@ struct NewSessionForm: View {
@State private var spawnWindow = true
@State private var titleMode: TitleMode = .dynamic
// Git worktree state
@State private var isGitRepository = false
@State private var gitRepoPath: String?
@State private var selectedWorktreePath: String?
@State private var selectedWorktreeBranch: String?
@State private var checkingGitStatus = false
@State private var worktreeService: WorktreeService?
// Branch state (matching web version)
@State private var currentBranch = ""
@State private var selectedBaseBranch = ""
@State private var branchSwitchWarning: String?
// UI state
@State private var isCreating = false
@State private var showError = false
@ -83,6 +102,31 @@ struct NewSessionForm: View {
// Form content
ScrollView {
VStack(alignment: .leading, spacing: 18) {
// Branch Switch Warning
if let warning = branchSwitchWarning {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 14))
.foregroundColor(.yellow)
Text(warning)
.font(.system(size: 11))
.foregroundColor(.primary)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color.yellow.opacity(0.1))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.yellow.opacity(0.3), lineWidth: 1)
)
}
// Name field (first)
VStack(alignment: .leading, spacing: 6) {
Text("Name")
@ -123,6 +167,9 @@ struct NewSessionForm: View {
HStack(spacing: 8) {
AutocompleteTextField(text: $workingDirectory, placeholder: "~/")
.focused($focusedField, equals: .directory)
.onChange(of: workingDirectory) { _, newValue in
checkForGitRepository(at: newValue)
}
Button(action: selectDirectory) {
Image(systemName: "folder")
@ -137,6 +184,60 @@ struct NewSessionForm: View {
}
}
// Git branch and worktree selection when Git repository is detected
if isGitRepository, let repoPath = gitRepoPath, let service = worktreeService {
GitBranchWorktreeSelector(
repoPath: repoPath,
gitMonitor: gitMonitor,
worktreeService: service,
onBranchChanged: { branch in
selectedBaseBranch = branch
branchSwitchWarning = nil
},
onWorktreeChanged: { worktree in
if let worktree {
// Find the worktree info to get the path
if let worktreeInfo = service.worktrees.first(where: { $0.branch == worktree }) {
selectedWorktreePath = worktreeInfo.path
selectedWorktreeBranch = worktreeInfo.branch
workingDirectory = worktreeInfo.path
}
} else {
selectedWorktreePath = nil
selectedWorktreeBranch = nil
// Don't change workingDirectory here - keep the original git repo path
}
},
onCreateWorktree: { branchName, baseBranch in
// Create the worktree
try await service.createWorktree(
gitRepoPath: repoPath,
branch: branchName,
createBranch: true,
baseBranch: baseBranch
)
// After creation, select the new worktree
await service.fetchWorktrees(for: repoPath)
if let newWorktree = service.worktrees.first(where: { $0.branch == branchName }) {
selectedWorktreePath = newWorktree.path
selectedWorktreeBranch = newWorktree.branch
workingDirectory = newWorktree.path
}
}
)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color(NSColor.controlBackgroundColor).opacity(0.05))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.accentColor.opacity(0.2), lineWidth: 1)
)
}
// Quick Start
VStack(alignment: .leading, spacing: 10) {
Text("Quick Start")
@ -299,6 +400,8 @@ struct NewSessionForm: View {
.onAppear {
loadPreferences()
focusedField = .name
// Check if the default/loaded directory is a Git repository
checkForGitRepository(at: workingDirectory)
}
.task {
await repositoryDiscovery.discoverRepositories(in: configManager.repositoryBasePath)
@ -353,11 +456,52 @@ struct NewSessionForm: View {
Task {
do {
var finalWorkingDir: String
var effectiveBranch = ""
// Clear any previous warning
await MainActor.run {
branchSwitchWarning = nil
}
// If using a specific worktree
if let selectedWorktreePath, let selectedBranch = selectedWorktreeBranch {
// Using a specific worktree
finalWorkingDir = selectedWorktreePath
effectiveBranch = selectedBranch
} else if isGitRepository && !selectedBaseBranch.isEmpty && selectedBaseBranch != currentBranch {
// Not using worktree but selected a different branch - attempt to switch
finalWorkingDir = workingDirectory
if let service = worktreeService, let repoPath = gitRepoPath {
do {
try await service.switchBranch(gitRepoPath: repoPath, branch: selectedBaseBranch)
effectiveBranch = selectedBaseBranch
} catch {
// Branch switch failed - show warning but continue with current branch
effectiveBranch = currentBranch
let errorMessage = error.localizedDescription
let isUncommittedChanges = errorMessage.lowercased().contains("uncommitted changes")
await MainActor.run {
branchSwitchWarning = isUncommittedChanges
? "Cannot switch to \(selectedBaseBranch) due to uncommitted changes. Creating session on \(currentBranch)."
: "Failed to switch to \(selectedBaseBranch): \(errorMessage). Creating session on \(currentBranch)."
}
}
}
} else {
// Use current branch
finalWorkingDir = workingDirectory
effectiveBranch = selectedBaseBranch.isEmpty ? currentBranch : selectedBaseBranch
}
// Parse command into array
let commandArray = parseCommand(command.trimmingCharacters(in: .whitespacesAndNewlines))
// Expand tilde in working directory
let expandedWorkingDir = NSString(string: workingDirectory).expandingTildeInPath
let expandedWorkingDir = NSString(string: finalWorkingDir).expandingTildeInPath
// Create session using SessionService
let sessionId = try await sessionService.createSession(
@ -365,7 +509,9 @@ struct NewSessionForm: View {
workingDir: expandedWorkingDir,
name: sessionName.isEmpty ? nil : sessionName.trimmingCharacters(in: .whitespacesAndNewlines),
titleMode: titleMode.rawValue,
spawnTerminal: spawnWindow
spawnTerminal: spawnWindow,
gitRepoPath: gitRepoPath,
gitBranch: effectiveBranch.isEmpty ? nil : effectiveBranch
)
// If not spawning window, open in browser
@ -457,6 +603,75 @@ struct NewSessionForm: View {
UserDefaults.standard.set(spawnWindow, forKey: AppConstants.UserDefaultsKeys.newSessionSpawnWindow)
UserDefaults.standard.set(titleMode.rawValue, forKey: AppConstants.UserDefaultsKeys.newSessionTitleMode)
}
private func checkForGitRepository(at path: String) {
guard !checkingGitStatus else { return }
logger.info("🔍 Checking for Git repository at: \(path)")
checkingGitStatus = true
Task {
let expandedPath = NSString(string: path).expandingTildeInPath
logger.debug("🔍 Expanded path: \(expandedPath)")
if let repo = await gitMonitor.findRepository(for: expandedPath) {
logger.info("✅ Found Git repository: \(repo.path)")
await MainActor.run {
self.isGitRepository = true
self.gitRepoPath = repo.path
self.worktreeService = WorktreeService(serverManager: serverManager)
self.checkingGitStatus = false
}
// Fetch branches and worktrees in parallel
if let service = self.worktreeService {
await withTaskGroup(of: Void.self) { group in
group.addTask {
await service.fetchBranches(for: repo.path)
}
group.addTask {
await service.fetchWorktrees(for: repo.path)
}
}
// Update UI state with fetched data
await MainActor.run {
// Set available branches
// Branches are now loaded by GitBranchWorktreeSelector
// Find and set current branch
if let currentBranchData = service.branches.first(where: { $0.current }) {
self.currentBranch = currentBranchData.name
if self.selectedBaseBranch.isEmpty {
self.selectedBaseBranch = currentBranchData.name
}
}
// Pre-select current worktree if we're in one (not the main worktree)
if let currentWorktree = service.worktrees.first(where: {
$0.path == expandedPath && !($0.isMainWorktree ?? false)
}) {
self.selectedWorktreePath = currentWorktree.path
self.selectedWorktreeBranch = currentWorktree.branch
}
}
}
} else {
logger.info("❌ No Git repository found")
await MainActor.run {
self.isGitRepository = false
self.gitRepoPath = nil
self.selectedWorktreePath = nil
self.selectedWorktreeBranch = nil
self.worktreeService = nil
self.currentBranch = ""
self.selectedBaseBranch = ""
self.branchSwitchWarning = nil
self.checkingGitStatus = false
}
}
}
}
}
// MARK: - Repository Dropdown List

View file

@ -1,5 +1,4 @@
import AppKit
import Combine
import Observation
import SwiftUI
@ -25,10 +24,11 @@ final class StatusBarController: NSObject {
private let terminalLauncher: TerminalLauncher
private let gitRepositoryMonitor: GitRepositoryMonitor
private let repositoryDiscovery: RepositoryDiscoveryService
private let configManager: ConfigManager
private let worktreeService: WorktreeService
// MARK: - State Tracking
private var cancellables = Set<AnyCancellable>()
private var updateTimer: Timer?
private var hasNetworkAccess = true
@ -41,7 +41,9 @@ final class StatusBarController: NSObject {
tailscaleService: TailscaleService,
terminalLauncher: TerminalLauncher,
gitRepositoryMonitor: GitRepositoryMonitor,
repositoryDiscovery: RepositoryDiscoveryService
repositoryDiscovery: RepositoryDiscoveryService,
configManager: ConfigManager,
worktreeService: WorktreeService
) {
self.sessionMonitor = sessionMonitor
self.serverManager = serverManager
@ -50,6 +52,8 @@ final class StatusBarController: NSObject {
self.terminalLauncher = terminalLauncher
self.gitRepositoryMonitor = gitRepositoryMonitor
self.repositoryDiscovery = repositoryDiscovery
self.configManager = configManager
self.worktreeService = worktreeService
self.menuManager = StatusBarMenuManager()
@ -83,19 +87,28 @@ final class StatusBarController: NSObject {
// Initialize the icon controller
iconController = StatusBarIconController(button: button)
// Perform initial update immediately for instant feedback
updateStatusItemDisplay()
// Schedule another update after a short delay to catch server startup
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(100))
updateStatusItemDisplay()
}
}
}
private func setupMenuManager() {
let configuration = StatusBarMenuManager.Configuration(
let configuration = StatusBarMenuConfiguration(
sessionMonitor: sessionMonitor,
serverManager: serverManager,
ngrokService: ngrokService,
tailscaleService: tailscaleService,
terminalLauncher: terminalLauncher,
gitRepositoryMonitor: gitRepositoryMonitor,
repositoryDiscovery: repositoryDiscovery
repositoryDiscovery: repositoryDiscovery,
configManager: configManager,
worktreeService: worktreeService
)
menuManager.setup(with: configuration)
}
@ -105,13 +118,16 @@ final class StatusBarController: NSObject {
observeServerState()
// Create a timer to periodically update the display
// since SessionMonitor doesn't have a publisher
// This serves dual purpose: updating session counts and ensuring server state is reflected
updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor in
_ = await self?.sessionMonitor.getSessions()
self?.updateStatusItemDisplay()
}
}
// Fire timer immediately to catch any early state changes
updateTimer?.fire()
}
private func observeServerState() {

View file

@ -1,5 +1,8 @@
import AppKit
import Foundation
import os.log
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "StatusBarIconController")
/// Manages the visual appearance of the status bar item's button.
///
@ -47,18 +50,22 @@ final class StatusBarIconController {
/// - Parameter isServerRunning: A boolean indicating if the server is running.
private func updateIcon(isServerRunning: Bool) {
guard let button else { return }
let iconName = isServerRunning ? "menubar" : "menubar.inactive"
if let image = NSImage(named: iconName) {
image.isTemplate = true
button.image = image
} else {
// Fallback to regular icon with alpha adjustment
if let image = NSImage(named: "menubar") {
image.isTemplate = true
button.image = image
button.alphaValue = isServerRunning ? 1.0 : 0.5
}
// Always use the same icon - it's already set as a template in the asset catalog
guard let image = NSImage(named: "menubar") else {
logger.warning("menubar icon not found")
return
}
// The image is already configured as a template in Contents.json,
// but we set it explicitly to be safe
image.isTemplate = true
button.image = image
// Use opacity to indicate server state:
// - 1.0 (fully opaque) when server is running
// - 0.5 (semi-transparent) when server is stopped
button.alphaValue = isServerRunning ? 1.0 : 0.5
}
/// Formats the session count indicator with a minimalist style.

View file

@ -1,24 +1,25 @@
import AppKit
import Combine
import Observation
import SwiftUI
/// gross hack: https://stackoverflow.com/questions/26004684/nsstatusbarbutton-keep-highlighted?rq=4
/// Didn't manage to keep the highlighted state reliable active with any other way.
extension NSStatusBarButton {
override public func mouseDown(with event: NSEvent) {
super.mouseDown(with: event)
self.highlight(true)
// Keep the button highlighted while the menu is visible
// The highlight state is maintained based on whether any menu is visible
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) {
// Check if we should keep the highlight based on menu visibility
// Since we can't access the menu manager directly, we check our own state
if self.state == .on {
self.highlight(true)
#if !SWIFT_PACKAGE
/// gross hack: https://stackoverflow.com/questions/26004684/nsstatusbarbutton-keep-highlighted?rq=4
/// Didn't manage to keep the highlighted state reliable active with any other way.
/// DO NOT CHANGE THIS! Yes, accessing AppDelegate is ugly, but it's the ONLY reliable way
/// to maintain button highlight state. All other approaches have been tried and failed.
extension NSStatusBarButton {
override public func mouseDown(with event: NSEvent) {
super.mouseDown(with: event)
self.highlight(true)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
self
.highlight(AppDelegate.shared?.statusBarController?.menuManager.customWindow?
.isWindowVisible ?? false
)
}
}
}
}
#endif
/// Manages status bar menu behavior, providing left-click custom view and right-click context menu functionality.
///
@ -26,6 +27,7 @@ extension NSStatusBarButton {
/// handling mouse events and window state transitions. Provides special handling for
/// maintaining button highlight state during custom window display.
@MainActor
@Observable
final class StatusBarMenuManager: NSObject {
// MARK: - Menu State Management
@ -44,6 +46,8 @@ final class StatusBarMenuManager: NSObject {
private var terminalLauncher: TerminalLauncher?
private var gitRepositoryMonitor: GitRepositoryMonitor?
private var repositoryDiscovery: RepositoryDiscoveryService?
private var configManager: ConfigManager?
private var worktreeService: WorktreeService?
// Custom window management
fileprivate var customWindow: CustomMenuWindow?
@ -53,38 +57,23 @@ final class StatusBarMenuManager: NSObject {
/// State management
private var menuState: MenuState = .none
// Track new session state
@Published private var isNewSessionActive = false
private var cancellables = Set<AnyCancellable>()
/// Track new session state
private var isNewSessionActive = false {
didSet {
// Update window when state changes
customWindow?.isNewSessionActive = isNewSessionActive
}
}
// MARK: - Initialization
override init() {
super.init()
// Subscribe to new session state changes to update window
$isNewSessionActive
.sink { [weak self] isActive in
self?.customWindow?.isNewSessionActive = isActive
}
.store(in: &cancellables)
}
// MARK: - Configuration
struct Configuration {
let sessionMonitor: SessionMonitor
let serverManager: ServerManager
let ngrokService: NgrokService
let tailscaleService: TailscaleService
let terminalLauncher: TerminalLauncher
let gitRepositoryMonitor: GitRepositoryMonitor
let repositoryDiscovery: RepositoryDiscoveryService
}
// MARK: - Setup
func setup(with configuration: Configuration) {
func setup(with configuration: StatusBarMenuConfiguration) {
self.sessionMonitor = configuration.sessionMonitor
self.serverManager = configuration.serverManager
self.ngrokService = configuration.ngrokService
@ -92,6 +81,8 @@ final class StatusBarMenuManager: NSObject {
self.terminalLauncher = configuration.terminalLauncher
self.gitRepositoryMonitor = configuration.gitRepositoryMonitor
self.repositoryDiscovery = configuration.repositoryDiscovery
self.configManager = configuration.configManager
self.worktreeService = configuration.worktreeService
}
// MARK: - State Management
@ -126,7 +117,11 @@ final class StatusBarMenuManager: NSObject {
let serverManager,
let ngrokService,
let tailscaleService,
let terminalLauncher else { return }
let terminalLauncher,
let gitRepositoryMonitor,
let repositoryDiscovery,
let configManager,
let worktreeService else { return }
// Update menu state to custom window FIRST before any async operations
updateMenuState(.customWindow, button: button)
@ -135,18 +130,21 @@ final class StatusBarMenuManager: NSObject {
let sessionService = SessionService(serverManager: serverManager, sessionMonitor: sessionMonitor)
// Create the main view with all dependencies and binding
let mainView = VibeTunnelMenuView(isNewSessionActive: Binding(
let sessionBinding = Binding(
get: { [weak self] in self?.isNewSessionActive ?? false },
set: { [weak self] in self?.isNewSessionActive = $0 }
))
.environment(sessionMonitor)
.environment(serverManager)
.environment(ngrokService)
.environment(tailscaleService)
.environment(terminalLauncher)
.environment(sessionService)
.environment(gitRepositoryMonitor)
.environment(repositoryDiscovery)
)
let mainView = VibeTunnelMenuView(isNewSessionActive: sessionBinding)
.environment(sessionMonitor)
.environment(serverManager)
.environment(ngrokService)
.environment(tailscaleService)
.environment(terminalLauncher)
.environment(sessionService)
.environment(gitRepositoryMonitor)
.environment(repositoryDiscovery)
.environment(configManager)
.environment(worktreeService)
// Wrap in custom container for proper styling
let containerView = CustomMenuContainer {

View file

@ -22,7 +22,7 @@ struct VibeTunnelMenuView: View {
@State private var hoveredSessionId: String?
@State private var hasStartedKeyboardNavigation = false
@State private var showingNewSession = false
@FocusState private var focusedField: FocusField?
@FocusState private var focusedField: MenuFocusField?
/// Binding to allow external control of new session state
@Binding var isNewSessionActive: Bool
@ -31,13 +31,6 @@ struct VibeTunnelMenuView: View {
self._isNewSessionActive = isNewSessionActive
}
enum FocusField: Hashable {
case sessionRow(String)
case settingsButton
case newSessionButton
case quitButton
}
var body: some View {
if showingNewSession {
NewSessionForm(isPresented: Binding(
@ -108,12 +101,27 @@ struct VibeTunnelMenuView: View {
}
.frame(width: MenuStyles.menuWidth)
.background(Color.clear)
.focusable() // Enable keyboard focus
.focusEffectDisabled() // Remove blue focus ring
.onKeyPress { keyPress in
// Handle Tab key for focus indication
if keyPress.key == .tab && !hasStartedKeyboardNavigation {
hasStartedKeyboardNavigation = true
// Let the system handle the Tab to actually move focus
return .ignored
}
// Handle arrow keys for navigation
if keyPress.key == .upArrow || keyPress.key == .downArrow {
hasStartedKeyboardNavigation = true
return handleArrowKeyNavigation(keyPress.key == .upArrow)
}
// Handle Enter key to activate focused item
if keyPress.key == .return {
return handleEnterKey()
}
return .ignored
}
}
@ -136,4 +144,71 @@ struct VibeTunnelMenuView: View {
}
return false
}
// MARK: - Keyboard Navigation
private func handleArrowKeyNavigation(_ isUpArrow: Bool) -> KeyPress.Result {
let allSessions = activeSessions + idleSessions
let focusableFields: [MenuFocusField] = allSessions.map { .sessionRow($0.key) } +
[.newSessionButton, .settingsButton, .quitButton]
guard let currentFocus = focusedField,
let currentIndex = focusableFields.firstIndex(of: currentFocus)
else {
// No current focus, focus first item
if !focusableFields.isEmpty {
focusedField = focusableFields[0]
}
return .handled
}
let newIndex: Int = if isUpArrow {
currentIndex > 0 ? currentIndex - 1 : focusableFields.count - 1
} else {
currentIndex < focusableFields.count - 1 ? currentIndex + 1 : 0
}
focusedField = focusableFields[newIndex]
return .handled
}
private func handleEnterKey() -> KeyPress.Result {
guard let currentFocus = focusedField else { return .ignored }
switch currentFocus {
case .sessionRow(let sessionId):
// Find the session and trigger the appropriate action
if sessionMonitor.sessions[sessionId] != nil {
let hasWindow = WindowTracker.shared.windowInfo(for: sessionId) != nil
if hasWindow {
// Focus the terminal window
WindowTracker.shared.focusWindow(for: sessionId)
} else {
// Open in browser
if let url = DashboardURLBuilder.dashboardURL(port: serverManager.port, sessionId: sessionId) {
NSWorkspace.shared.open(url)
}
}
// Close the menu after action
NSApp.windows.first { $0.className == "VibeTunnelMenuWindow" }?.close()
}
return .handled
case .newSessionButton:
showingNewSession = true
return .handled
case .settingsButton:
SettingsOpener.openSettings()
// Close the menu after action
NSApp.windows.first { $0.className == "VibeTunnelMenuWindow" }?.close()
return .handled
case .quitButton:
NSApplication.shared.terminate(nil)
return .handled
}
}
}

View file

@ -0,0 +1,224 @@
import OSLog
import SwiftUI
/// View for selecting or creating Git worktrees
struct WorktreeSelectionView: View {
let gitRepoPath: String
@Binding var selectedWorktreePath: String?
let worktreeService: WorktreeService
@State private var showCreateWorktree = false
@Binding var newBranchName: String
@Binding var createFromBranch: String
@Binding var shouldCreateNewWorktree: Bool
@State private var showError = false
@State private var errorMessage = ""
@FocusState private var focusedField: Field?
enum Field: Hashable {
case branchName
case baseBranch
}
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "WorktreeSelectionView")
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: "point.3.connected.trianglepath.dotted")
.font(.system(size: 13))
.foregroundColor(.accentColor)
Text("Git Repository Detected")
.font(.system(size: 11, weight: .medium))
.foregroundColor(.secondary)
}
if worktreeService.isLoading {
HStack {
ProgressView()
.scaleEffect(0.8)
Text("Loading worktrees...")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 8)
} else {
VStack(alignment: .leading, spacing: 8) {
// Current branch info
if let currentBranch = worktreeService.worktrees.first(where: { $0.isCurrentWorktree ?? false }) {
HStack {
Label("Current Branch", systemImage: "arrow.branch")
.font(.caption)
.foregroundColor(.secondary)
Text(currentBranch.branch)
.font(.system(.caption, design: .monospaced))
.foregroundColor(.accentColor)
}
}
// Worktree selection
if !worktreeService.worktrees.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text(selectedWorktreePath != nil ? "Selected Worktree" : "Select Worktree")
.font(.caption)
.foregroundColor(.secondary)
ScrollView {
VStack(spacing: 2) {
ForEach(worktreeService.worktrees) { worktree in
WorktreeRow(
worktree: worktree,
isSelected: selectedWorktreePath == worktree.path
) {
selectedWorktreePath = worktree.path
shouldCreateNewWorktree = false
showCreateWorktree = false
newBranchName = ""
createFromBranch = ""
}
}
}
}
.frame(maxHeight: 120)
}
}
// Action buttons or create form
if showCreateWorktree {
// Inline create worktree form
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Create New Worktree")
.font(.caption)
.fontWeight(.medium)
.foregroundColor(.secondary)
Spacer()
Button(action: {
showCreateWorktree = false
shouldCreateNewWorktree = false
newBranchName = ""
createFromBranch = ""
}, label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 12))
.foregroundColor(.secondary)
})
.buttonStyle(.plain)
}
TextField("Branch name", text: $newBranchName)
.textFieldStyle(.roundedBorder)
.font(.system(size: 11))
.focused($focusedField, equals: .branchName)
TextField("Base branch (optional)", text: $createFromBranch)
.textFieldStyle(.roundedBorder)
.font(.system(size: 11))
.focused($focusedField, equals: .baseBranch)
Text("Leave empty to create from current branch")
.font(.system(size: 10))
.foregroundColor(.secondary.opacity(0.8))
}
.padding(.top, 8)
.padding(10)
.background(Color(NSColor.controlBackgroundColor).opacity(0.5))
.cornerRadius(6)
.onAppear {
focusedField = .branchName
}
} else {
HStack(spacing: 8) {
Button(action: {
showCreateWorktree = true
shouldCreateNewWorktree = true
}, label: {
Label("New Worktree", systemImage: "plus.circle")
.font(.caption)
})
.buttonStyle(.link)
if let followMode = worktreeService.followMode {
Toggle(isOn: .constant(followMode.enabled)) {
Label("Follow Mode", systemImage: "arrow.triangle.2.circlepath")
.font(.caption)
}
.toggleStyle(.button)
.buttonStyle(.link)
.disabled(true) // For now, just display status
}
}
.padding(.top, 4)
}
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color(NSColor.controlBackgroundColor).opacity(0.05))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.accentColor.opacity(0.2), lineWidth: 1)
)
.task {
await worktreeService.fetchWorktrees(for: gitRepoPath)
}
.alert("Error", isPresented: $showError) {
Button("OK") {}
} message: {
Text(errorMessage)
}
}
}
/// Row view for displaying a single worktree
struct WorktreeRow: View {
let worktree: Worktree
let isSelected: Bool
let onSelect: () -> Void
var body: some View {
Button(action: onSelect) {
HStack {
Image(systemName: (worktree.isCurrentWorktree ?? false) ? "checkmark.circle.fill" : "circle")
.font(.system(size: 10))
.foregroundColor((worktree.isCurrentWorktree ?? false) ? .accentColor : .secondary)
VStack(alignment: .leading, spacing: 2) {
Text(worktree.branch)
.font(.system(.caption, design: .monospaced))
.foregroundColor(isSelected ? .white : .primary)
Text(shortenPath(worktree.path))
.font(.system(size: 10))
.foregroundColor(isSelected ? .white.opacity(0.8) : .secondary)
}
Spacer()
if worktree.locked ?? false {
Image(systemName: "lock.fill")
.font(.system(size: 10))
.foregroundColor(.orange)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(isSelected ? Color.accentColor : Color.clear)
.cornerRadius(4)
}
.buttonStyle(.plain)
}
private func shortenPath(_ path: String) -> String {
let components = path.components(separatedBy: "/")
if components.count > 3 {
return ".../" + components.suffix(2).joined(separator: "/")
}
return path
}
}

View file

@ -10,7 +10,7 @@ import SwiftUI
struct SessionDetailView: View {
let session: ServerSessionInfo
@State private var windowTitle = ""
@State private var windowInfo: WindowEnumerator.WindowInfo?
@State private var windowInfo: WindowInfo?
@State private var isFindingWindow = false
@State private var windowSearchAttempted = false
@Environment(SystemPermissionManager.self)
@ -20,7 +20,7 @@ struct SessionDetailView: View {
@Environment(ServerManager.self)
private var serverManager
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "SessionDetailView")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "SessionDetailView")
var body: some View {
HStack(spacing: 30) {
@ -256,7 +256,7 @@ struct SessionDetailView: View {
// Log session details for debugging
logger
.info(
"Session details: id=\(session.id), pid=\(session.pid ?? -1), workingDir=\(session.workingDir), attachedViaVT=\(session.attachedViaVT ?? false)"
"Session details: id=\(session.id), pid=\(session.pid ?? -1), workingDir=\(session.workingDir)"
)
// Try to match by various criteria

View file

@ -5,7 +5,7 @@ import SwiftUI
// MARK: - Logger
extension Logger {
fileprivate static let advanced = Logger(subsystem: "com.vibetunnel.VibeTunnel", category: "AdvancedSettings")
fileprivate static let advanced = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "AdvancedSettings")
}
/// Advanced settings tab for power user options

View file

@ -13,7 +13,7 @@ struct CloudflareIntegrationSection: View {
@State private var isTogglingTunnel = false
@State private var tunnelEnabled = false
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "CloudflareIntegrationSection")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "CloudflareIntegrationSection")
// MARK: - Constants

View file

@ -26,7 +26,7 @@ struct DashboardSettingsView: View {
@State private var ngrokStatus: NgrokTunnelStatus?
@State private var tailscaleStatus: (isInstalled: Bool, isRunning: Bool, hostname: String?)?
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "DashboardSettings")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "DashboardSettings")
private var accessMode: DashboardAccessMode {
DashboardAccessMode(rawValue: accessModeString) ?? .localhost
@ -87,7 +87,7 @@ struct DashboardSettingsView: View {
return DashboardSessionInfo(
id: session.id,
title: session.name ?? "Untitled",
title: session.name.isEmpty ? "Untitled" : session.name,
createdAt: createdAt,
isActive: session.isRunning
)

View file

@ -37,7 +37,7 @@ struct DebugSettingsView: View {
@State private var devServerValidation: DevServerValidation = .notValidated
@State private var devServerManager = DevServerManager.shared
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "DebugSettings")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "DebugSettings")
var body: some View {
NavigationStack {

View file

@ -14,7 +14,8 @@ struct GeneralSettingsView: View {
private var showInDock = true
@AppStorage(AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
private var preventSleepWhenRunning = true
@StateObject private var configManager = ConfigManager.shared
@Environment(ConfigManager.self) private var configManager
@AppStorage(AppConstants.UserDefaultsKeys.serverPort)
private var serverPort = "4020"
@AppStorage(AppConstants.UserDefaultsKeys.dashboardAccessMode)
@ -27,7 +28,7 @@ struct GeneralSettingsView: View {
private var serverManager
private let startupManager = StartupManager()
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "GeneralSettings")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "GeneralSettings")
private var accessMode: DashboardAccessMode {
DashboardAccessMode(rawValue: accessModeString) ?? .localhost

View file

@ -3,7 +3,7 @@ import SwiftUI
/// Settings section for managing quick start commands
struct QuickStartSettingsSection: View {
@StateObject private var configManager = ConfigManager.shared
@Environment(ConfigManager.self) private var configManager
@State private var editingCommandId: String?
@State private var newCommandName = ""
@State private var newCommandCommand = ""
@ -113,7 +113,7 @@ struct QuickStartSettingsSection: View {
}
}
private func updateCommand(_ updated: ConfigManager.QuickStartCommand) {
private func updateCommand(_ updated: QuickStartCommand) {
configManager.updateCommand(
id: updated.id,
name: updated.name,
@ -121,7 +121,7 @@ struct QuickStartSettingsSection: View {
)
}
private func deleteCommand(_ command: ConfigManager.QuickStartCommand) {
private func deleteCommand(_ command: QuickStartCommand) {
configManager.deleteCommand(id: command.id)
}
@ -166,10 +166,10 @@ struct QuickStartSettingsSection: View {
// MARK: - Command Row
private struct QuickStartCommandRow: View {
let command: ConfigManager.QuickStartCommand
let command: QuickStartCommand
let isEditing: Bool
let onEdit: () -> Void
let onSave: (ConfigManager.QuickStartCommand) -> Void
let onSave: (QuickStartCommand) -> Void
let onDelete: () -> Void
let onStopEditing: () -> Void

View file

@ -34,7 +34,7 @@ struct RemoteAccessSettingsView: View {
@State private var showingServerErrorAlert = false
@State private var serverErrorMessage = ""
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "RemoteAccessSettings")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "RemoteAccessSettings")
private var accessMode: DashboardAccessMode {
DashboardAccessMode(rawValue: accessModeString) ?? .localhost
@ -219,7 +219,7 @@ private struct TailscaleIntegrationSection: View {
@State private var statusCheckTimer: Timer?
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "TailscaleIntegrationSection")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "TailscaleIntegrationSection")
var body: some View {
Section {

View file

@ -16,7 +16,7 @@ struct SecurityPermissionsSettingsView: View {
@State private var permissionUpdateTrigger = 0
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "SecurityPermissionsSettings")
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "SecurityPermissionsSettings")
// MARK: - Helper Properties
@ -60,13 +60,12 @@ struct SecurityPermissionsSettingsView: View {
.navigationTitle("Security")
.onAppear {
onAppearSetup()
// Register for continuous monitoring
permissionManager.registerForMonitoring()
}
.task {
// Check permissions before first render to avoid UI flashing
await permissionManager.checkAllPermissions()
// Register for continuous monitoring
permissionManager.registerForMonitoring()
}
.onDisappear {
permissionManager.unregisterFromMonitoring()

View file

@ -208,7 +208,7 @@ private struct PortConfigurationView: View {
@MainActor
enum ServerConfigurationHelpers {
private static let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "ServerConfiguration")
private static let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "ServerConfiguration")
static func restartServerWithNewPort(_ port: Int, serverManager: ServerManager) async {
// Update the port in ServerManager and restart

Some files were not shown because too many files have changed in this diff Show more