mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
feat: add comprehensive Git worktree management with follow mode and enhanced UI (#452)
This commit is contained in:
parent
4c897f139b
commit
f3a98ee058
328 changed files with 31934 additions and 6561 deletions
6
.github/workflows/ios.yml
vendored
6
.github/workflows/ios.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
6
.github/workflows/mac.yml
vendored
6
.github/workflows/mac.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
7
.github/workflows/nightly.yml
vendored
7
.github/workflows/nightly.yml
vendored
|
|
@ -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 ==="
|
||||
|
|
|
|||
510
.github/workflows/node.yml
vendored
510
.github/workflows/node.yml
vendored
|
|
@ -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
|
||||
24
.github/workflows/playwright.yml
vendored
24
.github/workflows/playwright.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -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
15
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
94
CHANGELOG.md
94
CHANGELOG.md
|
|
@ -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
123
CLAUDE.md
|
|
@ -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`
|
||||
|
|
|
|||
77
README.md
77
README.md
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
117
docs/git-hooks.md
Normal 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.
|
||||
194
docs/git-worktree-follow-mode.md
Normal file
194
docs/git-worktree-follow-mode.md
Normal 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.
|
||||
|
|
@ -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
386
docs/openapi.md
Normal 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
514
docs/worktree-spec.md
Normal 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
417
docs/worktree.md
Normal 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
3
mac/.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
60
mac/VibeTunnel/Core/Configuration/AppPreferences.swift
Normal file
60
mac/VibeTunnel/Core/Configuration/AppPreferences.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
30
mac/VibeTunnel/Core/Configuration/AuthConfig.swift
Normal file
30
mac/VibeTunnel/Core/Configuration/AuthConfig.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
39
mac/VibeTunnel/Core/Configuration/DebugConfig.swift
Normal file
39
mac/VibeTunnel/Core/Configuration/DebugConfig.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
35
mac/VibeTunnel/Core/Configuration/DevServerConfig.swift
Normal file
35
mac/VibeTunnel/Core/Configuration/DevServerConfig.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
42
mac/VibeTunnel/Core/Configuration/ServerConfig.swift
Normal file
42
mac/VibeTunnel/Core/Configuration/ServerConfig.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
80
mac/VibeTunnel/Core/Models/ControlMessage.swift
Normal file
80
mac/VibeTunnel/Core/Models/ControlMessage.swift
Normal 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 {}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
36
mac/VibeTunnel/Core/Models/GitInfo.swift
Normal file
36
mac/VibeTunnel/Core/Models/GitInfo.swift
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
39
mac/VibeTunnel/Core/Models/NetworkTypes.swift
Normal file
39
mac/VibeTunnel/Core/Models/NetworkTypes.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
50
mac/VibeTunnel/Core/Models/PathSuggestion.swift
Normal file
50
mac/VibeTunnel/Core/Models/PathSuggestion.swift
Normal 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
|
||||
}
|
||||
}
|
||||
62
mac/VibeTunnel/Core/Models/QuickStartCommand.swift
Normal file
62
mac/VibeTunnel/Core/Models/QuickStartCommand.swift
Normal 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
|
||||
}
|
||||
}
|
||||
25
mac/VibeTunnel/Core/Models/TunnelMetrics.swift
Normal file
25
mac/VibeTunnel/Core/Models/TunnelMetrics.swift
Normal 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
|
||||
}
|
||||
56
mac/VibeTunnel/Core/Models/WindowInfo.swift
Normal file
56
mac/VibeTunnel/Core/Models/WindowInfo.swift
Normal 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?
|
||||
}
|
||||
272
mac/VibeTunnel/Core/Models/Worktree.swift
Normal file
272
mac/VibeTunnel/Core/Models/Worktree.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import OSLog
|
|||
|
||||
extension Logger {
|
||||
fileprivate static let repositoryDiscovery = Logger(
|
||||
subsystem: "sh.vibetunnel.vibetunnel",
|
||||
subsystem: BundleIdentifiers.loggerSubsystem,
|
||||
category: "RepositoryDiscovery"
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] = [:]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import OSLog
|
|||
@MainActor
|
||||
final class PermissionChecker {
|
||||
private let logger = Logger(
|
||||
subsystem: "sh.vibetunnel.vibetunnel",
|
||||
subsystem: BundleIdentifiers.loggerSubsystem,
|
||||
category: "PermissionChecker"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import OSLog
|
|||
@MainActor
|
||||
final class ProcessTracker {
|
||||
private let logger = Logger(
|
||||
subsystem: "sh.vibetunnel.vibetunnel",
|
||||
subsystem: BundleIdentifiers.loggerSubsystem,
|
||||
category: "ProcessTracker"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
153
mac/VibeTunnel/Core/Services/WorktreeService.swift
Normal file
153
mac/VibeTunnel/Core/Services/WorktreeService.swift
Normal 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
|
||||
|
|
@ -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: "/")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue