diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 3223a27..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,126 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: - - main - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -jobs: - test: - name: Tests (Node + Rust) - runs-on: ubuntu-22.04 - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Cache npm downloads - uses: actions/cache@v4 - with: - path: ~/.npm - key: npm-${{ runner.os }}-${{ hashFiles('package.json') }} - restore-keys: npm-${{ runner.os }}- - - - name: Install frontend dependencies - run: npm install - - - name: Typecheck - run: npx tsc --noEmit - - - name: Check i18n keys - run: npm run check-i18n - - - name: Verify command contracts - run: npm run verify:contracts - - - name: Run frontend tests - run: npm test - - - name: Build frontend assets - run: npm run build - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache Rust dependencies - uses: swatinem/rust-cache@v2 - with: - workspaces: src-tauri - cache-on-failure: true - shared-key: ci - - - name: Install system dependencies (Linux) - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential pkg-config patchelf \ - libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev \ - libwebkit2gtk-4.1-dev \ - libsoup-3.0-dev || sudo apt-get install -y --no-install-recommends libsoup2.4-dev - - # 静态护栏:clippy 对 lib 代码零警告(可抓 unwrap/panic/切片等隐患)。 - # 测试代码暂不纳入门禁(存量警告待清理后再收紧为 --all-targets)。 - - name: Clippy (lib gate) - run: cargo clippy --manifest-path src-tauri/Cargo.toml --lib -- -D warnings - - # 单线程运行:部分测试修改全局进程状态(环境变量 PATH/HOME、全局 static), - # 并行会互相串扰导致 flaky。--test-threads=1 消除串扰,不损失覆盖率。 - - name: Run Rust tests - run: cargo test --manifest-path src-tauri/Cargo.toml -- --test-threads=1 - - # 平台护栏:Windows/macOS 上的编译与测试。用户报告的异常关闭是 Windows 特有, - # 仅在 ubuntu 跑无法捕获平台相关的编译错误、路径处理与 cfg(windows) 分支问题。 - platform-build: - name: Build & Test (${{ matrix.os }}) - strategy: - fail-fast: false - matrix: - os: [windows-latest, macos-latest] - runs-on: ${{ matrix.os }} - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Install frontend dependencies - run: npm install - - - name: Build frontend assets - run: npm run build - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache Rust dependencies - uses: swatinem/rust-cache@v2 - with: - workspaces: src-tauri - cache-on-failure: true - shared-key: ci-${{ matrix.os }} - - # 单线程运行:部分测试修改全局进程状态(环境变量 PATH/HOME、全局 static), - # 并行会互相串扰导致 flaky。--test-threads=1 消除串扰,不损失覆盖率。 - - name: Run Rust tests - run: cargo test --manifest-path src-tauri/Cargo.toml -- --test-threads=1 - - # 完整 build 可暴露 cfg(windows) 分支、manifest 嵌入、链接阶段问题, - # 这些在 cargo test/check 下不一定触发。 - - name: Build Rust (debug) - run: cargo build --manifest-path src-tauri/Cargo.toml diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml deleted file mode 100644 index 6a9d5ef..0000000 --- a/.github/workflows/deploy-docs.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Deploy Documentation to GitHub Pages - -on: - # 监听 main 分支的 push 事件 - push: - branches: - - main - paths: - - 'docs/**' - - '.github/workflows/deploy-docs.yml' - - # 支持手动触发 - workflow_dispatch: - -# 配置正确的 Pages 权限 -permissions: - contents: read - pages: write - id-token: write - -# 确保同一时间只有一个部署任务运行 -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - deploy: - name: Deploy to GitHub Pages - runs-on: ubuntu-latest - - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Pages - uses: actions/configure-pages@v4 - - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - # 从 docs 目录部署静态文件 - path: './docs' - - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml deleted file mode 100644 index aa720bf..0000000 --- a/.github/workflows/deploy-pages.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Deploy to GitHub Pages - -on: - push: - branches: - - main - paths: - - 'docs/**' - - '.github/workflows/deploy-pages.yml' - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Pages - uses: actions/configure-pages@v5 - - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - path: './docs' - - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index abbab2a..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,405 +0,0 @@ -name: Release Build - -on: - push: - branches: - - main - paths-ignore: - - 'docs/**' - - '*.md' - - '.github/workflows/deploy-*' - workflow_dispatch: - inputs: - major_version: - description: '大版本号' - required: false - default: '0' - -permissions: - contents: write - -concurrency: - group: release - cancel-in-progress: true - -jobs: - prepare: - name: Generate version - runs-on: ubuntu-22.04 - outputs: - version: ${{ steps.version.outputs.version }} - tag: ${{ steps.version.outputs.tag }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Generate version from date - id: version - run: | - MAJOR="${{ github.event.inputs.major_version || '0' }}" - YYYYMMDD=$(date -u +%Y%m%d) - HHMMSS=$(( $(date -u +%-H) * 10000 + $(date -u +%-M) * 100 + $(date -u +%-S) )) - VERSION="${MAJOR}.${YYYYMMDD}.${HHMMSS}" - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" - echo "Generated version: ${VERSION} (tag: v${VERSION})" - - - name: Generate changelog - id: changelog - run: | - # Find previous release tag - PREV_TAG=$(git tag --sort=-creatordate | head -1 || true) - echo "Previous tag: $PREV_TAG" - - if [ -n "$PREV_TAG" ]; then - RANGE="${PREV_TAG}..HEAD" - else - RANGE="HEAD~20..HEAD" - fi - - # Extract commit messages, categorize by prefix - FEATURES="" - FIXES="" - OTHERS="" - - while IFS= read -r line; do - # Skip merge commits and Co-Authored-By lines - case "$line" in - Merge*) continue ;; - "") continue ;; - esac - - if echo "$line" | grep -qiE '^feat'; then - # Remove "feat: " or "feat(...): " prefix for cleaner display - clean=$(echo "$line" | sed -E 's/^feat(\(.*\))?:\s*//') - FEATURES="${FEATURES}- ${clean}\n" - elif echo "$line" | grep -qiE '^fix'; then - clean=$(echo "$line" | sed -E 's/^fix(\(.*\))?:\s*//') - FIXES="${FIXES}- ${clean}\n" - elif echo "$line" | grep -qiE '^refactor'; then - clean=$(echo "$line" | sed -E 's/^refactor(\(.*\))?:\s*//') - OTHERS="${OTHERS}- ${clean}\n" - fi - done < <(git log "$RANGE" --pretty=format:"%s" --no-merges 2>/dev/null || true) - - # Build changelog - CHANGELOG="" - if [ -n "$FEATURES" ]; then - CHANGELOG="${CHANGELOG}### ✨ 新功能\n\n${FEATURES}\n" - fi - if [ -n "$FIXES" ]; then - CHANGELOG="${CHANGELOG}### 🐛 修复\n\n${FIXES}\n" - fi - if [ -n "$OTHERS" ]; then - CHANGELOG="${CHANGELOG}### 🔧 优化\n\n${OTHERS}\n" - fi - - if [ -z "$CHANGELOG" ]; then - CHANGELOG="常规更新与改进\n" - fi - - # Write to file for multi-line output - echo -e "$CHANGELOG" > /tmp/changelog.md - echo "changelog_file=/tmp/changelog.md" >> "$GITHUB_OUTPUT" - - - name: Create GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ steps.version.outputs.tag }} - run: | - CHANGELOG=$(cat /tmp/changelog.md) - gh release create "$TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --title "Worktree Manager $TAG" \ - --notes "$(cat < **macOS 首次安装说明:** - > 1. 下载 \`.dmg\`,拖入 Applications 安装 - > 2. 首次打开如提示无法验证开发者,请**右键点击 app → 打开** - > 3. 如仍无法打开,前往 **系统设置 → 隐私与安全性**,在底部找到提示并点击 **仍要打开** - > 4. 以上方法均无效时,打开终端执行:\`xattr -cr "/Applications/Worktree Manager.app"\` - EOF - )" - - release: - needs: prepare - strategy: - fail-fast: false - matrix: - include: - - os: macos-latest - - os: ubuntu-22.04 - - os: windows-latest - - runs-on: ${{ matrix.os }} - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache Rust dependencies - uses: swatinem/rust-cache@v2 - with: - workspaces: src-tauri - cache-on-failure: true - shared-key: release-build - save-if: ${{ github.ref == 'refs/heads/main' }} - - - name: Add macOS targets - if: runner.os == 'macOS' - run: rustup target add aarch64-apple-darwin x86_64-apple-darwin - - - name: Install system dependencies (Linux) - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential pkg-config patchelf \ - libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev \ - libwebkit2gtk-4.1-dev \ - libsoup-3.0-dev || sudo apt-get install -y --no-install-recommends libsoup2.4-dev - - - name: Set version - shell: bash - env: - APP_VERSION: ${{ needs.prepare.outputs.version }} - run: | - node -e " - const fs = require('fs'); - const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); - pkg.version = process.env.APP_VERSION; - fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); - " - node -e " - const fs = require('fs'); - const conf = JSON.parse(fs.readFileSync('src-tauri/tauri.conf.json', 'utf8')); - conf.version = process.env.APP_VERSION; - fs.writeFileSync('src-tauri/tauri.conf.json', JSON.stringify(conf, null, 2) + '\n'); - " - sed -i.bak 's/^version = ".*"/version = "'"$APP_VERSION"'"/' src-tauri/Cargo.toml && rm -f src-tauri/Cargo.toml.bak - echo "Set version to $APP_VERSION" - - - name: Cache npm downloads - uses: actions/cache@v4 - with: - path: ~/.npm - key: npm-${{ runner.os }}-${{ hashFiles('package.json') }} - restore-keys: npm-${{ runner.os }}- - - - name: Install frontend dependencies - shell: bash - run: | - rm -rf node_modules package-lock.json - npm install - - - name: Build Tauri App (macOS) - if: runner.os == 'macOS' - env: - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: npx tauri build --target universal-apple-darwin - - - name: Build Tauri App (Windows) - if: runner.os == 'Windows' - env: - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: npx tauri build --bundles nsis - - - name: Build Tauri App (Linux) - if: runner.os == 'Linux' - env: - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: npx tauri build --bundles appimage,deb - - - name: Prepare macOS assets - if: runner.os == 'macOS' - shell: bash - env: - TAG: ${{ needs.prepare.outputs.tag }} - run: | - set -euxo pipefail - mkdir -p release-assets - for path in \ - "src-tauri/target/universal-apple-darwin/release/bundle/macos" \ - "src-tauri/target/aarch64-apple-darwin/release/bundle/macos" \ - "src-tauri/target/release/bundle/macos"; do - if [ -d "$path" ]; then - TAR_GZ=$(find "$path" -maxdepth 1 -name "*.tar.gz" -type f | head -1 || true) - if [ -n "$TAR_GZ" ]; then - cp "$TAR_GZ" "release-assets/Worktree-Manager-${TAG}-macOS.tar.gz" - [ -f "$TAR_GZ.sig" ] && cp "$TAR_GZ.sig" "release-assets/Worktree-Manager-${TAG}-macOS.tar.gz.sig" - break - fi - fi - done - DMG=$(find src-tauri/target -name "*.dmg" -type f | head -1 || true) - [ -n "$DMG" ] && cp "$DMG" "release-assets/Worktree-Manager-${TAG}-macOS.dmg" - - - name: Prepare Windows assets - if: runner.os == 'Windows' - shell: pwsh - env: - TAG: ${{ needs.prepare.outputs.tag }} - run: | - New-Item -ItemType Directory -Force -Path release-assets | Out-Null - $nsis = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *-setup.exe -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($null -ne $nsis) { - Copy-Item $nsis.FullName (Join-Path release-assets "Worktree-Manager-$env:TAG-Windows-setup.exe") - $sigPath = "$($nsis.FullName).sig" - if (Test-Path $sigPath) { - Copy-Item $sigPath (Join-Path release-assets "Worktree-Manager-$env:TAG-Windows-setup.exe.sig") - } - } - $nsis_zip = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *-setup.nsis.zip -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($null -ne $nsis_zip) { - Copy-Item $nsis_zip.FullName (Join-Path release-assets "Worktree-Manager-$env:TAG-Windows-setup.nsis.zip") - $sigPath = "$($nsis_zip.FullName).sig" - if (Test-Path $sigPath) { - Copy-Item $sigPath (Join-Path release-assets "Worktree-Manager-$env:TAG-Windows-setup.nsis.zip.sig") - } - } - - - name: Prepare Linux assets - if: runner.os == 'Linux' - shell: bash - env: - TAG: ${{ needs.prepare.outputs.tag }} - run: | - set -euxo pipefail - mkdir -p release-assets - APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true) - if [ -n "$APPIMAGE" ]; then - cp "$APPIMAGE" "release-assets/Worktree-Manager-${TAG}-Linux.AppImage" - [ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" "release-assets/Worktree-Manager-${TAG}-Linux.AppImage.sig" - fi - DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true) - [ -n "$DEB" ] && cp "$DEB" "release-assets/Worktree-Manager-${TAG}-Linux.deb" - - - name: Upload release assets - shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ needs.prepare.outputs.tag }} - run: | - for file in release-assets/*; do - [ -f "$file" ] && gh release upload "$TAG" "$file" --clobber --repo "$GITHUB_REPOSITORY" - done - - - name: List build bundles (debug) - if: always() - shell: bash - run: find src-tauri/target -maxdepth 5 -type f \( -name "*.dmg" -o -name "*.msi" -o -name "*.deb" -o -name "*.AppImage" -o -name "*.tar.gz" -o -name "*.sig" -o -name "*.exe" \) 2>/dev/null || true - - assemble-latest-json: - name: Assemble latest.json - runs-on: ubuntu-22.04 - needs: [prepare, release] - permissions: - contents: write - steps: - - name: Download all release assets - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ needs.prepare.outputs.tag }} - run: | - set -euxo pipefail - mkdir -p dl - gh release download "$TAG" --dir dl --repo "$GITHUB_REPOSITORY" - ls -la dl || true - - - name: Generate latest.json - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - TAG: ${{ needs.prepare.outputs.tag }} - VERSION: ${{ needs.prepare.outputs.version }} - run: | - set -euo pipefail - PUB_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) - base_url="https://github.com/$REPO/releases/download/$TAG" - - # Fetch release notes from GitHub Release for the updater dialog - # Only include changelog (update content), not download/install instructions - NOTES=$(gh release view "$TAG" --repo "$REPO" --json body -q '.body' 2>/dev/null || echo "Release $TAG") - # Extract only the changelog section (between "## 更新内容" and "---") - CHANGELOG_ONLY=$(echo "$NOTES" | sed -n '/^## 更新内容$/,/^---$/p' | sed '1d;$d' | sed '/^$/N;/^\n$/d') - if [ -z "$CHANGELOG_ONLY" ]; then - CHANGELOG_ONLY="$NOTES" - fi - NOTES_JSON=$(echo "$CHANGELOG_ONLY" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))') - - mac_url="" mac_sig="" - win_url="" win_sig="" - linux_url="" linux_sig="" - - shopt -s nullglob - for sig in dl/*.sig; do - base=${sig%.sig} - fname=$(basename "$base") - url="$base_url/$fname" - sig_content=$(cat "$sig") - case "$fname" in - *.tar.gz) mac_url="$url"; mac_sig="$sig_content" ;; - *.AppImage) linux_url="$url"; linux_sig="$sig_content" ;; - *.nsis.zip) win_url="$url"; win_sig="$sig_content" ;; - esac - done - - { - echo '{' - echo " \"version\": \"$VERSION\"," - echo " \"notes\": ${NOTES_JSON}," - echo " \"pub_date\": \"$PUB_DATE\"," - echo ' "platforms": {' - first=1 - if [ -n "$mac_url" ] && [ -n "$mac_sig" ]; then - for key in darwin-aarch64 darwin-x86_64; do - [ $first -eq 0 ] && echo ',' - echo " \"$key\": {\"signature\": \"$mac_sig\", \"url\": \"$mac_url\"}" - first=0 - done - fi - if [ -n "$win_url" ] && [ -n "$win_sig" ]; then - [ $first -eq 0 ] && echo ',' - echo " \"windows-x86_64\": {\"signature\": \"$win_sig\", \"url\": \"$win_url\"}" - first=0 - fi - if [ -n "$linux_url" ] && [ -n "$linux_sig" ]; then - [ $first -eq 0 ] && echo ',' - echo " \"linux-x86_64\": {\"signature\": \"$linux_sig\", \"url\": \"$linux_url\"}" - first=0 - fi - echo ' }' - echo '}' - } > latest.json - echo "Generated latest.json:" && cat latest.json - - - name: Upload latest.json to release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ needs.prepare.outputs.tag }} - run: gh release upload "$TAG" latest.json --clobber --repo "$GITHUB_REPOSITORY" diff --git a/.gitignore b/.gitignore deleted file mode 100644 index f8808a9..0000000 --- a/.gitignore +++ /dev/null @@ -1,33 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local -coverage/ - -.claude/ -.memory-staging/ -.superpowers/brainstorm/ -docs/superpowers/ - -# Requirement docs (internal) -requirement-docs/ - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100644 index 26fc364..0000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env sh -pnpm exec lint-staged diff --git a/.husky/pre-push b/.husky/pre-push deleted file mode 100644 index e80aea6..0000000 --- a/.husky/pre-push +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env sh -cd src-tauri || exit 1 - -cargo test diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs deleted file mode 100644 index 4f05fc9..0000000 --- a/.lintstagedrc.mjs +++ /dev/null @@ -1,16 +0,0 @@ -const quoteFiles = (files) => files.map((file) => JSON.stringify(file)).join(' '); - -export default { - 'src/**/*.{ts,tsx}': (files) => [ - `eslint --fix ${quoteFiles(files)}`, - 'pnpm exec tsc --noEmit', - 'node scripts/check-i18n.mjs', - ], - 'src/locales/*.json': () => [ - 'node scripts/check-i18n.mjs', - ], - 'src-tauri/**/*.rs': () => [ - 'cargo fmt --manifest-path src-tauri/Cargo.toml -- --check', - 'cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings', - ], -}; diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 24d7cc6..0000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] -} diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 18bb351..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,55 +0,0 @@ -# Worktree Manager - -Git worktree 管理工具 | Tauri 2 + React 19 + Rust | 桌面端 + 浏览器双模式 - -## 开发命令 - -npm install / npm run dev / cargo tauri dev / npm run build / cargo tauri build - -### 重启 dev 流程 -必须先构建前端再启动 Tauri,否则会因缓存过期报错: -```bash -pnpm run build && npm run tauri dev -``` -**不要**直接运行 `npx tauri dev` 或 `cargo tauri dev`。 - -## 项目结构 - -``` -src-tauri/src/: main.rs, lib.rs(核心~2770行), git_ops.rs(~800行), pty_manager.rs(~270行), http_server.rs(~1240行) -src/: App.tsx(~1230行), types.ts, constants.ts, index.css - components/: WorktreeSidebar, WorktreeDetail, Terminal, TerminalPanel, GitOperations, SettingsView, CreateWorktreeModal, ArchiveConfirmationModal, AddProjectModal, AddProjectToWorktreeModal, AddWorkspaceModal, BranchCombobox, ContextMenus, WelcomeView, UpdaterDialogs, Icons, ui/ - hooks/: useWorkspace(~340行), useTerminal(~380行), useUpdater - lib/: backend(~350行), websocket(~218行) -``` - -## 核心约束(必须遵守) - -### 终端状态分离 -activatedTerminals(标签栏显示)和 mountedTerminals(组件挂载/PTY 生命周期)必须分离,绝对不能合并。 -- Terminal 组件卸载会调用 pty_close 销毁后端 PTY 会话 -- 切换 worktree 时用 display:none + visible:false 隐藏,**不卸载** -- 归档 worktree 时必须调用 cleanupTerminalsForPath() 清理 mountedTerminals -- 语音输入在切换 worktree 或终端标签时自动关闭 - -### Git 操作混用规则 -读取用 git2 crate,写入用 Command。Command 更安全不会锁库。 - -### 双模式命令同步 -前端统一入口 callBackend(command, args),自动路由到 IPC 或 HTTP。 -新增命令须同步三处: backend.ts + lib.rs generate_handler + HTTP 路由。 -运行 npm run contracts 验证同步。 - -### 性能约束 -- Git 操作两阶段加载:先显示本地数据(毫秒级),后台 fetch 远程(3-6s),fetch 期间按钮禁用并显示进度条 -- Loading 状态用 fixed overlay 而非 early return,避免组件卸载/重挂载风暴 -- check_remote_branch_exists 使用 git branch -r --list(本地检查),不触发网络请求 - -## 按需参考(需要时读取) - -- 后端全局状态 + 命令分类 → /Users/guo/Work/GuoVault/Guo/workspaces/worktree-manager/claude-reference/backend-state.md -- 终端系统架构详情 → /Users/guo/Work/GuoVault/Guo/workspaces/worktree-manager/claude-reference/terminal-architecture.md -- 双模式通信详情 → /Users/guo/Work/GuoVault/Guo/workspaces/worktree-manager/claude-reference/dual-mode.md -- 命令契约同步规则 → /Users/guo/Work/GuoVault/Guo/workspaces/worktree-manager/claude-reference/COMMAND_CONTRACTS.md -- 完整知识库(Obsidian) → /Users/guo/Work/GuoVault/Guo/workspaces/worktree-manager/CLAUDE.md -- 数据类型定义 → src/types.ts diff --git a/LICENSE b/LICENSE deleted file mode 100644 index a5691e3..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024-2026 Worktree Manager Contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md deleted file mode 100644 index d8b5c0a..0000000 --- a/README.md +++ /dev/null @@ -1,297 +0,0 @@ -
- -Worktree Manager - -# Worktree Manager - -**The missing GUI for Git Worktrees.** - -Work on multiple branches simultaneously, across multiple repos, without `stash`, `clone`, or context-switching pain. - -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![GitHub release](https://img.shields.io/github/v/release/guoyongchang/worktree-manager)](https://github.com/guoyongchang/worktree-manager/releases) -[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/guoyongchang/worktree-manager/releases) -[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/) - -

- Quick Start • - Features • - Screenshots • - FAQ • - 中文 -

- -[**Download**](https://github.com/guoyongchang/worktree-manager/releases) | -[Documentation](https://guoyongchang.github.io/worktree-manager/) | -[MCP Integration](docs/MCP.md) - -
- ---- - -## Why Worktree Manager? - -You're deep in a feature branch. Fifteen files changed. Dev server running. Then Slack pings: **production is down**. - -**Without Worktree Manager** — `git stash` → switch branch → `npm install` → wait for rebuild → fix → switch back → `git stash pop` → pray for no conflicts → restart dev server. **15 minutes minimum.** - -**With Worktree Manager** — Click "New Worktree", type `hotfix-payment`, done. Your feature branch keeps running. Dependencies are shared via symlink — instant setup. Fix, push, archive. **30 seconds.** - -### Multi-Repo Workspaces & Parallel AI Development - -Group related repos into a **Workspace**. Creating a Worktree creates a `git worktree` across all linked projects at once. Each Worktree gets its own terminal — run Claude Code, Codex, or Cursor in parallel across different branches without conflicts. - -

-Multi-Repo Workspaces — one click switches all repos, each worktree runs its own AI agent -

- -### Zero-Cost Context Switching - -P0 alert mid-development? Create a `hotfix-payment` Worktree → AI-assisted fix → merge to main → archive → switch back. **Uncommitted code, terminal output, dev servers — everything stays intact.** - -

-Zero-cost context switching — hotfix without losing any work -

- ---- - -## 📸 Screenshots - -| Main Interface | Create Worktree | -| :---: | :---: | -| ![Main Interface](docs/screenshots/main-view.png) | ![Create Worktree](docs/screenshots/new-worktree.png) | - -| Terminal & AI Coding | Browser Remote Access | -| :---: | :---: | -| ![Terminal](docs/screenshots/use-example-1.png) | ![Remote Access](docs/screenshots/remote-access.png) | - -| Voice Input & AI Refine | -| :---: | -| ![Voice Input](docs/screenshots/voice-and-refine.png) | - ---- - -## 🚀 Quick Start - -### Download - -| Platform | Download | -|----------|----------| -| macOS | [`.dmg`](https://github.com/guoyongchang/worktree-manager/releases/latest) | -| Windows | [`-setup.exe`](https://github.com/guoyongchang/worktree-manager/releases/latest) | -| Linux | [`.AppImage` / `.deb`](https://github.com/guoyongchang/worktree-manager/releases/latest) | - -> **Only requirement: Git 2.0+.** No Node.js or Rust needed at runtime. - -### Get Started in 3 Steps - -1. **Create a Workspace** — Point to your project directory or create a new one -2. **Add Projects** — Import repos via GitHub shorthand (`owner/repo`), SSH, or HTTPS -3. **Create Worktrees** — Click "+", name your branch, select projects, go - -That's it. Your worktree is ready with all dependencies symlinked and terminals pre-configured. - ---- - -## ✨ Features - -### Core - -| Feature | Description | -|---------|-------------| -| 🌿 **Parallel Branches** | Work on multiple branches at the same time in isolated directories, sharing the same `.git` data | -| 📦 **Multi-Repo Workspaces** | Group related repos (frontend + backend + shared libs) — create a worktree and all repos switch together | -| 🔗 **Smart Symlinks** | Auto-link `node_modules`, `.next`, `vendor`, `target` etc. Zero disk waste, zero reinstall | -| 🏷️ **Tag Organization** | Tag projects by team, domain, or stack. Filter and batch-select when creating worktrees | - -### Git Operations - -| Feature | Description | -|---------|-------------| -| 🔄 **One-Click Operations** | Sync, merge to test, pull, push — all from the UI with real-time diff stats | -| 📊 **Branch Insights** | See how many commits you're ahead/behind at a glance | -| ⚡ **Batch Actions** | Trigger operations across all projects in a worktree simultaneously | -| 📝 **AI Commit Messages** | Generate commit messages with Qwen AI (optional) | - -### Terminal & Remote - -| Feature | Description | -|---------|-------------| -| 💻 **Built-in Terminal** | Full terminal emulator (xterm.js + PTY) with shell integration and search | -| 🎤 **Voice Input** | Speak to type in terminal — powered by Dashscope ASR with AI text refinement | -| 🌐 **Browser Remote** | Share your workspace over the network with password protection | -| 🔒 **ngrok Tunneling** | Optional public access via ngrok — no port forwarding needed | - -### Integrations - -| Feature | Description | -|---------|-------------| -| 🖥️ **IDE Integration** | One-click open in VS Code, Cursor, or IntelliJ IDEA | -| 🤖 **AI-Ready (MCP)** | Built-in [MCP server](docs/MCP.md) — let Claude Code, Cursor, or Codex manage worktrees via natural language | -| 📁 **Safe Archiving** | Pre-archive checks catch uncommitted changes and running processes. Restore anytime | - ---- - -## ❓ FAQ - -
-What is a Git worktree? - -A Git worktree lets you check out multiple branches into separate directories while sharing a single `.git` database. Unlike cloning, worktrees share history, refs, and hooks — no extra disk space for repository data. [Learn more](https://git-scm.com/docs/git-worktree) - -
- -
-How does the symlink feature work? - -When creating a worktree, Worktree Manager automatically creates symlinks for directories you specify (e.g., `node_modules`, `.next`, `target`). These point to the main project's directories, so you never need to reinstall dependencies. You can configure which folders to link per project. - -
- -
-Can I use it with a single repo? - -Absolutely. While multi-repo workspaces are a key feature, Worktree Manager works perfectly with a single repository too. - -
- -
-Does browser remote access require installing anything on the remote machine? - -No. The remote machine only needs a modern browser. Everything runs through the web interface — terminal, file browsing, git operations, worktree management. - -
- -
-Is my data safe when sharing via browser? - -Yes. Browser access is password-protected with challenge-response authentication (no plaintext passwords over the wire). You can also limit access to LAN-only or use ngrok for secure tunneling. - -
- -
-What does the MCP integration do? - -The built-in [Model Context Protocol](docs/MCP.md) server lets AI coding assistants (Claude Code, Cursor, Codex) create worktrees, check status, and run git operations through natural language — without leaving your AI chat. See [MCP docs](docs/MCP.md) for setup. - -
- ---- - -## 📂 How It Works - -
-Workspace directory structure - -``` -workspace/ -├── .worktree-manager.json # Workspace config -├── projects/ # Main repos (base branches) -│ ├── frontend/ -│ └── backend/ -└── worktrees/ # Your worktrees - ├── feature-checkout-v2/ - │ ├── projects/ - │ │ ├── frontend/ # ← git worktree (own branch) - │ │ │ └── node_modules # ← symlink to main - │ │ └── backend/ - │ ├── .claude -> ../../.claude # Shared files - │ └── CLAUDE.md -> ../../CLAUDE.md - └── hotfix-payment/ - └── ... -``` - -Shared items (`.claude`, `CLAUDE.md`, config files) are automatically symlinked across all worktrees so AI assistants and tooling configs stay in sync. - -
- -
-Workspace config example (.worktree-manager.json) - -```jsonc -{ - "name": "My Project", - "worktrees_dir": "worktrees", - "linked_workspace_items": [".claude", "CLAUDE.md"], - "tags": [ - { "id": "fe", "name": "Frontend", "color": "#3B82F6" }, - { "id": "be", "name": "Backend", "color": "#10B981" } - ], - "projects": [ - { - "name": "web-app", - "base_branch": "main", - "test_branch": "test", - "merge_strategy": "merge", - "linked_folders": ["node_modules", ".next"], - "tags": ["fe"] - } - ] -} -``` - -
- -
-Adding projects — supported formats - -| Format | Example | -|--------|---------| -| GitHub shorthand | `facebook/react` | -| SSH | `git@github.com:facebook/react.git` | -| SSH (custom port) | `ssh://git@gitlab.com:1022/org/repo.git` | -| HTTPS | `https://github.com/facebook/react.git` | - -
- ---- - -## 🔧 Building from Source - -
-For contributors and developers - -**Prerequisites:** Node.js 20+, Rust 1.70+ ([install](https://rustup.rs)), Git 2.0+ - -```bash -git clone https://github.com/guoyongchang/worktree-manager.git -cd worktree-manager -npm install - -# Development -npm run build && npm run tauri dev - -# Production build -npm run tauri build - -# Verify command contracts (IPC ↔ HTTP sync) -npm run contracts -``` - -**Tech Stack:** Tauri 2 · React 19 · TypeScript 5 · Tailwind CSS 4 · Rust (axum, git2, tokio) · xterm.js - -See [TESTING.md](docs/TESTING.md) for the testing strategy. - -
- ---- - -## Contributing - -Contributions are welcome! Please open an issue first to discuss what you'd like to change. - -## License - -[MIT](LICENSE) - ---- - -
- -**If Worktree Manager saves you time, consider giving it a ⭐!** - -[Report Bug](https://github.com/guoyongchang/worktree-manager/issues) · -[Request Feature](https://github.com/guoyongchang/worktree-manager/issues) · -[Documentation](https://guoyongchang.github.io/worktree-manager/) - -
diff --git a/README.zh-CN.md b/README.zh-CN.md deleted file mode 100644 index e001236..0000000 --- a/README.zh-CN.md +++ /dev/null @@ -1,293 +0,0 @@ -
- -Worktree Manager - -# Worktree Manager - -**Git Worktree 可视化管理工具** - -多分支并行开发,多仓库联动,告别 `stash`、`clone` 和上下文切换的痛苦。 - -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![GitHub release](https://img.shields.io/github/v/release/guoyongchang/worktree-manager)](https://github.com/guoyongchang/worktree-manager/releases) -[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/guoyongchang/worktree-manager/releases) -[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/) - -

- 快速开始 • - 核心功能 • - 截图 • - FAQ • - English -

- -[**下载**](https://github.com/guoyongchang/worktree-manager/releases) | -[文档](https://guoyongchang.github.io/worktree-manager/) | -[MCP 集成](docs/MCP.md) - -
- ---- - -## 为什么需要 Worktree Manager? - -### 线上着火,但你手里的活还没提交 - -你正在 `feature/checkout-v2` 上重构结算流程,改了十几个文件,`npm run dev` 跑着热更新。这时候 Slack 弹出告警:线上支付回调 500 了。 - -**没有 Worktree Manager** — `git stash` → 切分支 → `npm install`(依赖版本不同,得重装) → 等构建 → 修 bug → 切回来 → `git stash pop` → 祈祷没冲突 → 重启 dev server。**15 分钟起步。** - -**有 Worktree Manager** — 点「新建」,输入 `hotfix-payment`,完成。Feature 分支 dev server 还在跑,`node_modules` 通过 symlink 共享,秒级就绪。**切换成本 30 秒。** - -### 多仓库工作区 & 并行 AI 开发 - -将相关仓库组成一个**工作区**,新建 Worktree 时所有项目一起创建 `git worktree`。每个 Worktree 拥有独立终端 —— 可以同时在不同分支上运行 Claude Code、Codex 或 Cursor,互不冲突。 - -

-多仓库工作区 — 一键切换所有仓库,每个 worktree 运行独立 AI 代理 -

- -### 零成本上下文切换 - -开发到一半突发 P0?新建 `hotfix-payment` Worktree → AI 辅助修复 → 合并到 main → 归档 → 切回去。**未提交的代码、终端输出、dev server —— 一切都还在。** - -

-零成本上下文切换 — hotfix 不丢失任何工作 -

- -### 前后端联调,分支对不上就炸 - -前后端分仓,做「会员体系」时两个仓库都要切到 `feature/membership`。同事让你看一个 `feature/search` 的问题,你切了前端忘了切后端 —— 白屏、404,排查半天发现是分支没对齐。 - -**用 Worktree Manager** — 一个 worktree 绑定多个仓库,切换 worktree 就是切换整套环境,不存在「只切了一半」。 - ---- - -## 📸 截图 - -| 主界面 | 创建 Worktree | -| :---: | :---: | -| ![主界面](docs/screenshots/main-view.png) | ![创建 Worktree](docs/screenshots/new-worktree.png) | - -| 终端 & AI 编程 | 浏览器远程访问 | -| :---: | :---: | -| ![终端](docs/screenshots/use-example-1.png) | ![远程访问](docs/screenshots/remote-access.png) | - -| 语音输入 & AI 精炼 | -| :---: | -| ![语音输入](docs/screenshots/voice-and-refine.png) | - ---- - -## 🚀 快速开始 - -### 下载 - -| 平台 | 下载 | -|------|------| -| macOS | [`.dmg`](https://github.com/guoyongchang/worktree-manager/releases/latest) | -| Windows | [`-setup.exe`](https://github.com/guoyongchang/worktree-manager/releases/latest) | -| Linux | [`.AppImage` / `.deb`](https://github.com/guoyongchang/worktree-manager/releases/latest) | - -> **唯一要求:Git 2.0+。** 运行时不需要 Node.js 或 Rust。 - -### 三步上手 - -1. **创建工作区** — 导入你的项目目录,或新建一个 Workspace -2. **添加项目** — 通过 GitHub 简写(`owner/repo`)、SSH 或 HTTPS 添加仓库 -3. **新建 Worktree** — 点击「+」,输入分支名,选择项目,开始开发 - -就这么简单。Worktree 创建后自动链接依赖、配置终端,开箱即用。 - ---- - -## ✨ 核心功能 - -### 基础能力 - -| 功能 | 说明 | -|------|------| -| 🌿 **多分支并行** | 同时在多个分支上工作,互不干扰,共享 `.git` 数据 | -| 📦 **多仓库工作区** | 将前端 + 后端 + 公共库组成工作区,切换 worktree 所有仓库同步切换 | -| 🔗 **智能 Symlink** | 自动链接 `node_modules`、`.next`、`vendor`、`target`,零磁盘浪费 | -| 🏷️ **标签分组** | 按团队、领域或技术栈打标签,创建 worktree 时按标签筛选 | - -### Git 操作 - -| 功能 | 说明 | -|------|------| -| 🔄 **一键操作** | 同步 base、合并到 test、拉取、推送,全在界面完成 | -| 📊 **分支洞察** | 一眼看到领先/落后多少个 commit | -| ⚡ **批量触发** | 一键对 worktree 内所有项目执行操作 | -| 📝 **AI 提交信息** | 使用 Qwen AI 自动生成 commit message(可选) | - -### 终端 & 远程 - -| 功能 | 说明 | -|------|------| -| 💻 **内置终端** | 完整终端模拟器(xterm.js + PTY),支持 Shell 集成和搜索 | -| 🎤 **语音输入** | 对着终端说话即可输入,Dashscope ASR + AI 文本精炼 | -| 🌐 **浏览器远程** | 通过网络分享工作区,密码保护 | -| 🔒 **ngrok 穿透** | 可选 ngrok 隧道,无需端口映射即可公网访问 | - -### 集成 - -| 功能 | 说明 | -|------|------| -| 🖥️ **IDE 集成** | 一键用 VS Code、Cursor 或 IntelliJ IDEA 打开 | -| 🤖 **AI 就绪 (MCP)** | 内置 [MCP 服务器](docs/MCP.md),让 Claude Code、Cursor、Codex 通过自然语言管理 worktree | -| 📁 **安全归档** | 归档前检查未提交更改和运行中的进程,随时一键恢复 | - ---- - -## ❓ 常见问题 - -
-什么是 Git worktree? - -Git worktree 可以将同一仓库的多个分支检出到独立目录,共享 `.git` 数据。不需要克隆多份仓库,不占额外磁盘空间。[了解更多](https://git-scm.com/docs/git-worktree) - -
- -
-Symlink 是怎么工作的? - -创建 worktree 时,Worktree Manager 会自动为你指定的目录(如 `node_modules`、`.next`、`target`)创建符号链接,指向主项目的对应目录。这样你永远不需要重新安装依赖。可以按项目单独配置需要链接的目录。 - -
- -
-只有一个仓库也能用吗? - -当然。多仓库工作区是亮点功能,但单仓库一样好用。 - -
- -
-浏览器远程访问需要在远程机器装东西吗? - -不需要。远程机器只需要一个现代浏览器。终端、文件浏览、git 操作、worktree 管理全在网页完成。 - -
- -
-通过浏览器分享安全吗? - -安全。浏览器访问采用 challenge-response 认证(不在网络上传输明文密码)。可以限制仅局域网访问,或使用 ngrok 安全隧道。 - -
- -
-MCP 集成能做什么? - -内置 [Model Context Protocol](docs/MCP.md) 服务器,让 AI 编程助手(Claude Code、Cursor、Codex)通过自然语言创建 worktree、查看状态、执行 git 操作 —— 不用离开 AI 聊天窗口。详见 [MCP 文档](docs/MCP.md)。 - -
- ---- - -## 📂 工作原理 - -
-工作区目录结构 - -``` -workspace/ -├── .worktree-manager.json # 工作区配置 -├── projects/ # 主仓库(base 分支) -│ ├── frontend/ -│ └── backend/ -└── worktrees/ # Worktree 目录 - ├── feature-checkout-v2/ - │ ├── projects/ - │ │ ├── frontend/ # ← git worktree(独立分支) - │ │ │ └── node_modules # ← symlink 到主仓库 - │ │ └── backend/ - │ ├── .claude -> ../../.claude # 共享文件 - │ └── CLAUDE.md -> ../../CLAUDE.md - └── hotfix-payment/ - └── ... -``` - -共享文件(`.claude`、`CLAUDE.md`、配置文件)自动 symlink 到所有 worktree,AI 助手和工具配置始终保持同步。 - -
- -
-配置文件示例(.worktree-manager.json - -```jsonc -{ - "name": "我的项目", - "worktrees_dir": "worktrees", - "linked_workspace_items": [".claude", "CLAUDE.md"], - "tags": [ - { "id": "fe", "name": "前端", "color": "#3B82F6" }, - { "id": "be", "name": "后端", "color": "#10B981" } - ], - "projects": [ - { - "name": "web-app", - "base_branch": "main", - "test_branch": "test", - "merge_strategy": "merge", - "linked_folders": ["node_modules", ".next"], - "tags": ["fe"] - } - ] -} -``` - -
- ---- - -## 🔧 从源码构建 - -
-面向贡献者和开发者 - -**环境要求:** Node.js 20+、Rust 1.70+([安装](https://rustup.rs))、Git 2.0+ - -```bash -git clone https://github.com/guoyongchang/worktree-manager.git -cd worktree-manager -npm install - -# 开发模式 -npm run build && npm run tauri dev - -# 生产构建 -npm run tauri build - -# 验证命令契约(IPC ↔ HTTP 同步) -npm run contracts -``` - -**技术栈:** Tauri 2 · React 19 · TypeScript 5 · Tailwind CSS 4 · Rust (axum, git2, tokio) · xterm.js - -详见 [TESTING.md](docs/TESTING.md) 了解测试策略。 - -
- ---- - -## 参与贡献 - -欢迎贡献!请先开一个 issue 讨论你想改的内容。 - -## 许可证 - -[MIT](LICENSE) - ---- - -
- -**如果 Worktree Manager 为你节省了时间,请给个 ⭐!** - -[报告 Bug](https://github.com/guoyongchang/worktree-manager/issues) · -[功能建议](https://github.com/guoyongchang/worktree-manager/issues) · -[文档](https://guoyongchang.github.io/worktree-manager/) - -
diff --git a/docs/MCP.md b/docs/MCP.md deleted file mode 100644 index 53288e6..0000000 --- a/docs/MCP.md +++ /dev/null @@ -1,141 +0,0 @@ -# MCP Integration — Worktree Manager - -Enable AI assistants (Claude Code, Codex, Cursor) to interact with Worktree Manager through the Model Context Protocol (MCP). - -## What is MCP? - -[MCP](https://modelcontextprotocol.io) is an open protocol that enables AI assistants to connect with external tools and data sources. By implementing an MCP server, Worktree Manager becomes accessible to any MCP-compatible AI client. - -## Installation - -### Auto Install (Recommended) - -```bash -npx -y @worktree-manager/mcp install -``` - -This command: -1. Installs the MCP server package -2. Configures Claude Code to use it automatically - -### Manual Install - -Add to your `~/.claude.json`: - -```json -{ - "mcpServers": { - "worktree-manager": { - "command": "npx", - "args": ["-y", "@worktree-manager/mcp", "start"] - } - } -} -``` - -## Usage - -After installation, restart Claude Code or run: - -```bash -claude mcp restart -``` - -The MCP server will automatically start when needed. - -## Available Tools - -### Layer 1 — Core (Always Available) - -| Tool | Description | -|------|-------------| -| `workspace_list` | List all configured workspaces | -| `workspace_get_current` | Get the currently selected workspace | -| `worktree_list` | List all worktrees in current workspace | -| `worktree_get_status` | Get detailed status of a specific worktree | -| `workspace_get_status` | Get main workspace status | - -### Layer 2 — Details (On Demand) - -| Tool | Description | -|------|-------------| -| `project_get_branches` | Get list of branches for a project | -| `project_get_diff_stats` | Get diff statistics vs base branch | -| `project_get_changed_files` | List uncommitted files | - -### Layer 3 — Advanced (Wrapped by Skills) - -| Tool | Description | -|------|-------------| -| `worktree_create` | Create a new worktree | -| `worktree_archive` | Archive an existing worktree | -| `git_commit` | Stage and commit changes | -| `git_push` | Push to remote | - -## Examples - -### List all worktrees - -``` -List all worktrees in my current workspace -``` - -### Check worktree status - -``` -What's the status of my feature-xyz worktree? -``` - -### Create a new worktree - -``` -Create a new worktree called feature-abc with all projects -``` - -## How It Works - -``` -┌─────────────────────┐ MCP Protocol ┌─────────────────────┐ -│ Claude Code/Codex │◄───────────────────►│ @worktree-manager │ -│ / Cursor │ (stdio) │ /mcp │ -└─────────────────────┘ └────────┬────────────┘ - │ - HTTP (localhost:42819) - │ - ▼ - ┌─────────────────────┐ - │ Worktree Manager │ - │ (Tauri Desktop) │ - └─────────────────────┘ -``` - -When the Worktree Manager desktop app is running, the MCP server connects via HTTP for real-time data. When the app is not running, it falls back to reading the config file. - -## Requirements - -- [Worktree Manager](https://github.com/your-repo/worktree-manager) desktop app -- Node.js 18+ (for running via npx) -- Claude Code, Codex, or any MCP-compatible AI assistant - -## Troubleshooting - -### "No transport available" - -Ensure the Worktree Manager desktop app is running, or that `~/.config/worktree-manager/global.json` exists. - -### Tools not responding - -Try restarting the MCP server: - -```bash -claude mcp stop worktree-manager -claude mcp start worktree-manager -``` - -## Uninstall - -```bash -npx -y @worktree-manager/mcp uninstall -``` - -Or manually remove the `worktree-manager` entry from `~/.claude.json`. diff --git a/docs/TESTING.md b/docs/TESTING.md deleted file mode 100644 index 38fd42e..0000000 --- a/docs/TESTING.md +++ /dev/null @@ -1,114 +0,0 @@ -# Testing Strategy & API Matrix - -This repo has two backend transports: - -- **Desktop mode**: Tauri IPC commands (`invoke`) -- **Browser mode**: HTTP `/api/*` + WebSocket `/ws` - -To keep changes safe and reviewable, tests are organized by *behavior layer* rather than by technical layer. - -## Source Of Truth - -- API inventory and transport mirroring are tracked by the generated contract doc: - - `docs/generated/command-contracts.md` -- CI enforces: - - `npm run verify:contracts` - - `npm test` - - `cargo test` - -This means: adding/removing a command must update both the backend registration and frontend usage. Contracts are the gate. - -## Recommended Test Layers - -### Layer A: Rust pure-logic unit tests (fast, most coverage) - -Test functions that do not need a router/server: - -- serialization / backwards-compat config parsing -- origin/CORS allow list logic -- git output parsing (diff stats, status parsing) -- path normalization rules -- terminal output transformations (UTF-8 repair, chunking) - -Rule: prefer this layer whenever logic can be expressed without IO. - -### Layer B: Rust handler / command tests (business behavior) - -Test internal `_impl` functions and command functions with: - -- temp dirs -- small initialized git repos (when needed) -- in-memory state where possible - -Rule: test success + a small number of high-signal error cases. - -### Layer C: Rust router smoke (API surface stability) - -Goal: ensure the HTTP API surface exists and does not crash. - -We run a router-level smoke test that: - -1. extracts all `/api/*` routes from `src-tauri/src/http_server/routing.rs` -2. sends one request per route through `create_router()` -3. asserts each route is **not** `404` and **not** `500` - -This intentionally does not try to validate every endpoint's semantics; it guarantees: - -- route registration stays in sync with routing source -- middleware extractors are wired correctly (e.g. `ConnectInfo`) -- no route panics on trivial inputs - -### Layer D: Frontend integration tests (UI state + interactions) - -Use `vitest + @testing-library/react` to test: - -- view switching / panel toggles -- timers (auto refresh, polling) with fake timers -- error/success flows -- cross-component interactions (e.g. clicking a badge triggers a view) - -Rule: mock `@/lib/backend` and websocket manager; focus on observable UI behavior. - -### Layer E: E2E smoke (optional, few paths only) - -If/when added (Playwright recommended), keep it minimal: - -- browser sharing: open link -> auth -> worktree list visible -- terminal basic loop: open -> type -> output visible - -Rule: E2E is expensive. Use it for end-to-end connectivity only. - -## API Matrix Policy (How To Keep "All APIs" Covered) - -1. **Contracts are mandatory**: any change to commands/routes must keep `verify:contracts` green. -2. **Router smoke is mandatory**: new HTTP routes are automatically included in the smoke set via routing source extraction. -3. **Semantics are targeted**: - - Only critical/high-risk commands need dedicated Layer B tests. - - Most endpoints rely on Layer C (existence + non-500) plus Layer A unit tests for the underlying logic. - -## When Adding A New API - -Checklist: - -1. Add IPC command + HTTP route (if mirrored). -2. Update / run `npm run verify:contracts`. -3. Ensure router smoke stays green (`cargo test`). -4. Add Layer A/B tests if the change adds meaningful logic or a risky flow. - -## Security Surfaces - -| Surface | Primary test layer | File | -|--------|---------------------|------| -| Auth middleware (`401`) | Layer B router tests | `src-tauri/src/http_server.rs` | -| Localhost-only protection (`403`) | Layer B router tests | `src-tauri/src/http_server.rs` | -| Auth challenge (`salt`, `429`) | Layer B router tests | `src-tauri/src/http_server.rs` | -| Auth verify (`proof`, session management, stale session cleanup) | Layer B router tests | `src-tauri/src/http_server.rs` | -| Auth rate limiter | Layer A unit tests | `src-tauri/src/types.rs` | -| Nonce one-time use | Layer A unit tests + Layer B verify reuse test | `src-tauri/src/types.rs`, `src-tauri/src/http_server.rs` | -|--------|---------------------|------| -| Auth middleware (`401`) | Layer B router tests | `src-tauri/src/http_server.rs` | -| Localhost-only protection (`403`) | Layer B router tests | `src-tauri/src/http_server.rs` | -| Auth challenge (`salt`, `429`) | Layer B router tests | `src-tauri/src/http_server.rs` | -| Auth verify (`proof`, session management, stale session cleanup) | Layer B router tests | `src-tauri/src/http_server.rs` | -| Auth rate limiter | Layer A unit tests | `src-tauri/src/types.rs` | -| Nonce one-time use | Layer A unit tests + Layer B verify reuse test | `src-tauri/src/types.rs`, `src-tauri/src/http_server.rs` | diff --git a/docs/design-system.html b/docs/design-system.html deleted file mode 100644 index 5ded5cd..0000000 --- a/docs/design-system.html +++ /dev/null @@ -1,337 +0,0 @@ - - - - - - Worktree Manager - 设计系统配色方案 - - - - - -
-

Worktree Manager 设计系统

-

配色方案与组件示例

- - -
-

配色方案

- -
- -
-

背景色

-
-
-
-
-
slate-950
-
#0f172a
-
-
-
-
-
-
slate-900
-
#1e293b
-
-
-
-
-
-
slate-800
-
#334155
-
-
-
-
- - -
-

文字色

-
-
-
-
-
slate-100
-
主文字
-
-
-
-
-
-
slate-300
-
次要文字
-
-
-
-
-
-
slate-500
-
辅助文字
-
-
-
-
- - -
-

品牌色

-
-
-
-
-
blue-500
-
主按钮
-
-
-
-
-
-
blue-600
-
悬停状态
-
-
-
-
-
-
blue-400
-
高亮/链接
-
-
-
-
- - -
-

辅助色

-
-
-
-
-
green-500
-
成功/特性
-
-
-
-
-
-
purple-400
-
特殊/链接
-
-
-
-
-
-
orange-500
-
警告/强调
-
-
-
-
-
-
- - -
-

按钮组件

-
- - - - -
-
- - -
-

卡片组件

-
- -
-
- 🚀 -
-

默认卡片

-

这是一个标准的卡片组件示例

-
- - -
-
- -
-

悬停卡片

-

鼠标悬停查看边框和阴影效果

-
- - -
-
- 🎨 -
-

渐变卡片

-

带渐变背景的特殊卡片

-
-
-
- - -
-

表单元素

-
-
- - -
- -
- - -
- -
- -
-
-
- - -
-

代码块

-
-
- 安装命令 - -
-
# 克隆项目
-git clone https://github.com/guoyongchang/worktree-manager.git
-cd worktree-manager
-
-# 安装依赖
-npm install
-
-# 开发模式运行
-npm run tauri dev
-
-
- - -
-

徽章 (Badges)

-
- - 蓝色徽章 - - - 绿色徽章 - - - 紫色徽章 - - - 橙色徽章 - - - 灰色徽章 - -
-
- - -
-

渐变文字

-
-

- 蓝紫渐变标题 -

-

- 绿蓝渐变标题 -

-

- 橙粉渐变标题 -

-
-
- - -
-

间距系统

-
-
-
4px (1)
-
-
-
-
8px (2)
-
-
-
-
12px (3)
-
-
-
-
16px (4)
-
-
-
-
24px (6)
-
-
-
-
32px (8)
-
-
-
-
- - -
-

阴影系统

-
-
-
shadow-sm
-
-
-
shadow
-
-
-
shadow-md
-
-
-
shadow-lg
-
-
-
- - -
-

Worktree Manager Design System v1.0

-

Made with ❤️ by UI/UX Designer

-
- -
- - - diff --git a/docs/en/guide.html b/docs/en/guide.html deleted file mode 100644 index 28305d0..0000000 --- a/docs/en/guide.html +++ /dev/null @@ -1,1281 +0,0 @@ - - - - - - User Guide - Git Worktree Manager - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
-

User Guide

-

From installation to mastery, a comprehensive guide to Git Worktree Manager

-
- - - - - -
- - -
-

- 1 - Installation & Setup -

- -
-

Download & Install (Recommended)

-

Head to the GitHub Releases page and download the installer for your platform:

- -
- ✅ macOS (Intel + ARM) - ✅ Windows - ✅ Linux -
-
-

🔄 Built-in auto-update: once installed, new versions are pushed automatically -- no manual downloads needed.

-
-
- -
-

- - macOS Installation Notes -

-
    -
  1. - 1 - Download the .dmg file, open it, and drag the app into the Applications folder. -
  2. -
  3. - 2 - If you see "cannot verify the developer" on first launch, right-click the app and select "Open". -
  4. -
  5. - 3 - If it still won't open, go to System Settings → Privacy & Security, find the prompt at the bottom, and click "Open Anyway". -
  6. -
  7. - 4 - If none of the above works, open Terminal and run: xattr -cr "/Applications/Worktree Manager.app" -
  8. -
-
- -
- 🛠️ Build from Source (For Developers) -
-
-

Prerequisites

-
    -
  • - - Node.js 20+ (Download) -
  • -
  • - - Rust 1.70+ (Install Guide) -
  • -
  • - - Git 2.0+ (available on command line) -
  • -
-
- -

Build Steps

-
# 1. Clone the project
-git clone https://github.com/guoyongchang/worktree-manager.git
-cd worktree-manager
-
-# 2. Install dependencies
-npm install
-
-# 3. Run in development mode
-npm run tauri dev
-
-# 4. Build for production (optional)
-npm run tauri build
- -
-

💡 Tip: The first run may take a few minutes to download Rust dependencies. Please be patient.

-
-
-
-
- - -
-

- 2 - Getting Started -

- -
-
-

- 1 - Create a Workspace -

-

After launching the app, you'll need to create or import a workspace on first use:

-
    -
  • New Workspace: Select an empty directory and the app will initialize it automatically
  • -
  • Import Existing Project: Select a directory containing Git projects
  • -
-
- -
-

- 2 - Add a Project -

-

Add Git projects to your workspace using any of these three methods:

-
- - - - - - - - - - - - - - - - - - - - - - - - - -
MethodFormatExample
GitHub Shorthandowner/repofacebook/react
SSHgit@host:owner/repo.gitgit@github.com:facebook/react.git
HTTPShttps://host/owner/repo.githttps://github.com/facebook/react.git
-
-
- -
-

- 3 - Create a Worktree -

-

Click the + button in the sidebar and follow the wizard:

-
    -
  1. Enter a branch name (e.g. feature/login)
  2. -
  3. Choose which branch to base it on (defaults to main)
  4. -
  5. Select the projects to include
  6. -
  7. Configure folders to link (e.g. node_modules)
  8. -
  9. Click "Create" and wait for it to finish
  10. -
-
- -
-

- 4 - Start Developing -

-

From the Worktree list:

-
    -
  • Click Open IDE: opens in VS Code / Cursor / IDEA
  • -
  • Click Terminal: run commands in the built-in terminal
  • -
  • Click the folder icon: open in your file manager
  • -
-
-
-
- - -
-

- 3 - Workspace Management -

- -
-

Directory Structure

-
workspace/
-├── .worktree-manager.json    # Workspace configuration
-├── projects/                 # Main repository directory
-│   ├── frontend/             # Git project (main branch)
-│   └── backend/
-├── worktrees/                # Worktree directory
-│   ├── feature-login/
-│   │   ├── projects/
-│   │   │   ├── frontend/     # Independent branch working directory
-│   │   │   └── backend/
-│   │   ├── .claude → ../../.claude    # Symlink
-│   │   └── CLAUDE.md → ../../CLAUDE.md
-│   └── hotfix-bug/
-│       └── ...
-├── .claude/                  # Globally shared files
-└── CLAUDE.md
-
- -
-

Global File Sharing

-

Set linked_workspace_items in the workspace config to automatically symlink these files/folders to all Worktrees:

-
{
-  "linked_workspace_items": [
-    ".claude",
-    "CLAUDE.md",
-    "requirement-docs"
-  ]
-}
-

Ideal for AI development configs, requirement docs, and other resources shared across branches.

-
-
- - -
-

- 4 - Worktree Operations -

- -
-
-

🔄 Switch Branches

-

Click a different Worktree in the list to switch -- no git checkout needed

-
-
-

📊 Status Monitoring

-

Real-time display of commit count, uncommitted changes, and test branch merge status

-
-
-

🗑️ Archive Worktree

-

Archive when done -- checks for uncommitted/unpushed code first to prevent data loss

-
-
-

♻️ Restore Archive

-

One-click restore of previously archived Worktrees from the archive list

-
-
-
- - -
-

- 5 - Multi-Window & Workspace Switching -

- -
-
-

Multi-Window Workflow

-

Each Workspace can be opened in its own independent window, letting you work on multiple workspaces simultaneously without interference.

-
    -
  • In the sidebar Workspace list, click the external link icon (↗️) next to a Workspace name to open it in a new window
  • -
  • Each window runs independently -- you can view and operate different Workspaces at the same time
  • -
  • When the main window closes, child windows close as well
  • -
-
- -
-

Window Title

-

The window title dynamically shows the current workspace and branch info in this format:

-
- {WorkspaceName} - {WorktreeName} -
-

This makes it easy to tell windows apart when working with multiple workspaces.

-
- -
-

Worktree Locking

-

When a Worktree is already open in one window, it shows as "occupied" in other windows.

-
    -
  • An occupied Worktree cannot be operated on from another window, preventing conflicts
  • -
  • Switching to a different Worktree automatically releases the lock on the current one
  • -
  • Closing a window automatically releases all locks held by that window
  • -
-
-
-
- - -
-

- 6 - Terminal -

- -
-
-

Built-in Terminal

-

Each Worktree has its own built-in terminal with the working directory automatically set to the Worktree root. No need to cd manually.

-
    -
  • The terminal panel is at the bottom of the app and can be resized by dragging
  • -
  • Switching Worktrees automatically switches to the corresponding terminal session
  • -
  • Terminal sessions persist throughout the Worktree lifecycle and won't be lost when switching
  • -
-
- -
-

Multiple Tabs

-

Each Worktree can have multiple terminal tabs:

-
    -
  • Click the + button in the terminal panel to create a new tab
  • -
  • Click a tab to switch between terminal sessions
  • -
  • Click the x button on a tab to close that terminal
  • -
  • Each tab runs as an independent terminal process
  • -
-
- -
-

Fullscreen Mode

-

The terminal supports fullscreen mode for when you need more space:

-
    -
  • Click the maximize icon in the top-right corner of the terminal panel to enter fullscreen
  • -
  • Press Escape to exit fullscreen mode
  • -
  • In fullscreen mode, the terminal fills the entire application window
  • -
-
- -
-

External Terminal

-

If you prefer your system's native terminal, click the "Open in External Terminal" button in the terminal panel. It will open your default terminal app and navigate to the Worktree directory automatically.

-
-
-
- - -
-

- 7 - Voice Input -

- -
-
-

Overview

-

Voice input is powered by Alibaba Cloud Dashscope Paraformer real-time speech recognition. Speak into your microphone and your speech is transcribed in real time and injected into the current terminal. Great for hands-free scenarios or quick command entry.

-

Two operation modes are supported:

-
    -
  • Push-to-Talk: Hold Alt+V to speak, release to stop recording and transcribe
  • -
  • Click to Toggle: Click the microphone icon in the terminal panel to enter standby mode, then hold Alt+V to speak
  • -
-
- -
-

Configuring Dashscope

-

Before using voice input, you need to configure a Dashscope API Key:

-
    -
  1. Open the Settings page (gear icon)
  2. -
  3. Scroll to the "Speech Recognition (Dashscope)" section
  4. -
  5. Go to the Dashscope Console to get your API Key
  6. -
  7. Paste the API Key into the input field and click "Save"
  8. -
  9. Click the "Test Connection" button to verify the configuration
  10. -
-
-

Dashscope Paraformer supports mixed Chinese-English recognition with fast speed and high accuracy. Works on all desktop platforms (macOS / Windows / Linux).

-
-
- -
-

Microphone Settings

-

In the Speech Recognition section of Settings, you can manage microphone devices:

-
    -
  • Select Microphone: A dropdown lists all available audio input devices. You can choose a specific microphone (e.g. external mic, headset mic). Defaults to the system default device
  • -
  • Microphone Test: Click the "Test" button to see a real-time volume meter. Speak into the mic to confirm it's working. The test stops automatically after 10 seconds
  • -
  • Connection Test: Click "Test Connection" to verify the API Key and WebSocket address are configured correctly. A green "Connection Successful" message appears on success
  • -
-
-

💡 Tip: The system will prompt for microphone permission on first use -- click "Allow". If you accidentally denied it, macOS users can go to System Settings → Privacy & Security → Microphone to enable it manually.

-
-
- -
-

Usage Flow

-
    -
  1. Click the microphone icon in the top-right of the terminal panel to enter standby mode (icon turns blue, microphone is active)
  2. -
  3. Hold Alt+V to start speaking -- connects to Dashscope and streams audio in real time
  4. -
  5. Release Alt+V to stop recording -- the transcription is automatically typed into the terminal
  6. -
  7. You can press and release repeatedly for multiple voice inputs while the mic stays in standby
  8. -
  9. Click the microphone icon again to exit standby mode and release the microphone
  10. -
-
- # State flow
- Idle [Click mic] Standby [Hold Alt+V] Recording [Release] Standby -
-
- -
-

Voice Commands

-

In addition to regular text transcription, the following voice command keywords trigger terminal actions when spoken:

-
- - - - - - - - - - - - - - - - - - - - - - - - - -
Voice KeywordTerminal Action
submit / enterPress Enter
backspace / deleteDelete previous character (Backspace)
clearInterrupt current input (Ctrl+C)
escapeSend ESC key
-
-

Example: Hold Alt+V and say git status, release, then hold again and say enter -- this types git status in the terminal and presses Enter to execute.

-
- -
-

AI Voice Refinement

-

Voice transcriptions often contain filler words ("um", "uh", "like", "you know") and grammatical errors. The AI Voice Refinement feature integrates the Qwen large language model to automatically polish transcribed text:

-
    -
  • Remove Filler Words: Automatically detects and removes meaningless words like "um", "uh", "like", etc.
  • -
  • Grammar Correction: Converts colloquial expressions into proper command format
  • -
  • Preserve Intent: The refinement process keeps the original meaning intact without changing command semantics
  • -
-
- # Voice refinement example
- Raw transcription: um so like git status just to check
- Refined result: git status -
-
-

💡 Configuration: In the Speech Recognition section of Settings, configure a Qwen API Key (via the Dashscope platform) to enable AI refinement. The refinement feature can be toggled on/off independently.

-
-
- -
-

Mobile Voice Input

-

In browser remote mode (accessing from phone/tablet), the terminal panel provides a dedicated push-to-talk button:

-
    -
  • Long-press to Talk: Long-press the microphone button at the bottom of the terminal to start recording, release to stop
  • -
  • Touch Optimized: Button size and interactions are optimized for touchscreens -- operable with one hand
  • -
  • AI Refinement Included: Mobile voice input also goes through AI refinement processing
  • -
-
-
-
- - -
-

- 8 - IDE Integration -

- -
-
-

Supported IDEs

-

Worktree Manager supports a variety of popular IDEs and editors:

-
-
- VS Code - code -
-
- Cursor - cursor -
-
- IntelliJ IDEA - idea -
-
- Zed - zed -
-
- Sublime Text - subl -
-
- Windsurf - windsurf -
-
-
- -
-

Switch Default IDE

-

In the Worktree detail view, click the dropdown arrow next to the IDE button to see the IDE selection list:

-
    -
  • Selecting an IDE makes it the default for the current project
  • -
  • Next time you click the IDE button, it will open with the last selected IDE
  • -
  • Different projects can have different default IDEs
  • -
-
- -
-

Quick Open

-

In the IDE dropdown menu, each IDE option has a quick-open icon (↗️) on the right:

-
    -
  • Clicking the icon opens the project with that IDE without changing the default IDE setting
  • -
  • Handy for occasionally opening with a different IDE
  • -
-
- -
-

Open in File Manager

-

At the bottom of the IDE dropdown menu, there's an "Open in File Manager" option that opens the Worktree's project directory in your system file manager (Finder / File Explorer).

-
-
-
- - -
-

- 9 - Smart Folder Scan -

- -
-
-

Overview

-

Smart Folder Scan automatically detects large folders in your project that are good candidates for linking, helping you quickly configure linked_folders.

-

Once linked, newly created Worktrees will automatically symlink these folders from the main repository -- no need to reinstall dependencies or rebuild, saving significant disk space and time.

-
- -
-

How to Use

-
    -
  1. Go to the Workspace Settings page
  2. -
  3. Find the Linked Folders configuration for your project
  4. -
  5. Click the scan button (magnifying glass icon)
  6. -
  7. The system will automatically scan the project directory and list all linkable folders
  8. -
  9. Check the folders you want to link and confirm
  10. -
-
- -
-

Auto-Detected Folder Types

-

The scan automatically identifies these common large folders:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FolderPurpose
node_modulesNode.js dependencies
.nextNext.js build artifacts
distGeneral build output
buildBuild output
vendorPHP / Go dependencies
.outputNuxt 3 build output
.nuxtNuxt.js build artifacts
targetRust / Java build output
-
-
-
-
- - -
-

- 10 - Browser Sharing (LAN / ngrok) -

- -
-
-

Overview

-

Browser sharing lets you share your current workspace with colleagues over a browser. They do not need to install anything: open the link, enter the sharing password, then view your Worktrees and use the browser terminal.

-
-

Use Cases:

-
    -
  • Pair debugging -- colleagues inspect your terminal and workspace state through the browser
  • -
  • Code demos -- let PMs or QA view your branch status in the browser
  • -
  • Remote assistance -- hand over a LAN or ngrok link for quick troubleshooting access
  • -
  • Mobile access -- check workspace status from a tablet or phone browser on the go
  • -
-
-

Sharing supports two modes: LAN direct connection (same Wi-Fi / intranet) and ngrok tunneling (using the ngrok service). Both modes use the same browser password gate.

-
- -
-

Enable Sharing

-

In the desktop app, follow these steps to enable sharing:

-
    -
  1. Find the "Share" button at the bottom of the sidebar and click to open the sharing panel
  2. -
  3. Set the port number (recommended range: 49152-65535, also supports dev ports 3000-9999)
  4. -
  5. Set an access password (required, protects your workspace)
  6. -
  7. Click "Start Sharing" -- the system will start a built-in HTTP server
  8. -
  9. Once sharing is active, a LAN access URL will be displayed (e.g. http://192.168.1.100:49152)
  10. -
-
- # Example URL after sharing is enabled
- LAN Address: http://192.168.1.100:49152
- # Share this address and password with your colleague -
-
- -
-

Browser Mode Access

-

When a collaborator opens the shared link:

-
    -
  1. The browser shows a password entry page -- enter the password you set to gain access
  2. -
  3. Once inside, they see the same interface as the desktop app, including the Worktree list and branch status
  4. -
  5. They can use the built-in terminal (streamed in real time via WebSocket)
  6. -
  7. They can use the browser terminal and share the same terminal session as the desktop user
  8. -
  9. All operations sync in real time via WebSocket with minimal latency
  10. -
-
-

⚠️ Note: Terminal operations from the browser directly affect files on your machine. Only share with people you trust.

-
-
- -
-

ngrok Internet Tunneling

-

If your collaborator isn't on the same LAN, you can use ngrok for internet tunneling to access via a public URL:

- -

Step 1: Install ngrok and Get a Token

-
    -
  1. Go to ngrok.com and create a free account
  2. -
  3. Copy your Authtoken from the ngrok Dashboard
  4. -
- -

Step 2: Configure the Token in the App

-
    -
  1. Open the Settings page (gear icon)
  2. -
  3. Scroll to the bottom and find the "Internet Sharing (ngrok)" section
  4. -
  5. Paste the Authtoken into the input field and click "Save"
  6. -
- -

Step 3: Enable the Internet Tunnel

-
    -
  1. First enable LAN sharing as described above
  2. -
  3. In the sharing panel, click the "Enable Internet" button
  4. -
  5. Wait for the ngrok tunnel to establish (usually takes a few seconds)
  6. -
  7. The generated public URL (e.g. https://xxxx.ngrok-free.app) can be shared with anyone
  8. -
- -
- # ngrok public URL example
- Public URL: https://a1b2c3d4.ngrok-free.app
- # Anyone with internet access can reach this URL -
-
- -
-

QR Code Sharing

-

Once sharing is enabled, the sharing panel automatically generates a QR code for the current share URL:

-
    -
  • Scan to Access: Scan the QR code with your phone/tablet to open the workspace directly in a mobile browser
  • -
  • Embedded Password: The QR code link can embed the access password (via URL fragment), so scanning auto-authenticates without manual password entry
  • -
  • All Modes Supported: LAN and ngrok modes both support QR code generation
  • -
-
- -
-

Client Management

-

The sharing panel lets you view and manage all connected clients in real time:

-
    -
  • Connection List: Shows all connected browser clients with IP address, connection time, and other details
  • -
  • Kick Session: You can kick individual client sessions -- kicked users must re-enter the password to reconnect
  • -
  • Password Update: Changing the password automatically kicks all connected clients
  • -
-
- -
-

Security Considerations

-
-
- -
-

Password Protection

-

All shares require a password. Browser clients must authenticate before accessing any API

-
-
-
- -
-

Brute-Force Protection

-

Built-in rate limiting: max 5 authentication attempts per IP per 60 seconds

-
-
-
- -
-

Session Isolation

-

Each browser client gets a unique server-generated session ID that cannot be forged

-
-
-
- -
-

CORS Restrictions

-

Only allows cross-origin requests from localhost, LAN IPs, and the current ngrok URL

-
-
-
- -
-

Full Control

-

The sharer can change the password at any time (auto-kicks all connected users), close the internet tunnel, or stop sharing entirely

-
-
-
-
-

⚠️ Important: Sharing exposes your workspace files and terminal. Use a strong password and only share with people you trust. Be extra cautious with ngrok internet mode, as anyone with the link and password can access your workspace. Always disable sharing when not in use.

-
-
- -
-

Sharing Troubleshooting

-
-
- Colleague can't access LAN share? -
-
    -
  • Confirm both parties are on the same LAN / Wi-Fi
  • -
  • Check if the firewall allows the port (macOS may show a firewall prompt -- click "Allow")
  • -
  • Try testing connectivity with curl http://your-ip:port/api/get_share_info
  • -
  • If using a corporate VPN, network isolation may be the issue -- try an ngrok share URL instead
  • -
-
-
-
- ngrok tunnel fails to start? -
-
    -
  • Check if the ngrok token is configured correctly (bottom of Settings page)
  • -
  • Confirm your network can reach the internet (ngrok needs to connect to its servers)
  • -
  • Free ngrok has a simultaneous tunnel limit -- make sure no other tunnels are running
  • -
  • If it times out, try again -- ngrok can occasionally be slow to connect
  • -
-
-
-
- Browser terminal not responding? -
-
    -
  • Check WebSocket connection status (Browser DevTools → Network → WS)
  • -
  • If using ngrok, confirm the ngrok tunnel is still running
  • -
  • Try refreshing the page to reconnect
  • -
-
-
-
- Port is already in use? -
-

Simply switch to a different port number. Ports in the 49152-65535 range are recommended -- these are dynamic/private ports unlikely to be used by other programs.

-
# Check port usage (macOS/Linux)
-lsof -i :49152
-
-
-
-
-
-
- - -
-

- 11 - Configuration -

- -
-
-

Global Configuration

-

Location: ~/.config/worktree-manager/global.json

-
{
-  "workspaces": [
-    { "name": "My Project", "path": "/path/to/workspace" }
-  ],
-  "current_workspace": "/path/to/workspace",
-  "ngrok_token": "2abc...xyz",
-  "last_share_port": 49152
-}
-
-

ngrok_token: Optional. ngrok Authtoken for internet tunneling

-

last_share_port: Optional. Last used sharing port, auto-filled next time

-
-
- -
-

Workspace Configuration

-

Location: {workspace}/.worktree-manager.json

-
{
-  "name": "My Project",
-  "worktrees_dir": "worktrees",
-  "linked_workspace_items": [".claude", "CLAUDE.md"],
-  "projects": [
-    {
-      "name": "frontend",
-      "base_branch": "main",
-      "test_branch": "test",
-      "merge_strategy": "merge",
-      "linked_folders": ["node_modules", ".next"]
-    }
-  ]
-}
-
-

worktrees_dir: Directory where Worktrees are stored

-

linked_workspace_items: Globally shared files

-

linked_folders: Project-level folder linking

-

merge_strategy: Merge strategy (merge or rebase)

-
-
- -
-

Git Commit Configuration

-

In "Settings → Commit Settings", you can configure commit prefix templates and Git user info.

- -
-
-

Commit Prefix Templates

-

Supports up to 3 prefix templates, automatically prepended to the commit message. Variable substitution is supported:

-
    -
  • {{worktree-name}} — Current worktree name
  • -
  • {{project-name}} — Project name
  • -
  • {{branch-name}} — Current branch name
  • -
  • {{repo-name}} — Repository name
  • -
  • {{date:YYYY-MM-DD}} — Date (supports YYYY, MM, DD, HH, mm formats)
  • -
-

Example: [{{worktree-name}}] {{project-name}} at {{date}} renders as [feature-123] frontend at 2026-04-19

-

Switch templates from the dropdown, click the star icon to set the default. The "None" option disables the prefix.

-
- -
-

Git User Configuration

-

Supports global and per-project configuration to avoid committing with the wrong username/email.

-
    -
  • Global: Configure in "Settings → Commit Settings", applies to all projects
  • -
  • Per-project: Configure for each project in "Settings → Projects", leave empty to inherit global
  • -
  • When committing, the corresponding user.name and user.email are automatically written to the repository
  • -
-
-
-
-
-
- - -
-

- 12 - Keyboard Shortcuts -

- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ShortcutActionContext
EscapeExit terminal fullscreenTerminal fullscreen mode
EscapeClose dropdown menu / dialogAny menu or dialog open
Alt+V (hold)Push-to-talk, release to stopMicrophone in standby mode
Right-clickOpen Worktree context menuOn a Worktree list item
-
-
-
- - -
-

- 13 - Best Practices -

- -
-
-

✅ Recommended

-
    -
  • • Create a separate Worktree for each feature or task
  • -
  • • Use consistent branch naming conventions (e.g. feature/xxx, hotfix/xxx)
  • -
  • • Regularly archive merged Worktrees to keep the list clean
  • -
  • • Add frequently used dependency directories to linked_folders
  • -
  • • Use the built-in terminal to avoid path confusion
  • -
-
- -
-

⚠️ Avoid

-
    -
  • • Don't manually delete Worktree directories -- use the archive feature instead
  • -
  • • Don't modify the same file in multiple Worktrees simultaneously (linked files excepted)
  • -
  • • Don't link files that may cause conflicts (e.g. .env)
  • -
  • • Avoid creating too many Worktrees (10 or fewer recommended)
  • -
-
-
-
- - -
-

- 14 - Troubleshooting -

- -
-
- Q: macOS says the app is "damaged" or "cannot verify the developer"? -
-

This happens because the app isn't notarized by Apple, so macOS security blocks it. Follow these steps:

-
    -
  1. Right-click the app icon and select "Open" (don't double-click)
  2. -
  3. If it still won't open, go to System Settings → Privacy & Security, find the blocked app prompt at the bottom, and click "Open Anyway"
  4. -
  5. If none of the above works, open Terminal and run:
  6. -
-
xattr -cr "/Applications/Worktree Manager.app"
-

Then reopen the app. This command removes the quarantine attribute from the file.

-
-
- -
- Q: Worktree creation failed? -
-

Possible causes:

-
    -
  • Branch name already exists
  • -
  • Git repository has uncommitted changes
  • -
  • Insufficient disk space
  • -
-

Solution: Check the error message, ensure the main repository is in a clean state, or manually run git worktree add to see the detailed error.

-
-
- -
- Q: Symlinks broken / files not found? -
-

Check that the linked_folders configuration is correct and that the corresponding folders exist in the main repository. Run in terminal:

-
ls -la worktrees/your-worktree/
-

Check if the links are intact. If broken, you can delete and recreate the Worktree.

-
-
- -
- Q: How to open a workspace in a new window? -
-

In the sidebar Workspace list, hover over a Workspace name and an external link icon (↗️) will appear on the right.

-

Click the icon to open that workspace in a new window. The new window is independent and won't affect the main window.

-
-
- -
- Q: How to make the terminal fullscreen? -
-

Click the maximize icon in the top-right corner of the terminal panel to enter fullscreen mode.

-

Press Escape to exit fullscreen and return to the normal layout.

-
-
- -
- Q: Which folders should I link? -
-

Here are commonly recommended folders to link, by project type:

-
    -
  • node_modules -- Node.js / frontend project dependencies
  • -
  • .next -- Next.js build cache
  • -
  • dist -- General build output directory
  • -
  • build -- React / other framework build output
  • -
  • vendor -- PHP Composer / Go dependencies
  • -
  • .output -- Nuxt 3 build output
  • -
  • .nuxt -- Nuxt.js build cache
  • -
  • target -- Rust Cargo / Java Maven build output
  • -
-
-

💡 Tip: You can also use the Smart Folder Scan feature for automatic detection. See Section 9.

-
-
-
- -
- Q: How to sync workspaces across multiple computers? -
-

The workspace configuration is stored in .worktree-manager.json and can be added to version control:

-
git add .worktree-manager.json
-git commit -m "Add worktree config"
-git push
-

After pulling on another computer, the workspace will automatically recognize the configuration.

-
-
- -
- Q: Can archived Worktrees be restored? -
-

Yes! Find the Worktree in the archive list and click "Restore". Note that the branch state may differ from when it was archived (if the remote has been updated).

-
-
- -
- Q: Which operating systems are supported? -
-

Currently supported:

-
    -
  • ✅ macOS (Intel + Apple Silicon)
  • -
  • ✅ Linux (Ubuntu, Debian, Arch, etc.)
  • -
  • ⚠️ Windows (experimental support, symlinks require administrator privileges)
  • -
-
-
-
-
- -
- - -
-

Still Have Questions?

-

Feel free to open an Issue or join the discussion on GitHub

- -
- -
-
- - - - - - - - diff --git a/docs/en/index.html b/docs/en/index.html deleted file mode 100644 index 513661b..0000000 --- a/docs/en/index.html +++ /dev/null @@ -1,992 +0,0 @@ - - - - - - Git Worktree Manager - Multi-Branch Parallel Development Tool - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Worktree Manager -

Git Worktree Manager

-

An elegant Git Worktree visual management tool for efficient multi-branch parallel development. Supports secure browser sharing over LAN or ngrok.

- -
- - -
-
-

Download & Install

-

Ready to use, with auto-updates

- - - - - -
-
- - macOS (Intel + ARM) -
-
- - Windows -
-
- - Linux -
-
- - -
- - Built-in auto-update -- new versions are pushed automatically, no manual downloads needed -
- - -
-
-

- - macOS Installation Guide -

-
-
-
1
-

Download the .dmg file, open it and drag the app into the Applications folder.

-
-
-
2
-

If you see "cannot verify the developer" on first launch, right-click the app and select "Open".

-
-
-
3
-

If it still won't open, go to System Settings → Privacy & Security, find the prompt at the bottom and click "Open Anyway".

-
-
-
4
-
-

If none of the above works, open Terminal and run:

- xattr -cr "/Applications/Worktree Manager.app" -
-
-
-
-
-
-
- - -
-
-

Sound familiar?

-

Productivity black holes every developer has experienced

-
- - -
-
-
🔥 Production is on fire, but your work isn't committed yet
-
-

You're on feature/checkout-v2 refactoring the checkout flow, with a dozen files changed and npm run dev hot-reloading. Slack fires off an alert: payment callback is returning 500 in production.

-

The traditional approach: git stash → switch to hotfixnpm install (dependency versions differ, need to reinstall) → fix and push → switch back → git stash pop → pray there are no conflicts → restart dev server and wait for build cache to rebuild. At least 15 minutes, while production is still down.

-
-
-
-

Traditional Approach

-
- git stash - checkout hotfix - npm install - fix bug - push - checkout back - stash pop - npm install -
-
8 steps · 15+ minutes · possible conflicts
-
-
-

Worktree Manager

-
- Click "+" to create hotfix - fix bug - push - archive -
-
4 steps · 30-second switch · zero impact on original branch
-
-
-
-

node_modules automatically shared via symlink, ready in seconds. Your feature branch dev server keeps running, and your in-progress code stays untouched.

-
-
-
- - -
-
-
💥 Frontend-backend alignment breaks during debugging
-
-

Your project has separate repos: web and api. Working on the "membership" feature, both repos need to be on feature/membership. But a colleague asks you to check their feature/search issue—you switch the frontend but forget the backend. White screen, 404 errors, 30 minutes of debugging before realizing the branches are misaligned.

-
-
-
-

Traditional: manually align two repos

-
- cd web && git checkout - cd api && git checkout - forgot one? - 404 / white screen - debug 30 min -
-
Multi-repo alignment by memory · bound to fail
-
-
-

Worktree Manager: one worktree = one environment

-
- Create membership worktree - web + api auto checkout - develop -
-
Switch worktree = switch entire work environment
-
-
-
-

One worktree binds multiple project repos. When created, all repos check out to the corresponding branch simultaneously. No more "only switched half" problems.

-
-
-
- - -
-
-
🔄 Merge to test branch through muscle memory
-
-

Feature is done, time to merge into test for QA. Every time: git checkout testgit pullgit merge feature/xxx → resolve conflicts → git pushgit checkout feature/xxx back. With 3-4 features a day, this becomes mind-numbing repetition, and sometimes you forget to switch back and keep developing on test.

-
-
-
-

Traditional Approach

-
- checkout test - git pull - git merge - resolve conflicts - git push - checkout back -
-
6 steps · repeat for every merge · easy to forget switching back
-
-
-

Worktree Manager

-
- Click "Merge to test" -
-
1 step · stay on current branch · real-time status visible
-
-
-
-

Each project card has "Merge to test", "Sync base", and "Push" buttons right below. Branch status (commits ahead/behind, whether merged to test) updates in real-time at a glance.

-
-
-
- - -
-
-
🌐 Away from office, want to check code on your work machine
-
-

Your dev machine is on the office network. While traveling, you want to check code status or run a few terminal commands. Traditional solutions mean either SSH tunnels (tedious setup) or VPN + remote desktop (laggy).

-
-
-
-

Traditional Approach

-
- configure VPN - remote desktop - lag & latency -
-
Complex setup · laggy display · depends on company network
-
-
-

Worktree Manager

-
- Click "Share" - Send link - Open in browser -
-
3 steps · LAN or ngrok access · password protected
-
-
-
-

Enable sharing for LAN access, or use ngrok for public internet access. Open in any browser, authenticate with the share password, and view workspace status or use the built-in terminal—no client installation needed.

-
-
-
- -
-
-
- - -
-
-

How Git Worktree Manager solves these problems?

-

- Built on Git's native worktree capability, it checks out multiple branches into independent directories within the same repository, sharing the .git data. Combined with automatic symlinking of node_modules and other large folders, it enables zero-cost switching with zero extra disk usage. -

-
-
- - -
-
-

Core Features

-

Built for multi-branch development

-
-
-
🔀
-

Multi-Branch Parallel Work

-

Work on multiple branches simultaneously in one project, without interference. No stashing, no cloning multiple copies.

-
-
-
🔗
-

Smart Folder Linking

-

Automatically links node_modules, .next, vendor and other build artifacts to avoid redundant dependency installations. Supports custom paths.

-
-
-
📂
-

Global File Sharing

-

Files like .claude, CLAUDE.md, requirement-docs can be configured as global links, shared across all worktrees.

-
-
-
📊
-

Branch Status Monitoring

-

Real-time display of commit counts, uncommitted changes, test branch merge status, and more at a glance.

-
-
-
🚀
-

Quick IDE Launch

-

Open any worktree with VS Code, Cursor, IntelliJ IDEA and more in one click. Supports dropdown quick-switch between editors.

-
-
-
💻
-

Built-in Terminal

-

Each worktree has independent terminal sessions with multi-tab, tab duplication, and fullscreen support. Terminal state auto-saves when switching worktrees.

-
-
-
📦
-

Safe Archiving

-

Automatically checks for uncommitted and unpushed code before archiving to prevent data loss. Supports one-click restore.

-
-
-
🔌
-

Multiple Clone Methods

-

Add projects via GitHub shorthand, SSH, or HTTPS.

-
-
-
🪟
-

Multi-Window Support

-

Each workspace can open in an independent window, allowing simultaneous operation of multiple workspaces without interference.

-
-
-
🔍
-

Smart Folder Scan

-

Automatically scans for large folders in your project that can be linked (like node_modules, .next, dist), and adds them to link configuration with one click.

-
-
-
⌨️
-

Keyboard Shortcuts

-

Escape to exit fullscreen or close menus, terminal fullscreen mode, and more for efficient operation.

-
-
-
🔄
-

Auto Update

-

Built-in auto-update pushes new version notifications and upgrades to the latest version with one click.

-
-
-
-

Background Remote Sync

-

Git remote status refreshes in the background while local data loads instantly. Branch switching stays smooth, action buttons disable as needed, and sync progress is visible in real-time.

-
-
-
🎙️
-

Voice Input macOS

-

Speak into the microphone and voice is automatically transcribed to text in the terminal. Supports voice commands (enter, delete, clear, etc.), 2-second silence auto-stop, fully local processing.

-
-
-
🧠
-

AI Voice Refinement NEW

-

AI-powered post-processing for voice input. Automatically corrects recognition errors, normalizes command syntax, and intelligently interprets developer intent for more accurate terminal input.

-
-
-
📱
-

QR Code Sharing NEW

-

Generate a QR code for instant mobile access to your shared workspace. Scan and connect from any device—perfect for quick demos and on-the-go collaboration.

-
-
-
🌐
-

Browser Sharing

-

Share your workspace with one click. Colleagues access it through a browser. Supports LAN direct and ngrok tunneling, with password protection and a browser terminal.

-
-
-
-
- - -
-
-
-

Browser Sharing (LAN / ngrok)

-

No installation needed, just open the shared workspace in a browser

-
-
-
-
📡
-

LAN Direct

-

Click "Share" on the desktop app to generate a password-protected link. Colleagues on the same network can open it in their browser to see your workspace—no software installation required.

-
-
-
🌍
-

ngrok Tunnel

-

Not on the same network? Configure an ngrok token and enable internet tunneling with one click to generate a public address for remote access anytime, anywhere.

-
-
-
📱
-

QR Code Sharing

-

Generate a QR code for any sharing link. Scan with your phone or tablet for instant access—perfect for demos, quick checks, and cross-device collaboration.

-
-
-
🔑
-

Password-embedded Links

-

Share links with the password embedded in the URL hash. Recipients connect instantly without manual password entry—secure and frictionless.

-
-
-
👩‍💻
-

Client Management

-

Monitor connected clients in real-time, view session details, and kick users when needed. Full control over who accesses your shared workspace with built-in browser terminal.

-
-
- -
-
- - -
-
-

Three Steps to Start

-

Get started with multi-branch parallel development in minutes

-
-
-
1
-
-

Create a Workspace

-

After launching the app, click the "New Workspace" button in the top-left corner and select a directory as the workspace root. The app will automatically initialize the directory structure and detect Git projects within it.

-
    -
  • New workspace: Select an empty directory, and the app will automatically create projects/ and worktrees/ directories
  • -
  • Import existing projects: Select a directory containing Git projects, and the app will auto-detect and import them
  • -
  • You can also add new projects via GitHub shorthand (e.g., owner/repo), SSH, or HTTPS URLs
  • -
-
-
-
-
2
-
-

Create a Worktree (Working Branch)

-

Click the "+" button in the sidebar to open the dialog and configure:

-
    -
  • Enter a branch name (e.g., feature/login), supporting feature/, hotfix/ naming conventions
  • -
  • Select which projects to include (multi-select)
  • -
  • Choose a base branch (defaults to main)
  • -
  • node_modules and other configured folders are automatically linked from the main repo—no need to reinstall dependencies
  • -
-
-
-
-
3
-
-

Start Developing

-

Click to switch between worktrees in the list, and open any with your favorite IDE in one click. Each branch's code, dependencies, and terminal are fully isolated.

-
    -
  • Click the IDE button to quickly open projects in VS Code, Cursor, IDEA, etc.
  • -
  • Use the built-in terminal to run commands without switching windows
  • -
  • Monitor branch status in real-time: commit counts, unpushed changes, test branch merge status
  • -
  • Archive worktrees when done to keep your workspace tidy
  • -
-
-
-
- - -
-

Workspace Directory Structure

-
-
-workspace/
-├── .worktree-manager.json        # Workspace configuration
-├── projects/                     # Main repos (main branch)
-│   ├── frontend/
-│   └── backend/
-├── worktrees/                    # Worktree directory (auto-created)
-│   ├── feature-login/
-│   │   ├── projects/
-│   │   │   ├── frontend/         # Independent branch working directory
-│   │   │   └── backend/
-│   │   ├── .claude → ../../.claude   # Auto-linked
-│   │   └── CLAUDE.md → ../../CLAUDE.md
-│   └── hotfix-bug/
-│       └── ...
-├── .claude/
-└── CLAUDE.md
-
-
- - -
- - 🛠️ Build from Source (for developers) - -
-# Requirements: Node.js 20+, Rust 1.70+, Git 2.0+ - -# Clone the project -git clone https://github.com/guoyongchang/worktree-manager.git -cd worktree-manager - -# Install dependencies -npm install - -# Run in development mode -npm run tauri dev - -# Build for production -npm run tauri build -
-
-
-
- - -
-
-

Tech Stack

-

Built with modern technology

-
-
- Framework - Tauri 2 -
-
- Frontend - React 19 -
-
- Languages - TypeScript + Rust -
-
- Styling - Tailwind CSS 4 -
-
- UI Components - Radix UI -
-
- Build Tool - Vite 7 -
-
-
-
- - - - - - - - diff --git a/docs/generated/command-contracts.md b/docs/generated/command-contracts.md deleted file mode 100644 index dcab061..0000000 --- a/docs/generated/command-contracts.md +++ /dev/null @@ -1,184 +0,0 @@ -# Command Contracts - -Generated on 2026-06-11T02:38:38.085Z. - -This file is generated by `scripts/command-contracts.mjs`. -Route scanning includes both `src-tauri/src/http_server.rs` and `src-tauri/src/http_server/routing.rs`. - -## Summary - -- Frontend `callBackend()` usages: 131 -- backend.ts direct HTTP endpoints: 3 -- Tauri IPC commands: 135 -- HTTP API routes: 141 - -## Mirrored Command Matrix - -| Command | Frontend | IPC | HTTP | Method | Handler | -| --- | --- | --- | --- | --- | --- | -| `add_existing_project` | yes | yes | yes | POST | `h_add_existing_project` | -| `add_project_to_worktree` | yes | yes | yes | POST | `h_add_project_to_worktree` | -| `add_workspace` | yes | yes | yes | POST | `h_add_workspace` | -| `archive_worktree` | yes | yes | yes | POST | `h_archive_worktree` | -| `broadcast_terminal_state` | yes | yes | yes | POST | `h_broadcast_terminal_state` | -| `check_commit_ai_api_key` | yes | yes | yes | POST | `h_check_commit_ai_api_key` | -| `check_dashscope_api_key` | yes | yes | yes | POST | `h_check_dashscope_api_key` | -| `check_mirror_update` | yes | yes | yes | POST | `h_check_mirror_update` | -| `check_remote_branch_exists` | yes | yes | yes | POST | `h_check_remote_branch_exists` | -| `check_worktree_status` | yes | yes | yes | POST | `h_check_worktree_status` | -| `clone_project` | yes | yes | yes | POST | `h_clone_project` | -| `cloud_approve_pairing` | yes | yes | yes | POST | `h_cloud_approve_pairing` | -| `cloud_check_pairing_status` | yes | yes | yes | POST | `h_cloud_check_pairing_status` | -| `cloud_disconnect` | yes | yes | yes | POST | `h_cloud_disconnect` | -| `cloud_get_status` | yes | yes | yes | POST | `h_cloud_get_status` | -| `cloud_reject_pairing` | yes | yes | yes | POST | `h_cloud_reject_pairing` | -| `cloud_start_pairing` | yes | yes | yes | POST | `h_cloud_start_pairing` | -| `commit_all` | yes | yes | yes | POST | `h_commit_all` | -| `create_pull_request` | yes | yes | yes | POST | `h_create_pull_request` | -| `create_workspace` | yes | yes | yes | POST | `h_create_workspace` | -| `create_worktree` | yes | yes | yes | POST | `h_create_worktree` | -| `delete_archived_worktree` | yes | yes | yes | POST | `h_delete_archived_worktree` | -| `deploy_to_main` | yes | yes | yes | POST | `h_deploy_to_main` | -| `detect_tools` | yes | yes | yes | POST | `h_detect_tools` | -| `download_update_via_mirror` | yes | yes | yes | POST | `h_download_update_via_mirror` | -| `exit_main_occupation` | yes | yes | yes | POST | `h_exit_main_occupation` | -| `fetch_project_remote` | yes | yes | yes | POST | `h_fetch_project_remote` | -| `frontend_log` | | yes | yes | POST | `h_frontend_log` | -| `generate_commit_message` | yes | yes | yes | POST | `h_generate_commit_message` | -| `get_app_icon` | yes | yes | yes | POST | `h_get_app_icon` | -| `get_app_version` | yes | yes | yes | POST | `h_get_app_version` | -| `get_branch_diff_stats` | yes | yes | yes | POST | `h_get_branch_diff_stats` | -| `get_changed_files` | yes | yes | yes | POST | `h_get_changed_files` | -| `get_commit_ai_api_key` | yes | yes | yes | POST | `h_get_commit_ai_api_key` | -| `get_commit_ai_enabled` | yes | yes | yes | POST | `h_get_commit_ai_enabled` | -| `get_commit_prefix_config` | yes | yes | yes | POST | `h_get_commit_prefix_config` | -| `get_config_path_info` | yes | yes | yes | POST | `h_get_config_path_info` | -| `get_connected_clients` | yes | yes | yes | POST | `h_get_connected_clients` | -| `get_crash_report` | yes | yes | yes | GET | `h_get_crash_report` | -| `get_current_workspace` | yes | yes | yes | POST | `h_get_current_workspace` | -| `get_dashscope_api_key` | yes | yes | yes | POST | `h_get_dashscope_api_key` | -| `get_dashscope_base_url` | yes | yes | yes | POST | `h_get_dashscope_base_url` | -| `get_file_diff` | yes | yes | yes | POST | `h_get_file_diff` | -| `get_git_diff` | yes | yes | yes | POST | `h_get_git_diff` | -| `get_git_user_config` | yes | yes | yes | POST | `h_get_git_user_config` | -| `get_git_user_global_config` | yes | yes | yes | POST | `h_get_git_user_global_config` | -| `get_last_share_password` | yes | yes | yes | POST | `h_get_last_share_password` | -| `get_last_share_port` | yes | yes | yes | POST | `h_get_last_share_port` | -| `get_locked_worktrees` | | yes | yes | POST | `h_get_locked_worktrees` | -| `get_main_occupation` | yes | yes | yes | POST | `h_get_main_occupation` | -| `get_main_workspace_status` | yes | yes | yes | POST | `h_get_main_workspace_status` | -| `get_mirror_sources` | yes | yes | yes | POST | `h_get_mirror_sources` | -| `get_ngrok_token` | yes | yes | yes | POST | `h_get_ngrok_token` | -| `get_opened_workspaces` | | yes | yes | POST | `h_get_opened_workspaces` | -| `get_remote_branches` | yes | yes | yes | POST | `h_get_remote_branches` | -| `get_share_state` | yes | yes | yes | POST | `h_get_share_state` | -| `get_shell_integration_enabled` | yes | yes | yes | POST | `h_get_shell_integration_enabled` | -| `get_skip_git_hooks` | yes | yes | yes | POST | `h_get_skip_git_hooks` | -| `get_terminal_state` | yes | yes | yes | POST | `h_get_terminal_state` | -| `get_voice_asr_model` | yes | yes | yes | POST | `h_get_voice_asr_model` | -| `get_voice_refine_base_url` | yes | yes | yes | POST | `h_get_voice_refine_base_url` | -| `get_voice_refine_enabled` | yes | yes | yes | POST | `h_get_voice_refine_enabled` | -| `get_voice_refine_model` | yes | yes | yes | POST | `h_get_voice_refine_model` | -| `get_workspace_config` | yes | yes | yes | POST | `h_get_workspace_config` | -| `import_external_project` | yes | yes | yes | POST | `h_import_external_project` | -| `kick_client` | yes | yes | yes | POST | `h_kick_client` | -| `list_dashscope_models` | yes | yes | yes | POST | `h_list_dashscope_models` | -| `list_vault_item_children` | yes | yes | yes | POST | `h_list_vault_item_children` | -| `list_workspaces` | yes | yes | yes | POST | `h_list_workspaces` | -| `list_worktrees` | yes | yes | yes | POST | `h_list_worktrees` | -| `load_workspace_config_by_path` | yes | yes | yes | POST | `h_load_workspace_config_by_path` | -| `lock_worktree` | yes | yes | yes | POST | `h_lock_worktree` | -| `merge_to_base_branch` | yes | yes | yes | POST | `h_merge_to_base_branch` | -| `merge_to_test_branch` | yes | yes | yes | POST | `h_merge_to_test_branch` | -| `open_devtools` | yes | yes | yes | POST | `h_open_devtools` | -| `open_in_editor` | yes | yes | yes | POST | `h_open_in_editor` | -| `open_in_terminal` | yes | yes | yes | POST | `h_open_in_terminal` | -| `open_log_dir` | yes | yes | yes | POST | `h_open_log_dir` | -| `open_workspace_window` | yes | yes | yes | POST | `h_open_workspace_window` | -| `pty_close` | yes | yes | yes | POST | `h_pty_close` | -| `pty_close_by_path` | yes | yes | yes | POST | `h_pty_close_by_path` | -| `pty_create` | yes | yes | yes | POST | `h_pty_create` | -| `pty_exists` | yes | yes | yes | POST | `h_pty_exists` | -| `pty_read` | yes | yes | yes | POST | `h_pty_read` | -| `pty_resize` | yes | yes | yes | POST | `h_pty_resize` | -| `pty_write` | yes | yes | yes | POST | `h_pty_write` | -| `pull_current_branch` | yes | yes | yes | POST | `h_pull_current_branch` | -| `push_to_remote` | yes | yes | yes | POST | `h_push_to_remote` | -| `remove_project_from_config` | yes | yes | yes | POST | `h_remove_project_from_config` | -| `remove_workspace` | yes | yes | yes | POST | `h_remove_workspace` | -| `restore_worktree` | yes | yes | yes | POST | `h_restore_worktree` | -| `reveal_in_finder` | yes | yes | yes | POST | `h_reveal_in_finder` | -| `save_custom_mirrors` | yes | yes | yes | POST | `h_save_custom_mirrors` | -| `save_workspace_config` | yes | yes | yes | POST | `h_save_workspace_config` | -| `save_workspace_config_by_path` | yes | yes | yes | POST | `h_save_workspace_config_by_path` | -| `scan_existing_projects` | yes | yes | yes | POST | `h_scan_existing_projects` | -| `scan_linked_folders` | yes | yes | yes | POST | `h_scan_linked_folders` | -| `set_commit_ai_api_key` | yes | yes | yes | POST | `h_set_commit_ai_api_key` | -| `set_commit_ai_enabled` | yes | yes | yes | POST | `h_set_commit_ai_enabled` | -| `set_commit_prefix_config` | yes | yes | yes | POST | `h_set_commit_prefix_config` | -| `set_dashscope_api_key` | yes | yes | yes | POST | `h_set_dashscope_api_key` | -| `set_dashscope_base_url` | yes | yes | yes | POST | `h_set_dashscope_base_url` | -| `set_git_path` | yes | yes | yes | POST | `h_set_git_path` | -| `set_git_user_config` | yes | yes | yes | POST | `h_set_git_user_config` | -| `set_git_user_global_config` | yes | yes | yes | POST | `h_set_git_user_global_config` | -| `set_ngrok_token` | yes | yes | yes | POST | `h_set_ngrok_token` | -| `set_shell_integration_enabled` | yes | yes | yes | POST | `h_set_shell_integration_enabled` | -| `set_skip_git_hooks` | yes | yes | yes | POST | `h_set_skip_git_hooks` | -| `set_voice_asr_model` | yes | yes | yes | POST | `h_set_voice_asr_model` | -| `set_voice_refine_base_url` | yes | yes | yes | POST | `h_set_voice_refine_base_url` | -| `set_voice_refine_enabled` | yes | yes | yes | POST | `h_set_voice_refine_enabled` | -| `set_voice_refine_model` | yes | yes | yes | POST | `h_set_voice_refine_model` | -| `set_window_workspace` | yes | yes | yes | POST | `h_set_window_workspace` | -| `speed_test_single_mirror` | yes | yes | yes | POST | `h_speed_test_single_mirror` | -| `start_ngrok_tunnel` | yes | yes | yes | POST | `h_start_ngrok_tunnel` | -| `start_sharing` | yes | yes | yes | POST | `h_start_sharing` | -| `stop_ngrok_tunnel` | yes | yes | yes | POST | `h_stop_ngrok_tunnel` | -| `stop_sharing` | yes | yes | yes | POST | `h_stop_sharing` | -| `switch_branch` | yes | yes | yes | POST | `h_switch_branch` | -| `switch_workspace` | yes | yes | yes | POST | `h_switch_workspace` | -| `sync_all_projects_to_base` | yes | yes | yes | POST | `h_sync_all_projects_to_base` | -| `sync_with_base_branch` | yes | yes | yes | POST | `h_sync_with_base_branch` | -| `terminate_worktree_locking_process` | yes | yes | yes | POST | `h_terminate_worktree_locking_process` | -| `test_mirror_speed` | yes | yes | yes | POST | `h_test_mirror_speed` | -| `unlock_worktree` | yes | yes | yes | POST | `h_unlock_worktree` | -| `unregister_window` | | yes | yes | POST | `h_unregister_window` | -| `update_share_password` | yes | yes | yes | POST | `h_update_share_password` | -| `update_worktree_color` | yes | yes | yes | POST | `h_update_worktree_color` | -| `vault_link` | yes | yes | yes | POST | `h_vault_link` | -| `vault_status` | yes | yes | yes | POST | `h_vault_status` | -| `voice_is_active` | yes | yes | yes | POST | `h_voice_is_active` | -| `voice_refine_text` | yes | yes | yes | POST | `h_voice_refine_text` | -| `voice_send_audio` | yes | yes | yes | POST | `h_voice_send_audio` | -| `voice_start` | yes | yes | yes | POST | `h_voice_start` | -| `voice_stop` | yes | yes | yes | POST | `h_voice_stop` | - -## HTTP-only Endpoints - -| Endpoint | Source | Method | Handler | Kind | -| --- | --- | --- | --- | --- | -| `auth/challenge` | `authenticate() @ src/lib/backend.ts:323` | POST | `h_auth_challenge` | backend.ts direct fetch | -| `auth/verify` | `authenticate() @ src/lib/backend.ts:370` | POST | `h_auth_verify` | backend.ts direct fetch | -| `cert.pem` | `src-tauri/src/http_server/routing.rs:389` | GET | `h_cert_pem` | HTTP infrastructure | -| `get_share_info` | `getShareInfo() @ src/lib/backend.ts:312` | GET | `h_get_share_info` | backend.ts direct fetch | -| `mcp/config` | `src-tauri/src/http_server/routing.rs:366` | POST | `h_mcp_config` | HTTP infrastructure | -| `mcp/set_capability` | `src-tauri/src/http_server/routing.rs:367` | POST | `h_set_mcp_capability` | HTTP infrastructure | - -## Frontend missing IPC - -- None - -## Frontend missing HTTP - -- None - -## backend.ts HTTP-only endpoints missing HTTP route - -- None - -## IPC missing HTTP route - -- None - -## HTTP mirrored routes missing IPC command - -- None diff --git a/docs/gif/card1-multi-repo.gif b/docs/gif/card1-multi-repo.gif deleted file mode 100644 index 1e6c994..0000000 Binary files a/docs/gif/card1-multi-repo.gif and /dev/null differ diff --git a/docs/gif/card2-hotfix-story.gif b/docs/gif/card2-hotfix-story.gif deleted file mode 100644 index 2ae032d..0000000 Binary files a/docs/gif/card2-hotfix-story.gif and /dev/null differ diff --git a/docs/guide.html b/docs/guide.html deleted file mode 100644 index f48c71a..0000000 --- a/docs/guide.html +++ /dev/null @@ -1,1393 +0,0 @@ - - - - - - 使用指南 - Git Worktree Manager - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
-

使用指南

-

从安装到精通,全面了解 Git Worktree Manager

-
- - - - - -
- - -
-

- 1 - 安装与启动 -

- -
-

下载安装(推荐)

-

前往 GitHub Releases 页面下载适合你系统的安装包:

- -
- ✅ macOS (Intel + ARM) - ✅ Windows - ✅ Linux -
-
-

🔄 内置自动更新功能,安装后新版本会自动推送,无需手动下载。

-
-
- -
-

- - macOS 安装说明 -

-
    -
  1. - 1 - 下载 .dmg 文件,打开后将应用拖入 Applications 文件夹完成安装。 -
  2. -
  3. - 2 - 首次打开如提示"无法验证开发者",请右键点击 app,然后选择"打开" -
  4. -
  5. - 3 - 如仍无法打开,前往系统设置 → 隐私与安全性,在底部找到提示并点击"仍要打开" -
  6. -
  7. - 4 - 以上方法均无效时,打开终端执行:xattr -cr "/Applications/Worktree Manager.app" -
  8. -
-
- -
- 🛠️ 从源码构建(面向开发者) -
-
-

环境要求

-
    -
  • - - Node.js 20+ (下载安装) -
  • -
  • - - Rust 1.70+ (安装指南) -
  • -
  • - - Git 2.0+(命令行可用) -
  • -
-
- -

构建步骤

-
# 1. 克隆项目
-git clone https://github.com/guoyongchang/worktree-manager.git
-cd worktree-manager
-
-# 2. 安装依赖
-npm install
-
-# 3. 开发模式运行
-npm run tauri dev
-
-# 4. 构建生产版本(可选)
-npm run tauri build
- -
-

💡 提示:首次运行可能需要几分钟下载 Rust 依赖,请耐心等待。

-
-
-
-
- - -
-

- 2 - 快速上手 -

- -
-
-

- 1 - 创建工作区 -

-

启动应用后,首次使用需要创建或导入工作区:

-
    -
  • 新建工作区:选择一个空目录,应用会自动初始化
  • -
  • 导入现有项目:选择包含 Git 项目的目录
  • -
-
- -
-

- 2 - 添加项目 -

-

在工作区中添加 Git 项目,支持三种方式:

-
- - - - - - - - - - - - - - - - - - - - - - - - - -
方式格式示例
GitHub 简写owner/repofacebook/react
SSHgit@host:owner/repo.gitgit@github.com:facebook/react.git
HTTPShttps://host/owner/repo.githttps://github.com/facebook/react.git
-
-
- -
-

- 3 - 创建 Worktree -

-

点击侧边栏的 + 按钮,按照向导创建:

-
    -
  1. 输入分支名称(如 feature/login
  2. -
  3. 选择基于哪个分支创建(默认 main
  4. -
  5. 选择要包含的项目
  6. -
  7. 配置需要链接的文件夹(如 node_modules
  8. -
  9. 点击"创建",等待完成
  10. -
-
- -
-

- 4 - 开始开发 -

-

在 Worktree 列表中:

-
    -
  • 点击打开 IDE:用 VS Code / Cursor / IDEA 打开
  • -
  • 点击终端:在内置终端中运行命令
  • -
  • 点击文件夹图标:在文件管理器中打开
  • -
-
-
-
- - -
-

- 3 - 工作区管理 -

- -
-

目录结构

-
workspace/
-├── .worktree-manager.json    # 工作区配置
-├── projects/                 # 主仓库目录
-│   ├── frontend/             # Git 项目 (main 分支)
-│   └── backend/
-├── worktrees/                # Worktree 目录
-│   ├── feature-login/
-│   │   ├── projects/
-│   │   │   ├── frontend/     # 独立分支的工作目录
-│   │   │   └── backend/
-│   │   ├── .claude → ../../.claude    # 软链接
-│   │   └── CLAUDE.md → ../../CLAUDE.md
-│   └── hotfix-bug/
-│       └── ...
-├── .claude/                  # 全局共享文件
-└── CLAUDE.md
-
- -
-

全局文件共享

-

在工作区配置中设置 linked_workspace_items,这些文件/文件夹会自动链接到所有 Worktree:

-
{
-  "linked_workspace_items": [
-    ".claude",
-    "CLAUDE.md",
-    "requirement-docs"
-  ]
-}
-

适用于 AI 辅助开发配置、需求文档等跨分支共享的资源。

-
-
- - -
-

- 4 - Worktree 操作 -

- -
-
-

🔄 切换分支

-

在列表中点击不同的 Worktree 即可切换,无需 git checkout

-
-
-

📊 状态监控

-

实时显示提交数、未提交更改、是否合并到测试分支

-
-
-

🗑️ 归档 Worktree

-

完成开发后可归档,会先检查未提交/未推送的代码,避免丢失

-
-
-

♻️ 恢复归档

-

从归档列表中一键恢复之前的 Worktree

-
-
-
- - -
-

- 5 - 多窗口与工作区切换 -

- -
-
-

多窗口工作模式

-

每个 Workspace 可以在独立的窗口中打开,让你同时操作多个工作区,互不干扰。

-
    -
  • 在侧边栏的 Workspace 列表中,点击 Workspace 名称旁边的外部链接图标(↗️),即可在新窗口中打开该工作区
  • -
  • 每个窗口独立运行,可以同时查看和操作不同的 Workspace
  • -
  • 主窗口关闭时,子窗口也会随之关闭
  • -
-
- -
-

窗口标题

-

窗口标题会动态显示当前的工作区和分支信息,格式为:

-
- {WorkspaceName} - {WorktreeName} -
-

这样在多窗口场景下,你可以通过窗口标题快速区分不同的工作区。

-
- -
-

Worktree 锁定机制

-

当一个 Worktree 已经在某个窗口中打开时,在其他窗口中该 Worktree 会显示为"已占用"状态。

-
    -
  • 已占用的 Worktree 不能在另一个窗口中同时操作,避免冲突
  • -
  • 切换到其他 Worktree 时,当前 Worktree 的锁会自动释放
  • -
  • 关闭窗口时,该窗口持有的所有锁也会自动释放
  • -
-
-
-
- - -
-

- 6 - 终端功能 -

- -
-
-

内置终端

-

每个 Worktree 都有独立的内置终端,工作目录自动设置为 Worktree 的根目录。无需手动 cd 到对应路径。

-
    -
  • 终端面板位于应用底部,可以通过拖拽调整高度
  • -
  • 切换 Worktree 时,终端会自动切换到对应的会话
  • -
  • 终端会话在 Worktree 生命周期内保持,不会因为切换而丢失
  • -
-
- -
-

多标签页

-

每个 Worktree 可以拥有多个终端标签页:

-
    -
  • 点击终端面板的 + 按钮创建新的终端标签页
  • -
  • 点击标签页切换不同的终端会话
  • -
  • 点击标签页上的 x 按钮关闭终端
  • -
  • 每个标签页都是独立的终端进程
  • -
-
- -
-

全屏模式

-

终端支持全屏模式,适合需要更大终端空间的场景:

-
    -
  • 点击终端面板右上角的最大化图标进入全屏模式
  • -
  • Escape 键退出全屏模式
  • -
  • 全屏模式下终端会占满整个应用窗口
  • -
-
- -
-

外部终端

-

如果你更习惯使用系统自带的终端应用,可以点击终端面板的"在外部终端中打开"按钮,它会自动打开系统默认终端并切换到 Worktree 目录。

-
-
-
- - -
-

- 7 - 语音输入 -

- -
-
-

功能说明

-

语音输入功能基于阿里云 Dashscope Paraformer 实时语音识别,允许你对着麦克风说话,语音实时转写为文字并注入到当前终端中。适合双手不方便打字或需要快速输入命令的场景。

-

支持两种操作方式:

-
    -
  • 按住说话:按住 Alt+V 说话,松开即停止录音并转写
  • -
  • 点击切换:点击终端面板的麦克风图标进入待命状态,之后按住 Alt+V 说话
  • -
-
- -
-

配置 Dashscope

-

使用语音输入前,需要先配置 Dashscope API Key:

-
    -
  1. 打开设置页面(齿轮图标)
  2. -
  3. 滚动到"语音识别 (Dashscope)"区域
  4. -
  5. 前往 Dashscope 控制台 获取 API Key
  6. -
  7. 将 API Key 粘贴到输入框中,点击"保存"
  8. -
  9. 点击"测试连接"按钮验证配置是否正确
  10. -
-
-

Dashscope Paraformer 支持中英混合识别,识别速度快、准确率高,所有桌面平台(macOS / Windows / Linux)均可使用。

-
-
- -
-

麦克风设置

-

在设置页的语音识别区域,可以管理麦克风设备:

-
    -
  • 选择麦克风:下拉框列出所有可用的音频输入设备,可选择特定麦克风(如外接麦克风、耳机麦克风等),默认使用系统默认设备
  • -
  • 麦克风测试:点击"测试"按钮,会显示实时音量条,对着麦克风说话可确认设备是否正常工作。测试 10 秒后自动停止
  • -
  • 连接测试:点击"测试连接"按钮,验证 API Key 和 WebSocket 地址是否配置正确,成功时显示绿色"连接成功"提示
  • -
-
-

💡 提示:首次使用时系统会弹出麦克风权限请求,请点击"允许"。如果不小心拒绝了,macOS 用户前往系统设置 → 隐私与安全性 → 麦克风手动开启。

-
-
- -
-

使用流程

-
    -
  1. 点击终端面板右上角的麦克风图标,进入待命状态(图标变为蓝色,麦克风已打开)
  2. -
  3. 按住 Alt+V 开始说话,此时连接 Dashscope 并实时发送音频
  4. -
  5. 松开 Alt+V,停止录音,识别结果自动输入到终端
  6. -
  7. 可以反复按住/松开进行多次语音输入,麦克风始终保持待命
  8. -
  9. 再次点击麦克风图标,退出待命状态并释放麦克风
  10. -
-
- # 状态流转
- 空闲 [点击麦克风] 待命 [按住 Alt+V] 录音中 [松开] 待命 -
-
- -
-

语音命令

-

除了普通文字转写外,还支持以下语音命令关键词,说出即可触发对应终端操作:

-
- - - - - - - - - - - - - - - - - - - - - - - - - -
语音关键词终端操作
提交 / 回车 / enter / submit按下回车 (Enter)
删除 / backspace / delete删除前一个字符 (Backspace)
清空 / clear中断当前输入 (Ctrl+C)
中断 / escape发送 ESC 键
-
-

例如:按住 Alt+V 说 git status,松开后再按住说 回车,等同于在终端中输入 git status 并按回车执行。

-
- -
-

AI 语音精炼

-

语音转写的结果往往包含口语化的填充词("嗯"、"那个"、"就是说"等)和语法错误。AI 语音精炼功能集成了 Qwen 大语言模型,自动优化转写文本:

-
    -
  • 去除填充词:自动识别并移除"嗯"、"啊"、"那个"等无意义词汇
  • -
  • 语法修正:修正口语化表达为规范的命令格式
  • -
  • 语义保持:优化过程保持原始意图不变,不会改变命令含义
  • -
-
- # 语音精炼示例
- 原始转写: 嗯 那个 git status 就是看一下
- 精炼结果: git status -
-
-

💡 配置:在设置页面的语音识别区域,配置 Qwen API Key(使用 Dashscope 平台)即可启用 AI 精炼功能。精炼功能可单独开关。

-
-
- -
-

移动端语音输入

-

在浏览器远程模式下(手机/平板访问),终端面板提供专门的按住说话按钮

-
    -
  • 长按说话:在终端底部长按麦克风按钮开始录音,松开即停止
  • -
  • 触屏优化:按钮尺寸和交互针对触摸屏优化,单手即可操作
  • -
  • 同样支持 AI 精炼:移动端的语音输入同样经过 AI 精炼处理
  • -
-
-
-
- - -
-

- 8 - IDE 集成 -

- -
-
-

支持的 IDE

-

Worktree Manager 支持多种主流 IDE 和编辑器:

-
-
- VS Code - code -
-
- Cursor - cursor -
-
- IntelliJ IDEA - idea -
-
- Zed - zed -
-
- Sublime Text - subl -
-
- Windsurf - windsurf -
-
-
- -
-

切换默认 IDE

-

在 Worktree 详情页中,点击 IDE 按钮旁边的下拉箭头,即可看到 IDE 选择列表:

-
    -
  • 选择一个 IDE 后,它会成为当前项目的默认 IDE
  • -
  • 下次点击 IDE 按钮时,会直接使用上次选择的 IDE 打开
  • -
  • 不同项目可以设置不同的默认 IDE
  • -
-
- -
-

快速打开

-

在 IDE 下拉菜单中,每个 IDE 选项右侧都有一个快速打开图标(↗️):

-
    -
  • 点击图标可以直接用该 IDE 打开项目,而不会改变默认 IDE 设置
  • -
  • 适合偶尔用其他 IDE 打开的场景
  • -
-
- -
-

在文件夹中打开

-

IDE 下拉菜单底部还有"在文件夹中打开"选项,点击后会在系统文件管理器(Finder / 文件资源管理器)中打开 Worktree 的项目目录。

-
-
-
- - -
-

- 9 - 智能文件夹扫描 -

- -
-
-

功能说明

-

智能文件夹扫描可以自动检测项目中适合被链接的大文件夹,帮助你快速配置 linked_folders

-

链接这些文件夹后,新创建的 Worktree 会自动从主仓库创建软链接,无需重复安装依赖或重新构建,节省大量磁盘空间和时间。

-
- -
-

如何使用

-
    -
  1. 进入工作区设置页面
  2. -
  3. 找到项目的 Linked Folders 配置项
  4. -
  5. 点击扫描按钮(放大镜图标)
  6. -
  7. 系统会自动扫描项目目录,列出所有可链接的文件夹
  8. -
  9. 勾选你需要链接的文件夹,点击确认
  10. -
-
- -
-

自动检测的文件夹类型

-

扫描功能会自动识别以下常见的大文件夹:

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
文件夹用途
node_modulesNode.js 依赖
.nextNext.js 构建产物
dist通用构建输出
build构建输出
vendorPHP / Go 依赖
.outputNuxt 3 构建输出
.nuxtNuxt.js 构建产物
targetRust / Java 构建输出
-
-
-
-
- - -
-

- 10 - 浏览器分享(LAN / ngrok) -

- -
-
-

功能概述

-

浏览器分享功能允许你将当前工作区通过浏览器分享给同事。对方无需安装任何软件,只需打开链接并输入分享密码,即可查看你的 Worktree 和使用浏览器终端。

-
-

适用场景:

-
    -
  • 结对排查 -- 同事通过浏览器查看你的终端和工作区状态
  • -
  • 代码演示 -- 让 PM 或测试人员在浏览器中查看你的分支状态
  • -
  • 远程协助 -- 通过 LAN 或 ngrok 链接快速进入浏览器端进行排查
  • -
  • 移动办公 -- 在平板或手机浏览器上临时查看工作区状态
  • -
-
-

分享功能支持两种模式:局域网直连(同一 Wi-Fi / 内网)和 ngrok 穿透(使用 ngrok 服务)。两种方式都通过同一套分享密码保护浏览器访问。

-
- -
-

开启分享

-

在桌面端应用中,按照以下步骤开启分享:

-
    -
  1. 在侧边栏底部找到"分享"按钮,点击打开分享面板
  2. -
  3. 设置端口号(默认推荐 49152-65535 范围,也支持 3000-9999 开发端口)
  4. -
  5. 设置访问密码(必填,用于保护你的工作区)
  6. -
  7. 点击"开启分享",系统会启动一个内置 HTTP 服务器
  8. -
  9. 分享成功后会显示一个局域网访问地址(如 http://192.168.1.100:49152
  10. -
-
- # 分享成功后显示的地址示例
- 局域网地址: http://192.168.1.100:49152
- # 将此地址和密码发给同事即可 -
-
- -
-

浏览器模式访问

-

协作者打开分享链接后:

-
    -
  1. 浏览器会显示一个密码输入页面,输入你设置的密码即可进入
  2. -
  3. 进入后看到与桌面端相同的界面,包括 Worktree 列表、分支状态等
  4. -
  5. 可以使用内置终端(通过 WebSocket 实时传输)
  6. -
  7. 可以使用浏览器终端,与桌面端共享同一个终端会话
  8. -
  9. 所有操作通过 WebSocket 实时同步,延迟极低
  10. -
-
-

⚠️ 注意:浏览器端的终端操作会直接影响你本机的文件,请确保只分享给信任的人。

-
-
- -
-

ngrok 外网穿透

-

如果协作者不在同一局域网,可以使用 ngrok 进行外网穿透,通过公网 URL 访问:

- -

第一步:安装 ngrok 并获取 Token

-
    -
  1. 前往 ngrok.com 注册账号(免费)
  2. -
  3. ngrok Dashboard 复制你的 Authtoken
  4. -
- -

第二步:在应用中配置 Token

-
    -
  1. 打开设置页面(齿轮图标)
  2. -
  3. 滚动到底部找到 "外网分享 (ngrok)" 区域
  4. -
  5. 将 Authtoken 粘贴到输入框中,点击"保存"
  6. -
- -

第三步:启用外网隧道

-
    -
  1. 先按上述步骤开启局域网分享
  2. -
  3. 在分享面板中点击"开启外网"按钮
  4. -
  5. 等待 ngrok 隧道建立成功(通常几秒内完成)
  6. -
  7. 生成的公网地址(如 https://xxxx.ngrok-free.app)即可分享给任何人
  8. -
- -
- # ngrok 外网地址示例
- 外网地址: https://a1b2c3d4.ngrok-free.app
- # 任何能上网的人都可以通过此地址访问 -
-
- -
-

QR 码分享

-

开启分享后,分享面板会自动生成当前分享地址的二维码:

-
    -
  • 扫码访问:用手机/平板扫描二维码,直接在移动浏览器中打开工作区
  • -
  • 密码内嵌:二维码链接中可以内嵌访问密码(通过 URL fragment),扫码后自动完成认证,无需手动输入密码
  • -
  • 多种模式:局域网和 ngrok 两种模式均支持 QR 码
  • -
-
- -
-

客户端管理

-

在分享面板中可以实时查看和管理所有连接的客户端:

-
    -
  • 连接列表:显示所有已连接的浏览器客户端,包括 IP 地址、连接时间等信息
  • -
  • 踢出会话:可以单独踢出指定的客户端会话,被踢出的用户需要重新输入密码
  • -
  • 密码更新:修改密码后,所有已连接的客户端会被自动踢出
  • -
-
- -
-

安全注意事项

-
-
- -
-

密码保护

-

所有分享都必须设置密码,浏览器端必须先认证才能访问 API

-
-
-
- -
-

防暴力破解

-

内置速率限制机制,同一 IP 每 60 秒最多尝试 5 次认证

-
-
-
- -
-

会话隔离

-

每个浏览器端获得独立的 session ID,服务端生成,无法伪造

-
-
-
- -
-

CORS 限制

-

仅允许来自 localhost、局域网 IP 和当前 ngrok URL 的跨域请求

-
-
-
- -
-

随时可控

-

分享者可随时修改密码(自动踢出所有已连接用户)、关闭外网隧道或完全停止分享

-
-
-
-
-

⚠️ 重要提醒:分享功能会暴露你的工作区文件和终端。请使用强密码,且仅分享给你信任的人。使用 ngrok 外网模式时请格外注意,因为任何知道链接和密码的人都可以访问。不使用时请及时关闭分享。

-
-
- -
-

分享问题排查

-
-
- 局域网分享后同事无法访问? -
-
    -
  • 确认双方在同一局域网 / Wi-Fi 下
  • -
  • 检查防火墙是否放行了对应端口(macOS 可能会弹出防火墙提示,点击"允许")
  • -
  • 尝试用 curl http://你的IP:端口/api/get_share_info 测试连通性
  • -
  • 如果用的是公司 VPN,有可能网络隔离,建议改用 ngrok 分享链接
  • -
-
-
-
- ngrok 隧道启动失败? -
-
    -
  • 检查 ngrok token 是否正确配置(设置页面底部)
  • -
  • 确认网络能访问外网(ngrok 需要连接其服务器)
  • -
  • 免费版 ngrok 有同时隧道数限制,确保没有其他隧道在运行
  • -
  • 如果超时,重试一次,ngrok 偶尔会连接较慢
  • -
-
-
-
- 浏览器端终端无响应? -
-
    -
  • 检查 WebSocket 连接状态(浏览器开发者工具 → Network → WS)
  • -
  • 如果使用 ngrok,确认 ngrok 隧道仍在运行
  • -
  • 尝试刷新页面重新连接
  • -
-
-
-
- 端口被占用怎么办? -
-

更换一个端口号即可。推荐使用 49152-65535 范围内的端口,这些是动态/私有端口,不太可能被其他程序占用。

-
# 查看端口占用情况(macOS/Linux)
-lsof -i :49152
-
-
-
-
-
-
- - -
-

- 11 - Vault 挂载 -

- -
-
-

什么是 Vault 挂载?

-

Vault 挂载功能允许你将一个 Obsidian Vault(知识库目录)以 symlink 方式挂载到工作区根目录。挂载后,Vault 中的文件/文件夹对所有子 worktree 可见,实现跨分支的知识共享。

-
-

典型用途: 将项目文档、设计稿、API 文档等放在 Vault 中,所有 worktree 都能访问,无需在每个分支中复制。

-
-
- -
-

如何使用

-
    -
  1. 打开设置页面(齿轮图标)
  2. -
  3. 在左侧菜单中选择"Vault"
  4. -
  5. 点击"选择 Vault"按钮,选择你的 Vault 目录
  6. -
  7. Vault 目录下的文件/文件夹会显示在列表中
  8. -
  9. 勾选需要挂载的项,点击"更新挂载"
  10. -
-
-

💡 提示:文件夹支持展开查看子目录,99+ 项的子目录会显示限制提示。Vault 挂载项在列表中以绿色背景和 Link 图标标识。

-
-
- -
-

安全保护

-
-
- -
-

内置黑名单

-

.projects、worktrees、.worktree-manager.json 等关键文件/目录不允许挂载,防止破坏工作区结构。

-
-
-
- -
-

冲突检测

-

挂载前自动检查目标路径是否已存在文件或已有链接,避免覆盖。

-
-
-
- -
-

自动同步

-

更新挂载后,自动同步到所有子 worktree。删除 Vault 中的挂载项时,自动恢复原始文件(从 .local 备份)。

-
-
-
-
-
-
- - -
-

- 12 - 插件生态(MCP) -

- -
- -
-
-
🤖
-
-

MCP 插件

-

让 AI 助手直接操作 Worktree Manager

-
-
-

通过 Model Context Protocol (MCP) 协议,Claude Code、Codex、Cursor 等 AI 助手可以直接与 Worktree Manager 交互,执行工作区查询、worktree 创建、Git 操作等任务。

- -
-

安装

-
npx -y @worktree-manager/mcp install
-

安装后重启 Claude Code 或运行 claude mcp restart

-
- -

可用工具

-
-
-
Layer 1 — 核心查询
-
    -
  • workspace_list
  • -
  • worktree_list / worktree_get_status
  • -
  • workspace_get_status
  • -
-
-
-
Layer 2 — 详细信息
-
    -
  • project_get_branches
  • -
  • project_get_diff_stats
  • -
  • project_get_changed_files
  • -
-
-
-
Layer 3 — 高级操作(由 Skill 包装)
-
    -
  • worktree_create / worktree_archive
  • -
  • git_commit / git_push
  • -
-
-
-
- -
-
- - -
-

- 13 - 配置详解 -

- -
-
-

全局配置

-

位置:~/.config/worktree-manager/global.json

-
{
-  "workspaces": [
-    { "name": "我的项目", "path": "/path/to/workspace" }
-  ],
-  "current_workspace": "/path/to/workspace",
-  "ngrok_token": "2abc...xyz",
-  "last_share_port": 49152
-}
-
-

ngrok_token: 可选,ngrok Authtoken,用于外网穿透分享

-

last_share_port: 可选,上次使用的分享端口号,下次分享时自动填入

-
-
- -
-

工作区配置

-

位置:{workspace}/.worktree-manager.json

-
{
-  "name": "我的项目",
-  "worktrees_dir": "worktrees",
-  "linked_workspace_items": [".claude", "CLAUDE.md"],
-  "projects": [
-    {
-      "name": "frontend",
-      "base_branch": "main",
-      "test_branch": "test",
-      "merge_strategy": "merge",
-      "linked_folders": ["node_modules", ".next"]
-    }
-  ]
-}
-
-

worktrees_dir: Worktree 存放目录

-

linked_workspace_items: 全局共享文件

-

linked_folders: 项目级文件夹链接

-

merge_strategy: 合并策略 (mergerebase)

-
-
- -
-

Git 提交配置

-

在「设置 → 提交设置」中可配置提交前缀模板和 Git 用户信息。

- -
-
-

提交前缀模板

-

支持最多 3 个前缀模板,提交时自动添加到 commit message 前面。支持变量替换:

-
    -
  • {{worktree-name}} — 当前 worktree 名称
  • -
  • {{project-name}} — 项目名称
  • -
  • {{branch-name}} — 当前分支名
  • -
  • {{repo-name}} — 仓库名称
  • -
  • {{date:YYYY-MM-DD}} — 日期(支持 YYYY, MM, DD, HH, mm 格式)
  • -
-

示例:[{{worktree-name}}] {{project-name}} at {{date}} 会渲染为 [feature-123] frontend at 2026-04-19

-

可在下拉框中切换模板,点击星星图标设置默认模板。「无」选项表示不使用前缀。

-
- -
-

Git 用户配置

-

支持全局和项目级两级配置,避免用错误的用户名/邮箱提交。

-
    -
  • 全局设置:在「设置 → 提交设置」中配置,对所有项目生效
  • -
  • 项目级设置:在「设置 → 项目」中为每个项目单独配置,留空则继承全局
  • -
  • 提交时会自动写入对应仓库的 user.nameuser.email
  • -
-
-
-
-
-
- - -
-

- 14 - 快捷键 -

- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
快捷键功能适用场景
Escape退出终端全屏终端全屏模式下
Escape关闭下拉菜单 / 弹窗任意菜单或弹窗打开时
Alt+V(按住)按住说话,松开停止录音麦克风待命状态下
右键点击打开 Worktree 上下文菜单在 Worktree 列表项上
-
-
-
- - -
-

- 15 - 最佳实践 -

- -
-
-

✅ 推荐做法

-
    -
  • • 为每个需求/功能创建独立的 Worktree
  • -
  • • 使用统一的分支命名规范(如 feature/xxxhotfix/xxx
  • -
  • • 定期归档已合并的 Worktree,保持列表整洁
  • -
  • • 将常用的依赖目录加入 linked_folders
  • -
  • • 使用内置终端,避免路径混淆
  • -
-
- -
-

⚠️ 避免做法

-
    -
  • • 不要手动删除 Worktree 目录,请使用归档功能
  • -
  • • 不要在多个 Worktree 中同时修改同一个文件(链接的文件除外)
  • -
  • • 不要链接会产生冲突的文件(如 .env
  • -
  • • 避免创建过多 Worktree(建议不超过 10 个)
  • -
-
-
-
- - -
-

- 16 - 常见问题 -

- -
-
- Q: macOS 提示"已损坏"或"无法验证开发者"怎么办? -
-

这是因为应用未经 Apple 公证签名,macOS 的安全机制会阻止打开。请按以下步骤操作:

-
    -
  1. 右键点击应用图标,选择"打开"(而不是双击)
  2. -
  3. 如果仍然无法打开,前往系统设置 → 隐私与安全性,在页面底部找到被阻止的提示,点击"仍要打开"
  4. -
  5. 如果以上方法都不行,打开终端,执行以下命令:
  6. -
-
xattr -cr "/Applications/Worktree Manager.app"
-

执行后重新打开应用即可。此命令会移除文件的隔离属性。

-
-
- -
- Q: 创建 Worktree 失败怎么办? -
-

可能原因:

-
    -
  • 分支名已存在
  • -
  • Git 仓库有未提交的更改
  • -
  • 磁盘空间不足
  • -
-

解决方案:检查错误提示,确保主仓库状态干净,或手动运行 git worktree add 查看详细错误。

-
-
- -
- Q: 软链接失效/找不到文件? -
-

检查 linked_folders 配置是否正确,确保主仓库中对应的文件夹存在。在终端中运行:

-
ls -la worktrees/your-worktree/
-

查看链接是否正常。如果链接损坏,可以删除后重新创建 Worktree。

-
-
- -
- Q: 如何在新窗口打开工作区? -
-

在侧边栏的 Workspace 列表中,将鼠标悬停在 Workspace 名称上,右侧会出现一个外部链接图标(↗️)。

-

点击该图标,即可在新窗口中打开对应的工作区。新窗口是独立的,不会影响主窗口的操作。

-
-
- -
- Q: 终端如何全屏? -
-

点击终端面板右上角的最大化图标即可进入全屏模式。

-

Escape 键退出全屏模式,恢复到正常布局。

-
-
- -
- Q: 链接的文件夹有哪些推荐? -
-

以下是常见的推荐链接文件夹列表,按项目类型选择:

-
    -
  • node_modules -- Node.js / 前端项目依赖
  • -
  • .next -- Next.js 构建缓存
  • -
  • dist -- 通用构建输出目录
  • -
  • build -- React / 其他框架构建输出
  • -
  • vendor -- PHP Composer / Go 依赖
  • -
  • .output -- Nuxt 3 构建输出
  • -
  • .nuxt -- Nuxt.js 构建缓存
  • -
  • target -- Rust Cargo / Java Maven 构建输出
  • -
-
-

💡 提示:也可以使用智能文件夹扫描功能自动检测,详见第 9 节

-
-
-
- -
- Q: 如何在多台电脑间同步工作区? -
-

工作区配置存储在 .worktree-manager.json,可以加入版本控制:

-
git add .worktree-manager.json
-git commit -m "Add worktree config"
-git push
-

在其他电脑上 pull 后,工作区会自动识别配置。

-
-
- -
- Q: 归档的 Worktree 可以恢复吗? -
-

可以!在归档列表中找到对应的 Worktree,点击"恢复"即可。注意恢复后分支状态可能与归档时不同(如果远程有更新)。

-
-
- -
- Q: 支持哪些操作系统? -
-

目前支持:

-
    -
  • ✅ macOS (Intel + Apple Silicon)
  • -
  • ✅ Linux (Ubuntu, Debian, Arch 等)
  • -
  • ⚠️ Windows (实验性支持,软链接需要管理员权限)
  • -
-
-
-
-
- -
- - -
-

还有疑问?

-

欢迎在 GitHub 上提 Issue 或参与讨论

- -
- -
-
- - - - - - - - diff --git a/docs/icons/128x128.png b/docs/icons/128x128.png deleted file mode 100644 index 681a5ef..0000000 Binary files a/docs/icons/128x128.png and /dev/null differ diff --git a/docs/icons/app-icon.svg b/docs/icons/app-icon.svg deleted file mode 100644 index 8daa3b2..0000000 --- a/docs/icons/app-icon.svg +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/icons/icon.png b/docs/icons/icon.png deleted file mode 100644 index ec75c26..0000000 Binary files a/docs/icons/icon.png and /dev/null differ diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index 338a609..0000000 --- a/docs/index.html +++ /dev/null @@ -1,1038 +0,0 @@ - - - - - - Git Worktree Manager - 多分支并行开发工具 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Worktree Manager -

Git Worktree Manager

-

一个优雅的 Git Worktree 可视化管理工具,让多分支并行开发变得简单高效。支持通过局域网或 ngrok 在浏览器中安全分享工作区。

- -
- - -
-
-

下载安装

-

开箱即用,支持自动更新

- - - - - -
-
- - macOS (Intel + ARM) -
-
- - Windows -
-
- - Linux -
-
- - -
- - 内置自动更新 -- 安装后新版本会自动推送,无需手动下载 -
- - -
-
-

- - macOS 安装说明 -

-
-
-
1
-

下载 .dmg 文件,打开后将应用拖入 Applications 文件夹完成安装。

-
-
-
2
-

首次打开如提示"无法验证开发者",请右键点击 app,然后选择"打开"

-
-
-
3
-

如仍无法打开,前往系统设置 → 隐私与安全性,在底部找到提示并点击"仍要打开"

-
-
-
4
-
-

以上方法均无效时,打开终端执行:

- xattr -cr "/Applications/Worktree Manager.app" -
-
-
-
-
-
-
- - -
-
-

这些场景,是不是似曾相识?

-

每个开发者都经历过的效率黑洞

-
- - -
-
-
🔥 线上着火,但你手里的活还没提交
-
-

你正在 feature/checkout-v2 上重构结算流程,改了十几个文件,npm run dev 跑着热更新。Slack 弹出告警:线上支付回调 500 了。

-

传统做法:git stash → 切到 hotfixnpm install(依赖版本不一样得重装)→ 修完推上去 → 切回来 → git stash pop → 祈祷没冲突 → 重启 dev server 等构建缓存重建。15 分钟起步,线上还在报错。

-
-
-
-

传统做法

-
- git stash - checkout hotfix - npm install - 修 bug - push - checkout 回来 - stash pop - npm install -
-
8 步 · 15+ 分钟 · 可能冲突
-
-
-

Worktree Manager

-
- 点击 "+" 新建 hotfix - 修 bug - push - 归档 -
-
4 步 · 30 秒切换 · 原分支零影响
-
-
-
-

node_modules 自动通过 symlink 共享,秒级就绪。你的 feature 分支 dev server 还在跑,改到一半的代码一行不用动。

-
-
-
- - -
-
-
💥 前后端联调,分支对不上就炸
-
-

你的项目是前后端分仓:webapi。做「会员体系」需求时,两个仓库都要切到 feature/membership 分支。但同事让你帮忙看 feature/search 的问题,你切了前端忘了切后端——页面白屏,接口 404,排查半天才发现是分支没对齐。

-
-
-
-

传统做法:手动对齐两个仓库

-
- cd web && git checkout - cd api && git checkout - 忘切一个? - 404 / 白屏 - 排查 30 分钟 -
-
多仓分支靠记忆对齐 · 迟早出错
-
-
-

Worktree Manager:一个 worktree = 一套环境

-
- 创建 membership worktree - web + api 自动检出 - 开发 -
-
切换 worktree = 切换整套工作环境
-
-
-
-

一个 worktree 绑定多个项目仓库,创建时所有仓库同时检出到对应分支。不存在「只切了一半」的问题。

-
-
-
- - -
-
-
🔄 提测合并全靠命令行肌肉记忆
-
-

需求开发完了,要合并到 test 分支给 QA 验证。每次都得:git checkout testgit pullgit merge feature/xxx → 解决冲突 → git pushgit checkout feature/xxx 切回来。一天提测三四个需求,这套操作重复到麻木,偶尔还会忘记切回来就在 test 上继续开发。

-
-
-
-

传统做法

-
- checkout test - git pull - git merge - 解冲突 - git push - checkout 回来 -
-
6 步 · 每次提测重复一遍 · 容易忘切回来
-
-
-

Worktree Manager

-
- 点击「合并到 test」 -
-
1 步 · 不离开当前分支 · 状态实时可见
-
-
-
-

每个项目卡片下方直接有「合并到 test」「同步 base」「推送」按钮。分支状态(领先/落后几个 commit、是否已合并 test)实时显示,一目了然。

-
-
-
- - -
-
-
🌐 出差在外,想看一眼公司机器上的代码
-
-

你的开发机在公司内网,出差时想看一下代码运行状态,或者在终端里执行几条命令。传统方案要么 SSH 隧道(折腾),要么 VPN + 远程桌面(卡顿)。

-
-
-
-

传统做法

-
- 配置 VPN - 远程桌面连接 - 卡顿 & 延迟 -
-
配置复杂 · 画面卡顿 · 依赖公司网络
-
-
-

Worktree Manager

-
- 点击「分享」 - 选择隧道模式 - 发送链接 - 浏览器打开 -
-
4 步 · 支持局域网和 ngrok 公网访问
-
-
-
-

开启分享功能后,可通过局域网直连ngrok 外网穿透访问当前工作区。在任意浏览器中打开后,仍需通过分享密码验证,随后即可查看工作区状态并使用内置终端。

-
-
-
- -
-
-
- - -
-
-

Git Worktree Manager 怎么解决这些问题?

-

- 基于 Git 原生的 worktree 能力构建,在同一个仓库中同时检出多个分支到独立目录,共享 .git 数据。配合自动 symlink node_modules 等大文件夹,零成本切换零额外磁盘占用。 -

-
-
- - -
-
-

核心功能

-

为多分支开发量身打造

-
-
-
🔀
-

多分支并行工作

-

一个项目同时打开多个分支,互不干扰。不用 stash,不用 clone 多份。

-
-
-
🔗
-

智能文件夹链接

-

自动链接 node_modules、.next、vendor 等构建产物,避免重复安装依赖。支持自定义路径。

-
-
-
📂
-

全局文件共享

-

.claude、CLAUDE.md、requirement-docs 等文件可配置为全局链接,在所有 worktree 中共享。

-
-
-
🏛️
-

Vault 挂载

-

将 Vault 知识库以 symlink 方式挂载到工作区根目录,实现跨 worktree 的文档共享。自动同步到所有子 worktree,黑名单保护关键文件。

-
-
-
📊
-

分支状态监控

-

实时显示提交数、未提交更改、是否合并到测试分支等信息,一目了然。

-
-
-
🚀
-

快速打开 IDE

-

一键用 VS Code、Cursor、IntelliJ IDEA 等打开任意 worktree,支持下拉快速切换编辑器。

-
-
-
💻
-

内置终端

-

每个 worktree 有独立的终端会话,支持多标签页、复制标签和全屏模式。切换 worktree 时终端状态自动保存恢复。

-
-
-
📦
-

安全归档

-

归档前自动检查未提交和未推送的代码,防止丢失。支持一键恢复。

-
-
-
🔌
-

多种克隆方式

-

支持 GitHub 简写、SSH 和 HTTPS 三种方式添加项目。

-
-
-
🪟
-

多窗口支持

-

每个 Workspace 可以在独立窗口中打开,同时操作多个工作区,互不干扰。

-
-
-
🔍
-

智能文件夹扫描

-

自动扫描项目中可以被链接的大文件夹(如 node_modules、.next、dist 等),一键添加到链接配置。

-
-
-
⌨️
-

快捷键支持

-

Escape 退出全屏/关闭菜单,终端支持全屏模式,操作更高效。

-
-
-
🔄
-

自动更新

-

内置自动更新功能,新版本自动推送通知,一键升级到最新版本。

-
-
-
-

后台远程同步

-

Git 远程状态后台刷新,本地数据秒级加载。切换分支不卡顿,操作按钮按需禁用,同步进度实时可见。

-
-
-
🎙️
-

语音输入终端 macOS

-

对着麦克风说话,语音自动转写为文字注入终端。支持语音命令(回车、删除、清空等),2秒静默自动停止,完全本地处理。

-
-
-
-

AI 语音精炼

-

集成 Qwen 大模型自动优化语音转写结果。去除口语化填充词,修正语法,保持原始语义不变。

-
-
-
📱
-

QR 码分享

-

分享面板自动生成二维码,手机扫一扫即可打开。支持密码内嵌链接,扫码后自动登录无需手动输入。

-
-
-
🌐
-

浏览器分享

-

一键分享工作区,同事通过浏览器即可访问。支持局域网直连和 ngrok 外网穿透,保留分享密码保护和浏览器终端。

-
-
-
-
- - -
-
-
-

浏览器分享(LAN / ngrok)

-

无需安装,浏览器打开即可访问分享中的工作区

-
-
-
-
📡
-

局域网直连

-

同一网络内直接分享,最低延迟。设置端口和密码,分享给局域网内的同事即可。

-
-
-
🌍
-

ngrok 穿透

-

配置 ngrok token 后一键开启外网隧道,生成公网地址。适合已有 ngrok 账号的用户。

-
-
-
-
-
📱
-

QR 码分享

-

分享面板自动生成二维码,手机扫码即可打开工作区。

-
-
-
🔑
-

密码内嵌链接

-

生成带密码的分享链接,打开后自动登录,无需手动输入密码。

-
-
-
👥
-

客户端管理

-

实时查看所有连接的客户端,支持单独踢出指定会话。

-
-
- -
-
- - -
-
-

三步上手

-

简单几步,开始多分支并行开发

-
-
-
1
-
-

创建工作区(Workspace)

-

启动应用后,点击左上角的"新建 Workspace"按钮,选择一个目录作为工作区根目录。应用会自动初始化目录结构并识别其中的 Git 项目。

-
    -
  • 新建工作区:选择一个空目录,应用会自动创建 projects/worktrees/ 目录
  • -
  • 导入已有项目:选择包含 Git 项目的目录,应用会自动检测并导入
  • -
  • 也可以通过 GitHub 简写(如 owner/repo)、SSH 或 HTTPS 地址添加新项目
  • -
-
-
-
-
2
-
-

新建 Worktree(工作分支)

-

点击侧边栏的"+"按钮,在弹出的对话框中完成以下设置:

-
    -
  • 输入分支名(如 feature/login),支持 feature/hotfix/ 等命名规范
  • -
  • 选择需要包含的项目(可以多选)
  • -
  • 选择基于哪个分支创建(默认 main)
  • -
  • node_modules 等配置好的文件夹会自动从主仓库链接,无需重新安装依赖
  • -
-
-
-
-
3
-
-

开始开发

-

在列表中点击切换 worktree,用你喜欢的 IDE 一键打开。各分支的代码、依赖、终端完全隔离,互不干扰。

-
    -
  • 点击 IDE 按钮,快速用 VS Code、Cursor、IDEA 等打开项目
  • -
  • 使用内置终端运行命令,无需切换窗口
  • -
  • 实时查看分支状态:提交数、未推送变更、是否已合并到测试分支
  • -
  • 完成开发后归档 worktree,保持工作区整洁
  • -
-
-
-
- - -
-

工作区目录结构

-
-
-workspace/
-├── .worktree-manager.json        # 工作区配置
-├── projects/                     # 主仓库(main 分支)
-│   ├── frontend/
-│   └── backend/
-├── worktrees/                    # Worktree 目录(自动创建)
-│   ├── feature-login/
-│   │   ├── projects/
-│   │   │   ├── frontend/         # 独立分支的工作目录
-│   │   │   └── backend/
-│   │   ├── .claude → ../../.claude   # 自动链接
-│   │   └── CLAUDE.md → ../../CLAUDE.md
-│   └── hotfix-bug/
-│       └── ...
-├── .claude/
-└── CLAUDE.md
-
-
- - -
- - 🛠️ 从源码构建(面向开发者) - -
-# 环境要求:Node.js 20+、Rust 1.70+、Git 2.0+ - -# 克隆项目 -git clone https://github.com/guoyongchang/worktree-manager.git -cd worktree-manager - -# 安装依赖 -npm install - -# 开发模式运行 -npm run tauri dev - -# 构建生产版本 -npm run tauri build -
-
-
-
- - -
-
-

插件生态

-

AI 助手深度集成,知识自动归档

- -
- -
-
-
🤖
-

MCP 插件

-
-

通过 Model Context Protocol (MCP) 将 Worktree Manager 暴露给 Claude Code、Codex、Cursor 等 AI 助手。AI 可以直接读取工作区状态、创建 worktree、执行 Git 操作。

-
-
- - 工作区 / worktree 状态查询 -
-
- - 创建、归档 worktree -
-
- - Git commit / push 操作 -
-
- - 分支差异和变更文件查看 -
-
-
- npx -y @worktree-manager/mcp install -
-
- -
- - -
-
-
🏛️
-
-

Vault 知识库集成

-

连接 Obsidian Vault 知识库

-
-
-
-
-
1. 选择 Vault
-

在设置中选择 Vault 目录,Worktree Manager 自动挂载 Vault 文件到工作区根目录。

-
-
-
2. 自动同步
-

Vault 挂载状态自动同步到所有子 worktree,通过 symlink 实现零拷贝共享。

-
-
-
3. 安全保护
-

内置黑名单防止挂载关键文件,冲突检测避免覆盖现有文件,挂载前自动验证。

-
-
-
-
-
- - -
-
-

技术栈

-

基于现代技术构建

-
-
- 框架 - Tauri 2 -
-
- 前端 - React 19 -
-
- 语言 - TypeScript + Rust -
-
- 样式 - Tailwind CSS 4 -
-
- UI 组件 - Radix UI -
-
- 构建 - Vite 7 -
-
-
-
- - - - - - - - diff --git a/docs/screenshots/main-view.png b/docs/screenshots/main-view.png deleted file mode 100644 index 4bf0793..0000000 Binary files a/docs/screenshots/main-view.png and /dev/null differ diff --git a/docs/screenshots/new-worktree.png b/docs/screenshots/new-worktree.png deleted file mode 100644 index 60a3936..0000000 Binary files a/docs/screenshots/new-worktree.png and /dev/null differ diff --git a/docs/screenshots/remote-access.png b/docs/screenshots/remote-access.png deleted file mode 100644 index da4f4a1..0000000 Binary files a/docs/screenshots/remote-access.png and /dev/null differ diff --git a/docs/screenshots/use-example-1.png b/docs/screenshots/use-example-1.png deleted file mode 100644 index b6bdc7a..0000000 Binary files a/docs/screenshots/use-example-1.png and /dev/null differ diff --git a/docs/screenshots/voice-and-refine.png b/docs/screenshots/voice-and-refine.png deleted file mode 100644 index 78ca7ff..0000000 Binary files a/docs/screenshots/voice-and-refine.png and /dev/null differ diff --git a/docs/screenshots/voice-setting.png b/docs/screenshots/voice-setting.png deleted file mode 100644 index ca38e0b..0000000 Binary files a/docs/screenshots/voice-setting.png and /dev/null differ diff --git a/docs/superpowers/plans/2026-05-26-worktree-status-badge.md b/docs/superpowers/plans/2026-05-26-worktree-status-badge.md deleted file mode 100644 index d212238..0000000 --- a/docs/superpowers/plans/2026-05-26-worktree-status-badge.md +++ /dev/null @@ -1,691 +0,0 @@ -# Worktree 可切换状态 Badge 实现计划 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 将 Sidebar 中 worktree 的状态 Badge 改为可点击的手动状态切换,支持 in_progress / in_review / completed / paused 四种状态。 - -**Architecture:** 状态存储在 workspace 的 `.worktree-manager.json` 中(`worktree_statuses` 字段)。后端提供 `update_worktree_status` 命令读写状态,前端将 `StatusBadge` 改为 DropdownMenu。 - -**Tech Stack:** Rust (Tauri), TypeScript/React, Tailwind CSS, Radix UI DropdownMenu - ---- - -## 涉及文件总览 - -| 文件 | 职责 | -|------|------| -| `src-tauri/src/types.rs` | Rust 类型:`WorktreeStatus` 枚举,`WorkspaceConfig` / `WorktreeListItem` 扩展 | -| `src-tauri/src/commands/worktree.rs` | `update_worktree_status` 命令 + `scan_worktrees_dir` 读取状态 | -| `src-tauri/src/lib.rs` | `generate_handler!` 注册新命令 | -| `src-tauri/src/http_server.rs` | `h_update_worktree_status` HTTP handler | -| `src-tauri/src/http_server/routing.rs` | 注册 `/api/update_worktree_status` 路由 | -| `src/types.ts` | TypeScript 类型:`WorktreeStatus` + `WorktreeListItem` 扩展 | -| `src/lib/backend.ts` | `updateWorktreeStatus()` 前端调用函数 | -| `src/hooks/useWorkspace.ts` | `updateWorktreeStatus` hook 方法 | -| `src/components/worktree-sidebar/ExpandedSidebar.tsx` | `StatusBadge` 改为可点击 DropdownMenu | -| `src/locales/zh-CN.json` | 中文翻译 | -| `src/locales/en-US.json` | 英文翻译 | -| `docs/generated/command-contracts.md` | 命令契约文档更新 | - ---- - -### Task 1: Rust 类型定义 - -**Files:** -- Modify: `src-tauri/src/types.rs` - -- [ ] **Step 1: 添加 `WorktreeStatus` 枚举** - - 在 `WorkspaceConfig` 定义之前插入: - - ```rust - #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] - #[serde(rename_all = "snake_case")] - pub enum WorktreeStatus { - InProgress, - InReview, - Completed, - Paused, - } - ``` - -- [ ] **Step 2: `WorkspaceConfig` 添加 `worktree_statuses` 字段** - - 在 `archived_worktrees` 字段之后添加: - - ```rust - #[serde(default)] - pub worktree_statuses: HashMap, // worktree_name -> status - ``` - -- [ ] **Step 3: `WorkspaceConfig` Default 实现同步** - - 在 `archived_worktrees: vec![],` 之后添加: - - ```rust - worktree_statuses: HashMap::new(), - ``` - -- [ ] **Step 4: `WorktreeListItem` 添加 `status` 字段** - - 在 `is_archived` 之后添加: - - ```rust - pub status: Option, - ``` - -- [ ] **Step 5: Commit** - - ```bash - git add src-tauri/src/types.rs - git commit -m "feat(types): add WorktreeStatus enum and fields" - ``` - ---- - -### Task 2: 后端命令实现 - -**Files:** -- Modify: `src-tauri/src/commands/worktree.rs` - -- [ ] **Step 1: `scan_worktrees_dir` 读取状态** - - 找到 `scan_worktrees_dir` 函数中创建 `WorktreeListItem` 的代码块(约第 578 行): - - ```rust - result.push(WorktreeListItem { - name, - display_name, - path: normalize_path(&path.to_string_lossy()), - is_archived, - status: config.worktree_statuses.get(&name).cloned(), - projects, - }); - ``` - - 在 `is_archived` 之后、`projects` 之前添加 `status` 字段。 - -- [ ] **Step 2: 添加 `update_worktree_status_impl` 函数** - - 在 `list_worktrees_impl` 函数之后(约第 431 行之后)添加: - - ```rust - pub fn update_worktree_status_impl( - window_label: &str, - worktree_name: String, - status: WorktreeStatus, - ) -> Result<(), String> { - let (workspace_path, mut config) = - get_window_workspace_config(window_label).ok_or("No workspace selected")?; - - config.worktree_statuses.insert(worktree_name, status); - - crate::commands::workspace::save_workspace_config_impl( - window_label, - config, - ) - } - ``` - -- [ ] **Step 3: 添加 `update_worktree_status` Tauri command** - - 在 `update_worktree_status_impl` 之后添加: - - ```rust - #[tauri::command] - pub(crate) async fn update_worktree_status( - window: tauri::Window, - worktree_name: String, - status: WorktreeStatus, - workspace_path: Option, - ) -> Result<(), String> { - if let Some(path) = workspace_path { - let mut config = crate::config::load_workspace_config(&path); - config.worktree_statuses.insert(worktree_name, status); - crate::commands::workspace::save_workspace_config_by_path(path, config) - } else { - let label = window.label().to_string(); - tokio::task::spawn_blocking(move || { - update_worktree_status_impl(&label, worktree_name, status) - }) - .await - .map_err(|e| format!("Task join error: {}", e))? - } - } - ``` - -- [ ] **Step 4: Commit** - - ```bash - git add src-tauri/src/commands/worktree.rs - git commit -m "feat(backend): add update_worktree_status command" - ``` - ---- - -### Task 3: Tauri 命令注册 - -**Files:** -- Modify: `src-tauri/src/lib.rs` - -- [ ] **Step 1: 导入 `update_worktree_status`** - - 在 `src-tauri/src/lib.rs` 中找到 `use crate::commands::worktree::` 导入块,确认 `update_worktree_status` 是否在导入列表中。如果不存在,添加它。 - - 查找模式:`update_worktree_status` 是否已经在 `use crate::commands::worktree::{` 中。如果没有,添加。 - - 在 `generate_handler!` 宏中,在 `get_main_workspace_status,` 之后添加: - - ```rust - update_worktree_status, - ``` - -- [ ] **Step 2: Commit** - - ```bash - git add src-tauri/src/lib.rs - git commit -m "feat(tauri): register update_worktree_status command" - ``` - ---- - -### Task 4: HTTP 服务器支持 - -**Files:** -- Modify: `src-tauri/src/http_server.rs` -- Modify: `src-tauri/src/http_server/routing.rs` - -- [ ] **Step 1: HTTP server 添加 handler** - - 在 `src-tauri/src/http_server.rs` 中,找到 `-- Worktree operations --` 区域(约第 299 行之后),在 `h_list_worktrees` 之后添加: - - ```rust - async fn h_update_worktree_status(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let worktree_name = args["worktree_name"].as_str().unwrap_or("").to_string(); - let status: crate::types::WorktreeStatus = match serde_json::from_value(args["status"].clone()) { - Ok(s) => s, - Err(e) => { - return (StatusCode::BAD_REQUEST, format!("Invalid status: {}", e)).into_response() - } - }; - result_ok(crate::commands::worktree::update_worktree_status_impl( - &sid, worktree_name, status, - )) - } - ``` - - 同时确认 `http_server.rs` 顶部导入了 `update_worktree_status_impl`。在 `use crate::{` 块中添加: - - ```rust - update_worktree_status_impl, - ``` - -- [ ] **Step 2: HTTP routing 注册路由** - - 在 `src-tauri/src/http_server/routing.rs` 中: - - 1. 导入列表中添加 `h_update_worktree_status` - 2. `build_api_router` 函数中,在 `.route("/api/list_worktrees", post(h_list_worktrees))` 之后添加: - - ```rust - .route("/api/update_worktree_status", post(h_update_worktree_status)) - ``` - -- [ ] **Step 3: Commit** - - ```bash - git add src-tauri/src/http_server.rs src-tauri/src/http_server/routing.rs - git commit -m "feat(http): add update_worktree_status endpoint" - ``` - ---- - -### Task 5: 前端类型定义 - -**Files:** -- Modify: `src/types.ts` - -- [ ] **Step 1: 添加 `WorktreeStatus` 类型** - - 在 `WorktreeListItem` 定义之前添加: - - ```typescript - export type WorktreeStatus = 'in_progress' | 'in_review' | 'completed' | 'paused'; - ``` - -- [ ] **Step 2: `WorktreeListItem` 添加 `status` 字段** - - 在 `is_archived: boolean;` 之后添加: - - ```typescript - status?: WorktreeStatus; - ``` - -- [ ] **Step 3: Commit** - - ```bash - git add src/types.ts - git commit -m "feat(types): add WorktreeStatus frontend type" - ``` - ---- - -### Task 6: 前端 Backend 调用 - -**Files:** -- Modify: `src/lib/backend.ts` - -- [ ] **Step 1: 添加 `updateWorktreeStatus` 函数** - - 在文件末尾(或与其他 worktree 函数一起)添加: - - ```typescript - export async function updateWorktreeStatus( - worktreeName: string, - status: import('../types').WorktreeStatus, - workspacePath?: string, - ): Promise { - const extra = workspacePath ? { workspacePath } : {}; - return callBackend('update_worktree_status', { - worktreeName, - status, - ...extra, - }); - } - ``` - -- [ ] **Step 2: Commit** - - ```bash - git add src/lib/backend.ts - git commit -m "feat(backend): add updateWorktreeStatus frontend call" - ``` - ---- - -### Task 7: useWorkspace Hook - -**Files:** -- Modify: `src/hooks/useWorkspace.ts` - -- [ ] **Step 1: 导入 `updateWorktreeStatus`** - - 在 `callBackend, fetchProjectRemote, isTauri` 导入之后添加: - - ```typescript - import { updateWorktreeStatus as updateWorktreeStatusBackend } from '../lib/backend'; - ``` - - 或者在已有的 `callBackend` 导入行后添加 `updateWorktreeStatus`。 - -- [ ] **Step 2: 在接口定义中添加方法** - - 在 `UseWorkspaceReturn` 接口中,在 `getLockedWorktrees` 之后添加: - - ```typescript - updateWorktreeStatus: (worktreeName: string, status: import('../types').WorktreeStatus) => Promise; - ``` - -- [ ] **Step 3: 在 hook 中实现方法** - - 在 `getLockedWorktrees` 回调之后、`return` 之前添加: - - ```typescript - const updateWorktreeStatus = useCallback(async (worktreeName: string, status: import('../types').WorktreeStatus) => { - try { - const extra = explicitPath ? { workspacePath: explicitPath } : {}; - await updateWorktreeStatusBackend(worktreeName, status, explicitPath); - await loadData(); - } catch (e) { - setError(String(e)); - throw e; - } - }, [explicitPath, loadData]); - ``` - - 注意:如果 Step 1 选择了直接导入 `updateWorktreeStatus`,则这里需要避免命名冲突,用不同的变量名。 - -- [ ] **Step 4: 在返回值中添加** - - 在 `getLockedWorktrees,` 之后添加: - - ```typescript - updateWorktreeStatus, - ``` - -- [ ] **Step 5: Commit** - - ```bash - git add src/hooks/useWorkspace.ts - git commit -m "feat(hook): add updateWorktreeStatus to useWorkspace" - ``` - ---- - -### Task 8: Sidebar UI 组件 - -**Files:** -- Modify: `src/components/worktree-sidebar/ExpandedSidebar.tsx` - -- [ ] **Step 1: 导入 DropdownMenu 组件** - - 在现有的 DropdownMenu 导入块中确认已有 `DropdownMenuItem`,如果没有则添加: - - ```typescript - import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - } from '@/components/ui/dropdown-menu'; - ``` - - 注意:`DropdownMenuItem` 可能还不存在,需要确认并添加。 - -- [ ] **Step 2: 修改 `StatusBadge` 组件为可点击** - - 将现有的 `StatusBadge` 组件改为接收更多属性并支持点击: - - ```typescript - const StatusBadge: FC<{ - label: string; - tooltip: string; - tone: 'amber' | 'blue' | 'green' | 'gray' | 'purple'; - clickable?: boolean; - onClick?: () => void; - }> = ({ label, tooltip, tone, clickable, onClick }) => { - const toneClasses: Record = { - blue: 'text-[var(--color-accent)]/80 bg-[var(--color-accent)]/10 border border-[var(--color-accent)]/20', - amber: 'text-[var(--color-warning)]/80 bg-[var(--color-warning)]/10 border border-[var(--color-warning)]/20', - green: 'text-emerald-400 bg-emerald-900/30 border border-emerald-800/30', - gray: 'text-gray-400 bg-gray-800/50 border border-gray-700/30', - purple: 'text-purple-400 bg-purple-900/30 border border-purple-800/30', - }; - - const badge = ( - {label} - ); - - return ( - - - {badge} - {tooltip} - - - ); - }; - ``` - -- [ ] **Step 3: 添加 `WorktreeStatusBadge` 组件** - - 在 `StatusBadge` 之后添加新的状态切换组件: - - ```typescript - const STATUS_CONFIG: Record = { - in_progress: { label: 'sidebar.statusInProgress', tooltip: 'sidebar.statusInProgressTooltip', tone: 'blue' }, - in_review: { label: 'sidebar.statusInReview', tooltip: 'sidebar.statusInReviewTooltip', tone: 'purple' }, - completed: { label: 'sidebar.statusCompleted', tooltip: 'sidebar.statusCompletedTooltip', tone: 'green' }, - paused: { label: 'sidebar.statusPaused', tooltip: 'sidebar.statusPausedTooltip', tone: 'gray' }, - }; - - const WorktreeStatusBadge: FC<{ - status?: import('../../types').WorktreeStatus; - onStatusChange?: (status: import('../../types').WorktreeStatus) => void; - }> = ({ status, onStatusChange }) => { - const { t } = useTranslation(); - const effectiveStatus = status || 'in_progress'; - const config = STATUS_CONFIG[effectiveStatus]; - - return ( - - - - {t(config.label)} - - - - {(Object.keys(STATUS_CONFIG) as import('../../types').WorktreeStatus[]).map((s) => ( - onStatusChange?.(s)} - > - - {t(STATUS_CONFIG[s].label)} - - ))} - - - ); - }; - ``` - -- [ ] **Step 4: 替换原有的状态 Badge 渲染逻辑** - - 找到 active worktree 列表中的这段代码(约第 823-829 行): - - ```tsx - {(() => { - const allMerged = worktree.projects.length > 0 && worktree.projects.every(p => p.is_merged_to_base); - if (allMerged) { - return ; - } - return ; - })()} - ``` - - 替换为: - - ```tsx - onUpdateWorktreeStatus?.(worktree.name, status)} - /> - ``` - -- [ ] **Step 5: 添加 `onUpdateWorktreeStatus` prop** - - 1. 在 `ExpandedSidebarProps` 接口中添加: - - ```typescript - onUpdateWorktreeStatus?: (worktreeName: string, status: import('../../types').WorktreeStatus) => void; - ``` - - 2. 在 `ExpandedSidebar` 组件解构参数中添加 `onUpdateWorktreeStatus` - - 3. 在 `WorktreeList` 组件的 props 和解构参数中添加 `onUpdateWorktreeStatus` - - 4. 在 `WorktreeList` 的 `SortableWorktreeItem` 渲染中传递该 prop - -- [ ] **Step 6: Commit** - - ```bash - git add src/components/worktree-sidebar/ExpandedSidebar.tsx - git commit -m "feat(ui): make status badge clickable with dropdown" - ``` - ---- - -### Task 9: 连接 WorktreeSidebar 到 useWorkspace - -**Files:** -- Modify: `src/components/WorktreeSidebar.tsx`(或 `App.tsx` 中 WorktreeSidebar 的使用处) - -- [ ] **Step 1: 从 useWorkspace 中解构 `updateWorktreeStatus`** - - 找到 `WorktreeSidebar` 组件(或 App.tsx 中使用 useWorkspace 的地方),在 `getLockedWorktrees` 之后解构: - - ```typescript - updateWorktreeStatus, - ``` - -- [ ] **Step 2: 将 `updateWorktreeStatus` 传递给 `ExpandedSidebar`(或 `WorktreeSidebar`)** - - 在组件 props 中传递: - - ```tsx - onUpdateWorktreeStatus={updateWorktreeStatus} - ``` - - 如果 `WorktreeSidebar` 是一个 wrapper,确保它也透传了这个 prop 到 `ExpandedSidebar`。 - -- [ ] **Step 3: Commit** - - ```bash - git add src/components/WorktreeSidebar.tsx # 或 App.tsx - git commit -m "feat: wire up updateWorktreeStatus to sidebar" - ``` - ---- - -### Task 10: 国际化翻译 - -**Files:** -- Modify: `src/locales/zh-CN.json` -- Modify: `src/locales/en-US.json` - -- [ ] **Step 1: 中文翻译** - - 在 `src/locales/zh-CN.json` 中,在 `sidebar.pausedTooltip` 之后添加: - - ```json - "sidebar.statusInProgress": "进行中", - "sidebar.statusInProgressTooltip": "该工作区正在进行中", - "sidebar.statusInReview": "评审中", - "sidebar.statusInReviewTooltip": "该工作区正在评审中", - "sidebar.statusCompleted": "已完成", - "sidebar.statusCompletedTooltip": "该工作区已完成", - "sidebar.statusPaused": "暂停", - "sidebar.statusPausedTooltip": "该工作区已暂停" - ``` - -- [ ] **Step 2: 英文翻译** - - 在 `src/locales/en-US.json` 中,在 `sidebar.pausedTooltip` 之后添加: - - ```json - "sidebar.statusInProgress": "In Progress", - "sidebar.statusInProgressTooltip": "This worktree is in progress", - "sidebar.statusInReview": "In Review", - "sidebar.statusInReviewTooltip": "This worktree is in review", - "sidebar.statusCompleted": "Completed", - "sidebar.statusCompletedTooltip": "This worktree is completed", - "sidebar.statusPaused": "Paused", - "sidebar.statusPausedTooltip": "This worktree is paused" - ``` - -- [ ] **Step 3: Commit** - - ```bash - git add src/locales/zh-CN.json src/locales/en-US.json - git commit -m "feat(i18n): add worktree status translations" - ``` - ---- - -### Task 11: 更新命令契约文档 - -**Files:** -- Modify: `docs/generated/command-contracts.md` - -- [ ] **Step 1: 添加 `update_worktree_status` 命令文档** - - 在文档的 Worktree 操作区域,在 `list_worktrees` 之后添加: - - ```markdown - ### `update_worktree_status` - - **Type:** mutation - **Args:** - - `worktree_name: string` — worktree 名称 - - `status: "in_progress" | "in_review" | "completed" | "paused"` — 新状态 - - `workspace_path?: string` — 可选,指定 workspace 路径(browser 模式用) - - **Returns:** `void` - - **Description:** 更新指定 worktree 的状态标记。状态持久化到 workspace 的 `.worktree-manager.json` 中。 - - **前端调用:** - ```typescript - updateWorktreeStatus(worktreeName, status, workspacePath?) - ``` - ``` - - 注意:此文档由 `npm run contracts` 自动生成或手动维护,确认格式与其他命令一致。 - -- [ ] **Step 2: Commit** - - ```bash - git add docs/generated/command-contracts.md - git commit -m "docs: add update_worktree_status to command contracts" - ``` - ---- - -### Task 12: 编译验证 - -**Files:** -- N/A - -- [ ] **Step 1: Rust 编译检查** - - ```bash - cd src-tauri && cargo check - ``` - - Expected: 0 errors, 0 warnings related to our changes - -- [ ] **Step 2: TypeScript 编译检查** - - ```bash - npx tsc --noEmit - ``` - - Expected: 0 errors - -- [ ] **Step 3: Commit(如果无错误)** - - 如果编译通过,无需额外 commit。如果有修复,单独 commit。 - ---- - -## Self-Review 检查清单 - -**Spec 覆盖检查:** -- [x] `WorktreeStatus` 枚举(4 种状态)— Task 1 -- [x] `WorkspaceConfig.worktree_statuses` 存储 — Task 1 -- [x] `WorktreeListItem.status` 字段 — Task 1, Task 2 -- [x] `update_worktree_status` 后端命令 — Task 2 -- [x] Tauri handler 注册 — Task 3 -- [x] HTTP 端点 — Task 4 -- [x] 前端类型 — Task 5 -- [x] 前端 backend 调用 — Task 6 -- [x] useWorkspace hook — Task 7 -- [x] 可点击 DropdownMenu Badge — Task 8 -- [x] 状态透传连接 — Task 9 -- [x] i18n 翻译 — Task 10 -- [x] 命令契约文档 — Task 11 - -**Placeholder 扫描:** 无 TBD/TODO/"implement later" - -**类型一致性检查:** -- Rust `WorktreeStatus` 序列化为 snake_case(`in_progress` 等) -- TypeScript `WorktreeStatus` 使用相同的字符串值 -- HTTP handler 使用 `serde_json::from_value` 反序列化 -- 所有地方的 `tone` 值为 `'amber' | 'blue' | 'green' | 'gray' | 'purple'`,与 CSS class 映射一致 diff --git a/docs/superpowers/specs/2026-05-26-worktree-color-tag-design.md b/docs/superpowers/specs/2026-05-26-worktree-color-tag-design.md deleted file mode 100644 index 9b88078..0000000 --- a/docs/superpowers/specs/2026-05-26-worktree-color-tag-design.md +++ /dev/null @@ -1,129 +0,0 @@ -# Worktree 颜色标记(Color Tag)设计 - -## 背景 - -之前实现的 worktree 状态系统(进行中/评审中/已完成/暂停)已被用户否决。用户希望改为类似 macOS Finder 标签的自由颜色标记系统: -- 右键 worktree → 选择颜色(6 种固定色) -- Branch Icon 随颜色染色 -- 无颜色 = 默认态(不显示任何标记) - -## 设计 - -### 数据模型 - -**Rust (`src-tauri/src/types.rs`)** -- 删除 `WorktreeStatus` 枚举 -- 新增 `WorktreeColor` 枚举:`red` | `orange` | `yellow` | `green` | `blue` | `purple` -- `WorkspaceConfig.worktree_statuses` → `worktree_colors: HashMap` -- `WorktreeListItem.status: Option` → `color: Option` - -**TypeScript (`src/types.ts`)** -- 删除 `WorktreeStatus` 类型 -- 新增 `WorktreeColor = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'` -- `WorktreeListItem.status` → `color?: WorktreeColor` - -### 颜色定义(macOS Finder 风格) - -| 颜色 | Hex | Tailwind 映射 | -|------|-----|--------------| -| red | #FF453A | `text-red-400` | -| orange | #FF9500 | `text-orange-400` | -| yellow | #FFCC00 | `text-yellow-400` | -| green | #30D158 | `text-emerald-400` | -| blue | 主题 accent | `text-[var(--color-accent)]` | -| purple | #BF5AF2 | `text-purple-400` | - -### UI 交互 - -**ExpandedSidebar** -- 移除 `WorktreeStatusBadge` DropdownMenu 组件 -- Branch Icon (`GitBranch`) 根据 `worktree.color` 染色 - - 无颜色时保持默认 `text-[var(--color-accent)]` - - 有颜色时使用对应 Tailwind class -- 右键菜单(ContextMenus)添加颜色选择区域 - -**CollapsedSidebar** -- Branch Icon 同样根据 `worktree.color` 染色 -- 无颜色时保持默认 - -**右键菜单** -``` -[归档] ← 已有 -───────── -🎨 标记颜色 -● ● ● ● ● ● [清除] -``` -- 菜单中新增"标记颜色"区域(或直接使用圆点行) -- 6 个颜色圆点横排 + "清除"按钮 -- 点击后关闭菜单并更新颜色 - -### 后端命令 - -**`update_worktree_color`**(替换 `update_worktree_status`) -- 参数:`workspace_path?: string`, `worktree_name: string`, `color: WorktreeColor | null` -- 行为:读取 workspace config → 更新 `worktree_colors` 映射(`null` 则删除 key)→ 保存 config - -### 双模式同步 - -修改已有命令: -- `backend.ts`:`updateWorktreeStatus()` → `updateWorktreeColor()` -- `lib.rs`:`update_worktree_status` → `update_worktree_color` -- `http_server.rs`:`h_update_worktree_status` → `h_update_worktree_color` -- `routing.rs`:路由同步更新 - -### 持久化 - -`worktree_colors` 字段存于 `.worktree-manager.json` 中。 - -示例: -```json -{ - "name": "My Workspace", - "worktrees_dir": "worktrees", - "projects": [...], - "archived_worktrees": ["wt-old"], - "worktree_colors": { - "feature-123": "green", - "feature-456": "purple" - } -} -``` - -### 国际化 - -新增翻译 key: -- `contextMenu.setColor`: "标记颜色" / "Set Color" -- `contextMenu.removeColor`: "清除标记" / "Remove Color" - -删除翻译 key(不再使用): -- `sidebar.statusInProgress` 及其 tooltip -- `sidebar.statusInReview` 及其 tooltip -- `sidebar.statusCompleted` 及其 tooltip -- `sidebar.statusPaused` 及其 tooltip - -### 涉及文件 - -| 文件 | 修改内容 | -|------|---------| -| `src-tauri/src/types.rs` | `WorktreeStatus` → `WorktreeColor`,字段重命名 | -| `src-tauri/src/commands/worktree.rs` | `update_worktree_status` → `update_worktree_color`,读取 color | -| `src-tauri/src/lib.rs` | handler 重命名 | -| `src-tauri/src/http_server.rs` | handler 重命名 | -| `src-tauri/src/http_server/routing.rs` | 路由重命名 | -| `src/types.ts` | `WorktreeStatus` → `WorktreeColor` | -| `src/lib/backend.ts` | `updateWorktreeStatus` → `updateWorktreeColor` | -| `src/hooks/useWorkspace.ts` | hook 方法重命名,乐观更新 | -| `src/components/worktree-sidebar/ExpandedSidebar.tsx` | 移除 StatusBadge,Icon 染色,右键菜单透传 color setter | -| `src/components/worktree-sidebar/CollapsedSidebar.tsx` | Icon 染色 | -| `src/components/ContextMenus.tsx` | 右键菜单添加颜色选择区域 | -| `src/components/WorktreeSidebar.tsx` | prop 透传 | -| `src/components/WorkspaceCell.tsx` | 连接 color setter | -| `src/components/worktree-sidebar/types.ts` | prop 重命名 | -| `src/locales/zh-CN.json` | 翻译调整 | -| `src/locales/en-US.json` | 翻译调整 | -| `docs/generated/command-contracts.md` | 命令名更新 | - -### 兼容处理 - -- `serde(default)` 保证旧 config 无 `worktree_colors` 字段时不报错 -- 旧 `worktree_statuses` 字段保留 `#[serde(default)]` 和 `skip_serializing_if`,读取时忽略,不写入 diff --git a/docs/superpowers/specs/2026-05-26-worktree-status-badge-design.md b/docs/superpowers/specs/2026-05-26-worktree-status-badge-design.md deleted file mode 100644 index 7f1a8b8..0000000 --- a/docs/superpowers/specs/2026-05-26-worktree-status-badge-design.md +++ /dev/null @@ -1,97 +0,0 @@ -# Worktree 可切换状态 Badge 设计 - -## 背景 - -当前 Sidebar 中每个 active worktree 右侧的状态 Badge("进行中"/"已完成")是基于 `is_merged_to_base` 自动判断的。这个逻辑存在两个问题: -1. 刚创建的 worktree 还没合并任何项目,会被错误标记 -2. 一个 worktree 里有多个项目时,可能只开发其中一个,自动判断会要求所有项目都 merged - -因此状态应该是用户主观的工作流标记,而不是基于 git 状态的自动推断。 - -## 设计 - -### 数据模型 - -**Rust (`src-tauri/src/types.rs`)** -- 新增 `WorktreeStatus` 枚举:`in_progress` | `in_review` | `completed` | `paused` -- `WorkspaceConfig` 添加 `worktree_statuses: HashMap`(与现有 `archived_worktrees` 同级,存于 `.worktree-manager.json`) -- `WorktreeListItem` 添加 `status: Option` - -**TypeScript (`src/types.ts`)** -- 新增 `WorktreeStatus = 'in_progress' | 'in_review' | 'completed' | 'paused'` -- `WorktreeListItem` 同步添加 `status?: WorktreeStatus` - -### UI 交互 - -Sidebar 中每个 active worktree 右侧的 `StatusBadge` 改为可点击的 `DropdownMenu`: -- 点击 Badge 弹出下拉,列出 4 个状态选项(每个带对应颜色圆点标识) -- 选中后立即调用 `update_worktree_status` 命令更新 -- 成功后刷新 worktree 列表 -- 从未设置过状态的 worktree 默认显示为"进行中" - -颜色映射: -- `in_progress` — 蓝色(`var(--color-accent)`) -- `in_review` — 紫色(`#a855f7`) -- `completed` — 绿色(`emerald-400`) -- `paused` — 灰色(`gray-400`) - -### 后端命令 - -**`update_worktree_status`** -- 参数:`workspace_path?: string`, `worktree_name: string`, `status: WorktreeStatus` -- 行为:读取 workspace config → 更新 `worktree_statuses` 映射 → 保存 config -- 返回:`void` - -**`scan_worktrees_dir`**(已有命令,修改) -- 在构建 `WorktreeListItem` 时,从 `config.worktree_statuses` 中查找对应 worktree 的状态 -- 如果找到则填充到 `WorktreeListItem.status`,否则为 `None` - -### 双模式同步 - -新增命令需同步三处: -1. `src/lib/backend.ts` — 添加 `updateWorktreeStatus()` 调用函数 -2. `src-tauri/src/lib.rs` — `generate_handler!` 宏中注册 -3. `src-tauri/src/http_server.rs` + `routing.rs` — 添加 HTTP handler 和路由 - -### 持久化 - -状态存储在 workspace 根目录的 `.worktree-manager.json` 中,与 `archived_worktrees` 同级字段 `worktree_statuses`。 - -示例: -```json -{ - "name": "My Workspace", - "worktrees_dir": "worktrees", - "projects": [...], - "archived_worktrees": ["wt-old"], - "worktree_statuses": { - "feature-123": "in_review", - "feature-456": "completed" - } -} -``` - -### 国际化 - -新增翻译 key: -- `sidebar.statusInProgress` / `sidebar.statusInProgressTooltip` -- `sidebar.statusInReview` / `sidebar.statusInReviewTooltip` -- `sidebar.statusCompleted` / `sidebar.statusCompletedTooltip` -- `sidebar.statusPaused` / `sidebar.statusPausedTooltip` - -### 涉及文件 - -| 文件 | 修改内容 | -|------|---------| -| `src-tauri/src/types.rs` | 新增 `WorktreeStatus` 枚举,修改 `WorkspaceConfig` 和 `WorktreeListItem` | -| `src-tauri/src/commands/worktree.rs` | `scan_worktrees_dir` 读取状态,新增 `update_worktree_status` 命令 | -| `src-tauri/src/lib.rs` | `generate_handler!` 注册新命令 | -| `src-tauri/src/http_server.rs` | 新增 `h_update_worktree_status` handler | -| `src-tauri/src/http_server/routing.rs` | 注册 `/api/update_worktree_status` 路由 | -| `src/types.ts` | 新增 `WorktreeStatus` 类型,修改 `WorktreeListItem` | -| `src/lib/backend.ts` | 新增 `updateWorktreeStatus()` 函数 | -| `src/hooks/useWorkspace.ts` | 新增 `updateWorktreeStatus` 方法 | -| `src/components/worktree-sidebar/ExpandedSidebar.tsx` | `StatusBadge` 改为可点击 DropdownMenu | -| `src/locales/zh-CN.json` | 新增状态相关翻译 | -| `src/locales/en-US.json` | 新增状态相关翻译 | -| `docs/generated/command-contracts.md` | 更新命令契约文档 | diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index 23218d6..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,105 +0,0 @@ -// @ts-check -import js from "@eslint/js"; -import tseslint from "typescript-eslint"; -import boundaries from "eslint-plugin-boundaries"; -import reactHooks from "eslint-plugin-react-hooks"; - -export default tseslint.config( - js.configs.recommended, - ...tseslint.configs.recommended, - { - files: ["src/**/*.{ts,tsx}"], - plugins: { - boundaries, - "react-hooks": reactHooks, - }, - settings: { - "boundaries/elements": [ - { - type: "components", - pattern: ["src/components/**/*", "!src/components/ui/**/*"], - }, - { type: "ui-lib", pattern: "src/components/ui/**/*" }, - { type: "hooks", pattern: "src/hooks/**/*" }, - { type: "service", pattern: ["src/lib/**/*", "src/utils/**/*"] }, - { type: "types", pattern: ["src/types.ts", "src/i18n.d.ts"] }, - ], - "boundaries/ignore": [ - "**/*.test.*", - "**/*.spec.*", - "src/test/**/*", - ], - }, - rules: { - "boundaries/dependencies": [ - "error", - { - default: "disallow", - rules: [ - { from: { type: "components" }, allow: ["ui-lib", "hooks", "service", "types"] }, - { from: { type: "hooks" }, allow: ["service", "types"] }, - { from: { type: "ui-lib" }, allow: ["ui-lib"] }, - { from: { type: "service" }, allow: ["types"] }, - { from: { type: "types" }, allow: [] }, - ], - }, - ], - "react-hooks/exhaustive-deps": "warn", - "no-restricted-imports": [ - "error", - { - paths: [ - { - name: "@tauri-apps/api/core", - message: - "Do not import from @tauri-apps/api/core directly. Use src/lib/backend.ts instead.", - }, - ], - patterns: [ - { - group: ["@tauri-apps/plugin-*"], - message: - "Do not import Tauri plugins directly. Wrap them in src/lib/backend.ts.", - }, - ], - }, - ], - "@typescript-eslint/no-unused-vars": [ - "error", - { argsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }, - ], - "@typescript-eslint/no-explicit-any": "warn", - }, - }, - { - // Service layer is allowed to import @tauri-apps directly - files: ["src/lib/**/*.{ts,tsx}", "src/utils/**/*.{ts,tsx}"], - rules: { - "no-restricted-imports": "off", - }, - }, - { - // Radix UI generated files: exempt from all boundaries - files: ["src/components/ui/**/*.{ts,tsx}"], - rules: { - "boundaries/dependencies": "off", - "no-restricted-imports": "off", - }, - }, - { - // Test files: exempt from boundaries and import restrictions - files: ["src/**/*.test.{ts,tsx}", "src/test/**/*.{ts,tsx}"], - rules: { - "boundaries/dependencies": "off", - "no-restricted-imports": "off", - "@typescript-eslint/no-explicit-any": "off", - }, - }, - { - // Root-level app files (main.tsx, App.tsx, etc.) — not in any layer - files: ["src/*.{ts,tsx}"], - rules: { - "boundaries/dependencies": "off", - }, - } -); diff --git a/index.html b/index.html deleted file mode 100644 index 19af49f..0000000 --- a/index.html +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - - - Worktree Manager - - - -
- - - - - \ No newline at end of file diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index afdad14..0000000 --- a/package-lock.json +++ /dev/null @@ -1,7787 +0,0 @@ -{ - "name": "worktree-manager", - "version": "0.1.2", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "worktree-manager", - "version": "0.1.2", - "dependencies": { - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", - "@radix-ui/react-dialog": "1.1.15", - "@radix-ui/react-dropdown-menu": "2.1.16", - "@radix-ui/react-popover": "1.1.15", - "@radix-ui/react-select": "2.2.6", - "@radix-ui/react-slot": "1.2.4", - "@radix-ui/react-tooltip": "1.2.8", - "@tauri-apps/api": "2.10.1", - "@tauri-apps/plugin-dialog": "2.6.0", - "@tauri-apps/plugin-opener": "2.5.3", - "@tauri-apps/plugin-process": "2.3.1", - "@tauri-apps/plugin-updater": "2.10.0", - "@xterm/addon-fit": "0.11.0", - "@xterm/addon-search": "^0.16.0", - "@xterm/addon-unicode11": "^0.9.0", - "@xterm/addon-web-links": "0.12.0", - "@xterm/addon-webgl": "^0.19.0", - "@xterm/xterm": "6.0.0", - "class-variance-authority": "0.7.1", - "clsx": "2.1.1", - "highlight.js": "11.11.1", - "i18next": "25.8.13", - "lucide-react": "0.563.0", - "qrcode.react": "4.2.0", - "react": "19.1.0", - "react-dom": "19.1.0", - "react-i18next": "16.5.4", - "tailwind-merge": "3.4.0" - }, - "devDependencies": { - "@eslint/js": "10.0.1", - "@monaco-editor/react": "4.7.0", - "@tailwindcss/vite": "4.1.18", - "@tauri-apps/cli": "2.10.1", - "@testing-library/jest-dom": "6.9.1", - "@testing-library/react": "16.3.2", - "@types/node": "25.2.1", - "@types/react": "19.1.8", - "@types/react-dom": "19.1.6", - "@vitejs/plugin-react": "4.6.0", - "@vitest/coverage-v8": "4.1.2", - "autoprefixer": "10.4.24", - "eslint": "10.1.0", - "eslint-plugin-boundaries": "6.0.2", - "eslint-plugin-react-hooks": "7.1.0-canary-ed69815c-20260323", - "husky": "9.1.7", - "jsdom": "29.0.1", - "lint-staged": "16.4.0", - "pinyin-pro": "3.28.1", - "postcss": "8.5.6", - "tailwindcss": "4.1.18", - "typescript": "5.8.3", - "typescript-eslint": "8.58.0", - "vite": "7.0.4", - "vitest": "4.1.2" - } - }, - "node_modules/@adobe/css-tools": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", - "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@asamuzakjp/css-color": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.1.tgz", - "integrity": "sha512-iGWN8E45Ws0XWx3D44Q1t6vX2LqhCKcwfmwBYCDsFrYFS6m4q/Ks61L2veETaLv+ckDC6+dTETJoaAAb7VjLiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^3.1.1", - "@csstools/css-color-parser": "^4.0.2", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.2.7" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", - "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.7" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@boundaries/elements": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@boundaries/elements/-/elements-2.0.1.tgz", - "integrity": "sha512-sAWO3D8PFP6pBXdxxW93SQi/KQqqhE2AAHo3AgWfdtJXwO6bfK6/wUN81XnOZk0qRC6vHzUEKhjwVD9dtDWvxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-import-resolver-node": "0.3.9", - "eslint-module-utils": "2.12.1", - "handlebars": "4.7.9", - "is-core-module": "2.16.1", - "micromatch": "4.0.8" - }, - "engines": { - "node": ">=18.18" - } - }, - "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "css-tree": "^3.0.0" - }, - "bin": { - "specificity": "bin/cli.js" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@csstools/css-calc": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", - "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", - "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.1.1" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", - "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@dnd-kit/accessibility": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", - "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/core": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", - "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", - "license": "MIT", - "dependencies": { - "@dnd-kit/accessibility": "^3.1.1", - "@dnd-kit/utilities": "^3.2.2", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/sortable": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", - "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", - "license": "MIT", - "dependencies": { - "@dnd-kit/utilities": "^3.2.2", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "@dnd-kit/core": "^6.3.0", - "react": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/utilities": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", - "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.23.3", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", - "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^3.0.3", - "debug": "^4.3.1", - "minimatch": "^10.2.4" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", - "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.1.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/core": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", - "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/@eslint/object-schema": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", - "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", - "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.1.1", - "levn": "^0.4.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", - "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.7.6" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", - "license": "MIT" - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@monaco-editor/loader": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", - "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "state-local": "^1.0.6" - } - }, - "node_modules/@monaco-editor/react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", - "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@monaco-editor/loader": "^1.5.0" - }, - "peerDependencies": { - "monaco-editor": ">= 0.25.0 < 1", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@radix-ui/number": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", - "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", - "license": "MIT" - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", - "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", - "license": "MIT" - }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", - "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", - "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", - "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-direction": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", - "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", - "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-escape-keydown": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", - "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", - "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", - "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", - "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", - "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", - "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", - "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-visually-hidden": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", - "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", - "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", - "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", - "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", - "license": "MIT", - "dependencies": { - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", - "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", - "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", - "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", - "license": "MIT" - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.19", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", - "integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tailwindcss/node": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", - "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "enhanced-resolve": "^5.18.3", - "jiti": "^2.6.1", - "lightningcss": "1.30.2", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.1.18" - } - }, - "node_modules/@tailwindcss/node/node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" - } - }, - "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", - "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-x64": "4.1.18", - "@tailwindcss/oxide-freebsd-x64": "4.1.18", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-x64-musl": "4.1.18", - "@tailwindcss/oxide-wasm32-wasi": "4.1.18", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", - "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", - "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", - "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", - "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", - "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", - "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", - "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", - "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", - "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", - "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.0", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.7.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.7.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", - "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", - "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", - "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.1.18", - "@tailwindcss/oxide": "4.1.18", - "tailwindcss": "4.1.18" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7" - } - }, - "node_modules/@tauri-apps/api": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", - "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", - "license": "Apache-2.0 OR MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" - } - }, - "node_modules/@tauri-apps/cli": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz", - "integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==", - "dev": true, - "license": "Apache-2.0 OR MIT", - "bin": { - "tauri": "tauri.js" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" - }, - "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.10.1", - "@tauri-apps/cli-darwin-x64": "2.10.1", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", - "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", - "@tauri-apps/cli-linux-arm64-musl": "2.10.1", - "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", - "@tauri-apps/cli-linux-x64-gnu": "2.10.1", - "@tauri-apps/cli-linux-x64-musl": "2.10.1", - "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", - "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", - "@tauri-apps/cli-win32-x64-msvc": "2.10.1" - } - }, - "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz", - "integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz", - "integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz", - "integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz", - "integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz", - "integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz", - "integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz", - "integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz", - "integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz", - "integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz", - "integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz", - "integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/plugin-dialog": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz", - "integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.8.0" - } - }, - "node_modules/@tauri-apps/plugin-opener": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", - "integrity": "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.8.0" - } - }, - "node_modules/@tauri-apps/plugin-process": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz", - "integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.8.0" - } - }, - "node_modules/@tauri-apps/plugin-updater": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.0.tgz", - "integrity": "sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.10.1" - } - }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.2.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.1.tgz", - "integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/@types/react": { - "version": "19.1.8", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", - "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.1.6", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", - "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", - "devOptional": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.0.0" - } - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", - "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.0", - "@typescript-eslint/type-utils": "8.58.0", - "@typescript-eslint/utils": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.58.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", - "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", - "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.0", - "@typescript-eslint/types": "^8.58.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", - "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", - "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", - "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0", - "@typescript-eslint/utils": "8.58.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", - "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", - "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.58.0", - "@typescript-eslint/tsconfig-utils": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", - "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", - "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.58.0", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz", - "integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.19", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" - } - }, - "node_modules/@vitest/coverage-v8": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", - "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.2", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.1.2", - "vitest": "4.1.2" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/expect": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", - "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", - "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.2", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", - "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", - "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.2", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", - "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.2", - "@vitest/utils": "4.1.2", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", - "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", - "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.2", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@xterm/addon-fit": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", - "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", - "license": "MIT" - }, - "node_modules/@xterm/addon-search": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", - "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", - "license": "MIT" - }, - "node_modules/@xterm/addon-unicode11": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz", - "integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==", - "license": "MIT" - }, - "node_modules/@xterm/addon-web-links": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", - "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", - "license": "MIT" - }, - "node_modules/@xterm/addon-webgl": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", - "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", - "license": "MIT" - }, - "node_modules/@xterm/xterm": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", - "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", - "license": "MIT", - "workspaces": [ - "addons/*" - ] - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/aria-hidden": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", - "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/ast-v8-to-istanbul": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", - "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, - "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/autoprefixer": { - "version": "10.4.24", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", - "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001766", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.13", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", - "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001784", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", - "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/class-variance-authority": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", - "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", - "license": "Apache-2.0", - "dependencies": { - "clsx": "^2.1.1" - }, - "funding": { - "url": "https://polar.sh/cva" - } - }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", - "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^8.0.0", - "string-width": "^8.2.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "license": "MIT" - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/dompurify": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", - "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", - "dev": true, - "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.331", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", - "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", - "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.3", - "@eslint/config-helpers": "^0.5.3", - "@eslint/core": "^1.1.1", - "@eslint/plugin-kit": "^0.6.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-boundaries": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-boundaries/-/eslint-plugin-boundaries-6.0.2.tgz", - "integrity": "sha512-wSHgiYeMEbziP91lH0UQ9oslgF2djG1x+LV9z/qO19ggMKZaCB8pKIGePHAY91eLF4EAgpsxQk8MRSFGRPfPzw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@boundaries/elements": "2.0.1", - "chalk": "4.1.2", - "eslint-import-resolver-node": "0.3.9", - "eslint-module-utils": "2.12.1", - "handlebars": "4.7.9", - "micromatch": "4.0.8" - }, - "engines": { - "node": ">=18.18" - }, - "peerDependencies": { - "eslint": ">=6.0.0" - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "7.1.0-canary-ed69815c-20260323", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.0-canary-ed69815c-20260323.tgz", - "integrity": "sha512-05SifWyQ31QD76cMXlfpzscZ3LE3qDZwrmjf8r73LSVff4ouZN+q0WA+8uBYy49Py1EGHa7r87g8hp0+/GULvQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" - } - }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", - "dev": true, - "license": "MIT" - }, - "node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hermes-estree": "0.25.1" - } - }, - "node_modules/highlight.js": { - "version": "11.11.1", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", - "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/html-parse-stringify": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", - "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", - "license": "MIT", - "dependencies": { - "void-elements": "3.1.0" - } - }, - "node_modules/husky": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", - "dev": true, - "license": "MIT", - "bin": { - "husky": "bin.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" - } - }, - "node_modules/i18next": { - "version": "25.8.13", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.13.tgz", - "integrity": "sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA==", - "funding": [ - { - "type": "individual", - "url": "https://locize.com" - }, - { - "type": "individual", - "url": "https://locize.com/i18next.html" - }, - { - "type": "individual", - "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" - } - ], - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" - }, - "peerDependencies": { - "typescript": "^5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsdom": { - "version": "29.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", - "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^5.0.1", - "@asamuzakjp/dom-selector": "^7.0.3", - "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.1", - "@exodus/bytes": "^1.15.0", - "css-tree": "^3.2.1", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.7", - "parse5": "^8.0.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.1", - "undici": "^7.24.5", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.1", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsdom/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lint-staged": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", - "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^14.0.3", - "listr2": "^9.0.5", - "picomatch": "^4.0.3", - "string-argv": "^0.3.2", - "tinyexec": "^1.0.4", - "yaml": "^2.8.2" - }, - "bin": { - "lint-staged": "bin/lint-staged.js" - }, - "engines": { - "node": ">=20.17" - }, - "funding": { - "url": "https://opencollective.com/lint-staged" - } - }, - "node_modules/listr2": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", - "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^5.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lucide-react": { - "version": "0.563.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.563.0.tgz", - "integrity": "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magicast": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", - "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.3", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/marked": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", - "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/monaco-editor": { - "version": "0.55.1", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", - "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "dompurify": "3.2.7", - "marked": "14.0.0" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", - "dev": true, - "license": "MIT" - }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pinyin-pro": { - "version": "3.28.1", - "resolved": "https://registry.npmjs.org/pinyin-pro/-/pinyin-pro-3.28.1.tgz", - "integrity": "sha512-oqz8ulwRgtUXRi0vbqEfGNly19zpyCxYrjhkk5TibGcgSW6eNwS5woajCXRwqURi8Ehc2yOFTiB4uNoZ+NJOnA==", - "dev": true, - "license": "MIT" - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/qrcode.react": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", - "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", - "license": "ISC", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/react": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", - "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", - "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.26.0" - }, - "peerDependencies": { - "react": "^19.1.0" - } - }, - "node_modules/react-i18next": { - "version": "16.5.4", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.4.tgz", - "integrity": "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "html-parse-stringify": "^3.0.1", - "use-sync-external-store": "^1.6.0" - }, - "peerDependencies": { - "i18next": ">= 25.6.2", - "react": ">= 16.8.0", - "typescript": "^5" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-remove-scroll": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", - "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", - "license": "MIT", - "dependencies": { - "react-remove-scroll-bar": "^2.3.7", - "react-style-singleton": "^2.2.3", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.3", - "use-sidecar": "^1.1.3" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", - "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", - "license": "MIT", - "dependencies": { - "react-style-singleton": "^2.2.2", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-style-singleton": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", - "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", - "license": "MIT", - "dependencies": { - "get-nonce": "^1.0.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/rollup": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.1", - "@rollup/rollup-android-arm64": "4.60.1", - "@rollup/rollup-darwin-arm64": "4.60.1", - "@rollup/rollup-darwin-x64": "4.60.1", - "@rollup/rollup-freebsd-arm64": "4.60.1", - "@rollup/rollup-freebsd-x64": "4.60.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", - "@rollup/rollup-linux-arm-musleabihf": "4.60.1", - "@rollup/rollup-linux-arm64-gnu": "4.60.1", - "@rollup/rollup-linux-arm64-musl": "4.60.1", - "@rollup/rollup-linux-loong64-gnu": "4.60.1", - "@rollup/rollup-linux-loong64-musl": "4.60.1", - "@rollup/rollup-linux-ppc64-gnu": "4.60.1", - "@rollup/rollup-linux-ppc64-musl": "4.60.1", - "@rollup/rollup-linux-riscv64-gnu": "4.60.1", - "@rollup/rollup-linux-riscv64-musl": "4.60.1", - "@rollup/rollup-linux-s390x-gnu": "4.60.1", - "@rollup/rollup-linux-x64-gnu": "4.60.1", - "@rollup/rollup-linux-x64-musl": "4.60.1", - "@rollup/rollup-openbsd-x64": "4.60.1", - "@rollup/rollup-openharmony-arm64": "4.60.1", - "@rollup/rollup-win32-arm64-msvc": "4.60.1", - "@rollup/rollup-win32-ia32-msvc": "4.60.1", - "@rollup/rollup-win32-x64-gnu": "4.60.1", - "@rollup/rollup-win32-x64-msvc": "4.60.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/slice-ansi": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", - "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/state-local": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", - "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-argv": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", - "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6.19" - } - }, - "node_modules/string-width": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", - "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tailwind-merge": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", - "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, - "node_modules/tailwindcss": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", - "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", - "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", - "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tldts": { - "version": "7.0.27", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", - "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.27" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.0.27", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", - "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", - "dev": true, - "license": "MIT" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/tough-cookie": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", - "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.0.tgz", - "integrity": "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.58.0", - "@typescript-eslint/parser": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0", - "@typescript-eslint/utils": "8.58.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/undici": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", - "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/use-callback-ref": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", - "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sidecar": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", - "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", - "license": "MIT", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/vite": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.4.tgz", - "integrity": "sha512-SkaSguuS7nnmV7mfJ8l81JGBFV7Gvzp8IzgE8A8t23+AxuNX61Q5H1Tpz5efduSN7NHC8nQXD3sKQKZAu5mNEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.6", - "picomatch": "^4.0.2", - "postcss": "^8.5.6", - "rollup": "^4.40.0", - "tinyglobby": "^0.2.14" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vitest": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", - "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.2", - "@vitest/mocker": "4.1.2", - "@vitest/pretty-format": "4.1.2", - "@vitest/runner": "4.1.2", - "@vitest/snapshot": "4.1.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.2", - "@vitest/browser-preview": "4.1.2", - "@vitest/browser-webdriverio": "4.1.2", - "@vitest/ui": "4.1.2", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/void-elements": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", - "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-validation-error": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", - "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 21550c6..0000000 --- a/package.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "name": "worktree-manager", - "private": true, - "pnpm": { - "allowBuilds": { - "esbuild": true - } - }, - "version": "0.1.2", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview", - "tauri": "tauri", - "contracts": "npm run verify:contracts && npm run docs:contracts", - "verify:contracts": "node scripts/command-contracts.mjs check", - "docs:contracts": "node scripts/command-contracts.mjs generate", - "prepare": "husky", - "lint": "eslint src/", - "lint:fix": "eslint src/ --fix", - "check-i18n": "node scripts/check-i18n.mjs", - "clean": "rm -rf dist .vite node_modules/.vite", - "dev-run": "npm run clean && npm run build && npm run tauri dev", - "test": "vitest run --passWithNoTests", - "test:watch": "vitest" - }, - "dependencies": { - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", - "@radix-ui/react-dialog": "1.1.15", - "@radix-ui/react-dropdown-menu": "2.1.16", - "@radix-ui/react-popover": "1.1.15", - "@radix-ui/react-select": "2.2.6", - "@radix-ui/react-slot": "1.2.4", - "@radix-ui/react-tooltip": "1.2.8", - "@tauri-apps/api": "2.10.1", - "@tauri-apps/plugin-dialog": "2.6.0", - "@tauri-apps/plugin-opener": "2.5.3", - "@tauri-apps/plugin-process": "2.3.1", - "@tauri-apps/plugin-updater": "2.10.0", - "@xterm/addon-fit": "0.11.0", - "@xterm/addon-search": "^0.16.0", - "@xterm/addon-unicode11": "^0.9.0", - "@xterm/addon-web-links": "0.12.0", - "@xterm/addon-webgl": "^0.19.0", - "@xterm/xterm": "6.0.0", - "class-variance-authority": "0.7.1", - "clsx": "2.1.1", - "highlight.js": "11.11.1", - "i18next": "25.8.13", - "lucide-react": "0.563.0", - "qrcode.react": "4.2.0", - "react": "19.1.0", - "react-dom": "19.1.0", - "react-i18next": "16.5.4", - "tailwind-merge": "3.4.0" - }, - "devDependencies": { - "@eslint/js": "10.0.1", - "@monaco-editor/react": "4.7.0", - "@tailwindcss/vite": "4.1.18", - "@tauri-apps/cli": "2.10.1", - "@testing-library/jest-dom": "6.9.1", - "@testing-library/react": "16.3.2", - "@types/node": "25.2.1", - "@types/react": "19.1.8", - "@types/react-dom": "19.1.6", - "@vitejs/plugin-react": "4.6.0", - "@vitest/coverage-v8": "4.1.2", - "autoprefixer": "10.4.24", - "eslint": "10.1.0", - "eslint-plugin-boundaries": "6.0.2", - "eslint-plugin-react-hooks": "7.1.0-canary-ed69815c-20260323", - "husky": "9.1.7", - "jsdom": "29.0.1", - "lint-staged": "16.4.0", - "pinyin-pro": "3.28.1", - "postcss": "8.5.6", - "tailwindcss": "4.1.18", - "typescript": "5.8.3", - "typescript-eslint": "8.58.0", - "vite": "7.0.4", - "vitest": "4.1.2" - } -} diff --git a/packages/mcp/.github/workflows/publish.yml b/packages/mcp/.github/workflows/publish.yml deleted file mode 100644 index 75943bb..0000000 --- a/packages/mcp/.github/workflows/publish.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: Publish to npm - -on: - push: - branches: - - main - paths: - - 'packages/mcp/**' - workflow_dispatch: - inputs: - bump: - description: 'Version bump type (patch/minor/major)' - required: false - default: 'patch' - -jobs: - publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: packages/mcp/package-lock.json - - - name: Install dependencies - run: npm ci - working-directory: packages/mcp - - - name: Build - run: npm run build - working-directory: packages/mcp - - - name: Bump version - id: version - run: | - cd packages/mcp - if [ "${{ github.event.inputs.bump }}" != "" ]; then - BUMP_TYPE=${{ github.event.inputs.bump }} - else - # Auto bump based on conventional commits - BUMP_TYPE=patch - fi - npm version $BUMP_TYPE --no-git-tag-version - echo "NEW_VERSION=$(node -p \"require('./package.json').version\")" >> $GITHUB_OUTPUT - working-directory: packages/mcp - - - name: Commit and push version bump - run: | - cd packages/mcp - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add package.json package-lock.json - git commit -m "chore(mcp): bump version to ${{ steps.version.outputs.NEW_VERSION }}" - git tag mcp-v${{ steps.version.outputs.NEW_VERSION }} - git push origin HEAD:main --tags - working-directory: packages/mcp - - - name: Publish to npm - run: npm publish --access public - working-directory: packages/mcp - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/packages/mcp/README.md b/packages/mcp/README.md deleted file mode 100644 index 00aa559..0000000 --- a/packages/mcp/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# @worktree-manager/mcp - -MCP server for Worktree Manager — enables AI assistants (Claude Code, Codex, Cursor) to query workspace state and perform operations. - -## What is This? - -This MCP server lets AI coding assistants understand and interact with your Git worktrees managed by Worktree Manager. Ask questions like: - -- "What worktrees do I have?" -- "What's the status of my feature-xyz worktree?" -- "Create a new worktree for my next feature" - -## Installation - -```bash -npx -y @worktree-manager/mcp install -``` - -This auto-configures Claude Code. Restart Claude Code or run `claude mcp restart`. - -## Usage - -```bash -# Start MCP server manually -npx -y @worktree-manager/mcp start - -# Install to Claude Code (auto-configures ~/.claude.json) -npx -y @worktree-manager/mcp install - -# Uninstall from Claude Code -npx -y @worktree-manager/mcp uninstall -``` - -## Available Tools - -### Layer 1 — Core (Always Available) - -| Tool | Description | -|------|-------------| -| `workspace_list` | List all configured workspaces | -| `workspace_get_current` | Get the currently selected workspace | -| `worktree_list` | List all worktrees in current workspace | -| `worktree_get_status` | Get detailed status of a specific worktree | -| `workspace_get_status` | Get main workspace status | - -### Layer 2 — Details (On Demand) - -| Tool | Description | -|------|-------------| -| `project_get_branches` | Get list of branches for a project | -| `project_get_diff_stats` | Get diff statistics vs base branch | -| `project_get_changed_files` | List uncommitted files | - -### Layer 3 — Advanced (Wrapped by Skills) - -| Tool | Description | -|------|-------------| -| `worktree_create` | Create a new worktree | -| `worktree_archive` | Archive an existing worktree | -| `git_commit` | Stage and commit changes | -| `git_push` | Push to remote | - -## How It Works - -``` -Claude Code/Codex ←→ MCP Protocol ←→ @worktree-manager/mcp ←→ Worktree Manager App - (HTTP:42819) -``` - -When Worktree Manager desktop app is running → real-time data via HTTP. -When app is not running → reads from config file fallback. - -## Requirements - -- Worktree Manager desktop app -- Node.js 18+ -- Claude Code or any MCP-compatible AI assistant - -## License - -MIT diff --git a/packages/mcp/package-lock.json b/packages/mcp/package-lock.json deleted file mode 100644 index c74d6df..0000000 --- a/packages/mcp/package-lock.json +++ /dev/null @@ -1,1498 +0,0 @@ -{ - "name": "@worktree-manager/mcp", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@worktree-manager/mcp", - "version": "1.0.0", - "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0", - "axios": "^1.7.0", - "chokidar": "^3.6.0" - }, - "bin": { - "worktree-manager-mcp": "dist/index.js" - }, - "devDependencies": { - "@types/node": "^20.0.0", - "typescript": "^5.4.0" - } - }, - "node_modules/@hono/node-server": { - "version": "1.19.13", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/@hono/node-server/-/node-server-1.19.13.tgz", - "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@types/node": { - "version": "20.19.39", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/@types/node/-/node-20.19.39.tgz", - "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.14.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", - "license": "MIT", - "dependencies": { - "ip-address": "10.1.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/form-data/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hono": { - "version": "4.12.12", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/hono/-/hono-4.12.12.tgz", - "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jose": { - "version": "6.2.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } - } - } -} diff --git a/packages/mcp/package.json b/packages/mcp/package.json deleted file mode 100644 index daeaa28..0000000 --- a/packages/mcp/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@worktree-manager/mcp", - "version": "1.0.1", - "description": "MCP server for Worktree Manager", - "repository": { - "type": "git", - "url": "https://github.com/guoyongchang/worktree-manager" - }, - "type": "module", - "bin": { - "worktree-manager-mcp": "./dist/index.js" - }, - "scripts": { - "build": "tsc", - "prepublish": "npm run build" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0", - "axios": "^1.7.0", - "chokidar": "^3.6.0" - }, - "devDependencies": { - "@types/node": "^20.0.0", - "typescript": "^5.4.0" - } -} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts deleted file mode 100755 index bf1a72f..0000000 --- a/packages/mcp/src/index.ts +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env node - -import { WorktreeMcpServer } from './server.js'; -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; -import { join } from 'path'; -import { homedir } from 'os'; - -const CLAUDE_CONFIG_PATH = join(homedir(), '.claude.json'); -const MCP_CONFIG_DIR = '.config/worktree-manager'; -const MCP_CONFIG_PATH = join(homedir(), MCP_CONFIG_DIR, 'mcp.json'); - -async function startServer() { - const server = new WorktreeMcpServer(); - await server.initialize(); - await server.run(); -} - -function loadClaudeConfig(): any { - try { - if (existsSync(CLAUDE_CONFIG_PATH)) { - return JSON.parse(readFileSync(CLAUDE_CONFIG_PATH, 'utf-8')); - } - } catch {} - return {}; -} - -function saveClaudeConfig(config: any): void { - writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2)); -} - -async function install() { - console.error('Installing @worktree-manager/mcp to Claude Code...'); - - const config = loadClaudeConfig(); - config.mcpServers = config.mcpServers || {}; - config.mcpServers['worktree-manager'] = { - command: 'npx', - args: ['-y', '@worktree-manager/mcp', 'start'], - }; - saveClaudeConfig(config); - - console.error('Installed! Claude Code will now have access to Worktree Manager.'); - console.error('Restart Claude Code or run: claude mcp restart'); -} - -async function uninstall() { - console.error('Uninstalling @worktree-manager/mcp from Claude Code...'); - - const config = loadClaudeConfig(); - if (config.mcpServers?.['worktree-manager']) { - delete config.mcpServers['worktree-manager']; - saveClaudeConfig(config); - console.error('Uninstalled. Restart Claude Code to apply changes.'); - } else { - console.error('Not found in Claude config.'); - } -} - -function writeMcpConfig(): void { - try { - const dir = join(homedir(), MCP_CONFIG_DIR); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - const mcpConfig = { - version: '1.0.0', - http_port: 42819, - installed_at: new Date().toISOString(), - capability_level: 'core', - }; - writeFileSync(MCP_CONFIG_PATH, JSON.stringify(mcpConfig, null, 2)); - } catch (e) { - console.error('Warning: Could not write MCP config:', e); - } -} - -const command = process.argv[2] || 'start'; - -switch (command) { - case 'start': - writeMcpConfig(); - await startServer(); - break; - case 'install': - await install(); - break; - case 'uninstall': - await uninstall(); - break; - default: - console.error(`Unknown command: ${command}`); - console.error('Usage: worktree-manager-mcp [start|install|uninstall]'); - process.exit(1); -} \ No newline at end of file diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts deleted file mode 100644 index d6b0f72..0000000 --- a/packages/mcp/src/server.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; -import { HttpTransport } from './transport/http.js'; -import { ConfigTransport } from './transport/config.js'; -import { registerCoreTools, CORE_TOOLS } from './tools/core.js'; -import { registerDetailsTools, DETAILS_TOOLS } from './tools/details.js'; -import { registerAdvancedTools, ADVANCED_TOOLS } from './tools/advanced.js'; -import type { CapabilityLevel } from './types.js'; - -export class WorktreeMcpServer { - private server: Server | null = null; - private transport: HttpTransport | ConfigTransport | null = null; - private capabilityLevel: CapabilityLevel = 'core'; - - constructor() {} - - async initialize(): Promise { - // Try HTTP transport first - const httpTransport = new HttpTransport(); - if (await httpTransport.isAvailable()) { - this.transport = httpTransport; - console.error('[MCP] Using HTTP transport (Tauri app running)'); - } else { - // Fall back to config transport - const configTransport = new ConfigTransport(); - if (await configTransport.isAvailable()) { - this.transport = configTransport; - console.error('[MCP] Using config file transport (fallback mode)'); - } else { - throw new Error('No transport available. Ensure Tauri app is running or global config exists.'); - } - } - - // Initialize MCP server - this.server = new Server( - { - name: 'worktree-manager', - version: '1.0.0', - }, - { - capabilities: { - tools: {}, - }, - } - ); - - // Register tools based on capability level - this.registerTools(); - } - - private registerTools(): void { - if (!this.server || !this.transport) return; - - const transport = this.transport; - - // Core tools always registered - registerCoreTools(this.server, transport); - - // Details tools - only available with HTTP transport - if (this.capabilityLevel === 'details' || this.capabilityLevel === 'advanced') { - if (transport instanceof HttpTransport) { - registerDetailsTools(this.server, transport); - } - } - - // Advanced tools - only available with HTTP transport - if (this.capabilityLevel === 'advanced') { - if (transport instanceof HttpTransport) { - registerAdvancedTools(this.server, transport); - } - } - - // Set tool list handler - this.server.setRequestHandler( - ListToolsRequestSchema, - async () => { - let tools: any[] = [...CORE_TOOLS]; - if (this.capabilityLevel === 'details' || this.capabilityLevel === 'advanced') { - if (transport instanceof HttpTransport) { - tools = tools.concat(DETAILS_TOOLS); - } - } - if (this.capabilityLevel === 'advanced') { - if (transport instanceof HttpTransport) { - tools = tools.concat(ADVANCED_TOOLS); - } - } - return { tools }; - } - ); - } - - setCapabilityLevel(level: CapabilityLevel): void { - this.capabilityLevel = level; - } - - async run(): Promise { - if (!this.server) { - throw new Error('Server not initialized. Call initialize() first.'); - } - const transport = new StdioServerTransport(); - await this.server.connect(transport); - console.error('[MCP] Server running on stdio'); - } -} \ No newline at end of file diff --git a/packages/mcp/src/tools/advanced.ts b/packages/mcp/src/tools/advanced.ts deleted file mode 100644 index b14ada4..0000000 --- a/packages/mcp/src/tools/advanced.ts +++ /dev/null @@ -1,195 +0,0 @@ -import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; -import type { Transport } from '../transport/http.js'; - -export function registerAdvancedTools( - server: Server, - transport: Transport -): void { - server.setRequestHandler( - CallToolRequestSchema, - async (request) => { - const { name, arguments: args } = request.params; - - if (name === 'worktree_create') { - const name = args?.name as string; - const projects = args?.projects as Array<{ name: string; base_branch?: string }>; - const folder_name = args?.folder_name as string | undefined; - if (!name || !projects) { - throw new Error('name and projects are required'); - } - const result = await transport.createWorktree({ name, projects, folder_name }); - return { - content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], - }; - } - - if (name === 'worktree_archive') { - const name = args?.name as string; - if (!name) { - throw new Error('name is required'); - } - const result = await transport.archiveWorktree(name); - return { - content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], - }; - } - - if (name === 'worktree_delete_archived') { - const name = args?.name as string; - if (!name) { - throw new Error('name is required'); - } - const result = await transport.deleteArchivedWorktree(name); - return { - content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], - }; - } - - if (name === 'git_commit') { - const projectPath = args?.project_path as string; - const message = args?.message as string; - if (!projectPath || !message) { - throw new Error('project_path and message are required'); - } - const result = await transport.commitAll(projectPath, message); - return { - content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], - }; - } - - if (name === 'git_push') { - const projectPath = args?.project_path as string; - if (!projectPath) { - throw new Error('project_path is required'); - } - const result = await transport.pushToRemote(projectPath); - return { - content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], - }; - } - - if (name === 'git_switch_branch') { - const projectPath = args?.project_path as string; - const branchName = args?.branch_name as string; - if (!projectPath || !branchName) { - throw new Error('project_path and branch_name are required'); - } - const result = await transport.switchBranch(projectPath, branchName); - return { - content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], - }; - } - - if (name === 'git_fetch') { - const projectPath = args?.project_path as string; - if (!projectPath) { - throw new Error('project_path is required'); - } - const result = await transport.fetchProjectRemote(projectPath); - return { - content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], - }; - } - - return { - content: [{ type: 'text', text: `Unknown tool: ${name}` }], - isError: true, - }; - } - ); -} - -export const ADVANCED_TOOLS = [ - { - name: 'worktree_create', - description: 'Create a new worktree with specified projects', - inputSchema: { - type: 'object', - properties: { - name: { type: 'string', description: 'Worktree/branch name' }, - folder_name: { type: 'string', description: 'Optional folder name (defaults to name)' }, - projects: { - type: 'array', - items: { - type: 'object', - properties: { - name: { type: 'string' }, - base_branch: { type: 'string' }, - }, - }, - description: 'Projects to include in worktree', - }, - }, - required: ['name', 'projects'], - }, - }, - { - name: 'worktree_archive', - description: 'Archive an existing worktree', - inputSchema: { - type: 'object', - properties: { - name: { type: 'string', description: 'Worktree name to archive' }, - }, - required: ['name'], - }, - }, - { - name: 'worktree_delete_archived', - description: 'Permanently delete an archived worktree', - inputSchema: { - type: 'object', - properties: { - name: { type: 'string', description: 'Archived worktree name (with .archive suffix)' }, - }, - required: ['name'], - }, - }, - { - name: 'git_commit', - description: 'Stage all changes and commit with message', - inputSchema: { - type: 'object', - properties: { - project_path: { type: 'string' }, - message: { type: 'string' }, - }, - required: ['project_path', 'message'], - }, - }, - { - name: 'git_push', - description: 'Push current branch to remote', - inputSchema: { - type: 'object', - properties: { - project_path: { type: 'string' }, - }, - required: ['project_path'], - }, - }, - { - name: 'git_switch_branch', - description: 'Switch to a different branch', - inputSchema: { - type: 'object', - properties: { - project_path: { type: 'string' }, - branch_name: { type: 'string' }, - }, - required: ['project_path', 'branch_name'], - }, - }, - { - name: 'git_fetch', - description: 'Fetch from remote origin', - inputSchema: { - type: 'object', - properties: { - project_path: { type: 'string' }, - }, - required: ['project_path'], - }, - }, -]; diff --git a/packages/mcp/src/tools/core.ts b/packages/mcp/src/tools/core.ts deleted file mode 100644 index 0d96505..0000000 --- a/packages/mcp/src/tools/core.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; -import type { BaseTransport } from '../transport/config.js'; - -export function registerCoreTools(server: Server, transport: BaseTransport): void { - server.setRequestHandler( - CallToolRequestSchema, - async (request) => { - const { name, arguments: args } = request.params; - - if (name === 'workspace_list') { - const result = await transport.listWorkspaces(); - return { - content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], - }; - } - - if (name === 'workspace_get_current') { - const result = await transport.getCurrentWorkspace(); - return { - content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], - }; - } - - if (name === 'worktree_list') { - const includeArchived = args?.include_archived === true; - const result = await transport.listWorktrees(includeArchived); - return { - content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], - }; - } - - if (name === 'worktree_get_status') { - const name = args?.name as string; - if (!name) { - throw new Error('worktree name is required'); - } - const status = await transport.checkWorktreeStatus(name); - return { - content: [{ type: 'text', text: JSON.stringify(status, null, 2) }], - }; - } - - if (name === 'workspace_get_status') { - const result = await transport.getMainWorkspaceStatus(); - return { - content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], - }; - } - - return { - content: [{ type: 'text', text: `Unknown tool: ${name}` }], - isError: true, - }; - } - ); -} - -export const CORE_TOOLS = [ - { - name: 'workspace_list', - description: 'List all workspaces configured in Worktree Manager', - inputSchema: { - type: 'object', - properties: {}, - }, - }, - { - name: 'workspace_get_current', - description: 'Get the currently selected workspace', - inputSchema: { - type: 'object', - properties: {}, - }, - }, - { - name: 'worktree_list', - description: 'List all worktrees in the current workspace', - inputSchema: { - type: 'object', - properties: { - include_archived: { - type: 'boolean', - description: 'Include archived worktrees', - default: false, - }, - }, - }, - }, - { - name: 'worktree_get_status', - description: 'Get detailed status of a specific worktree', - inputSchema: { - type: 'object', - properties: { - name: { type: 'string', description: 'Worktree name' }, - }, - required: ['name'], - }, - }, - { - name: 'workspace_get_status', - description: 'Get status of the main workspace (projects in projects/ directory)', - inputSchema: { - type: 'object', - properties: {}, - }, - }, -]; diff --git a/packages/mcp/src/tools/details.ts b/packages/mcp/src/tools/details.ts deleted file mode 100644 index ef62b00..0000000 --- a/packages/mcp/src/tools/details.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; -import type { Transport } from '../transport/http.js'; - -export function registerDetailsTools( - server: Server, - transport: Transport -): void { - server.setRequestHandler( - CallToolRequestSchema, - async (request) => { - const { name, arguments: args } = request.params; - - if (name === 'project_get_branches') { - const projectPath = args?.project_path as string; - if (!projectPath) { - throw new Error('project_path is required'); - } - const result = await transport.getRemoteBranches(projectPath); - return { - content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], - }; - } - - if (name === 'project_get_diff_stats') { - const projectPath = args?.project_path as string; - const baseBranch = args?.base_branch as string; - if (!projectPath || !baseBranch) { - throw new Error('project_path and base_branch are required'); - } - const result = await transport.getBranchDiffStats(projectPath, baseBranch); - return { - content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], - }; - } - - if (name === 'project_get_changed_files') { - const projectPath = args?.project_path as string; - if (!projectPath) { - throw new Error('project_path is required'); - } - const result = await transport.getChangedFiles(projectPath); - return { - content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], - }; - } - - return { - content: [{ type: 'text', text: `Unknown tool: ${name}` }], - isError: true, - }; - } - ); -} - -export const DETAILS_TOOLS = [ - { - name: 'project_get_branches', - description: 'Get list of remote branches for a project', - inputSchema: { - type: 'object', - properties: { - project_path: { - type: 'string', - description: 'Full path to the project directory', - }, - }, - required: ['project_path'], - }, - }, - { - name: 'project_get_diff_stats', - description: 'Get diff statistics between current branch and base branch', - inputSchema: { - type: 'object', - properties: { - project_path: { type: 'string', description: 'Full path to the project' }, - base_branch: { type: 'string', description: 'Base branch to compare against' }, - }, - required: ['project_path', 'base_branch'], - }, - }, - { - name: 'project_get_changed_files', - description: 'Get list of files with uncommitted changes', - inputSchema: { - type: 'object', - properties: { - project_path: { type: 'string', description: 'Full path to the project' }, - }, - required: ['project_path'], - }, - }, -]; diff --git a/packages/mcp/src/transport/config.ts b/packages/mcp/src/transport/config.ts deleted file mode 100644 index edcf745..0000000 --- a/packages/mcp/src/transport/config.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { readFileSync, existsSync } from 'fs'; -import { join } from 'path'; -import { homedir } from 'os'; -import { watch } from 'chokidar'; -import type { TransportMode, WorkspaceRef } from '../types.js'; - -const GLOBAL_CONFIG_PATH = '.config/worktree-manager/global.json'; - -// Base transport interface for common operations -export interface BaseTransport { - getMode(): TransportMode; - isAvailable(): Promise; - listWorkspaces(): Promise; - getCurrentWorkspace(): Promise; - listWorktrees(includeArchived?: boolean): Promise; - getMainWorkspaceStatus(): Promise; - checkWorktreeStatus(name: string): Promise; -} - -// Extended transport interface for HTTP transport operations -export interface Transport extends BaseTransport { - createWorktree(request: { - name: string; - folder_name?: string; - projects: Array<{ name: string; base_branch?: string }>; - }): Promise; - archiveWorktree(name: string): Promise; - deleteArchivedWorktree(name: string): Promise; - getBranchDiffStats(projectPath: string, baseBranch: string): Promise; - getChangedFiles(projectPath: string): Promise; - getRemoteBranches(projectPath: string): Promise; - commitAll(projectPath: string, message: string): Promise; - pushToRemote(projectPath: string): Promise; - switchBranch(projectPath: string, branchName: string): Promise; - fetchProjectRemote(projectPath: string): Promise; -} - -export class ConfigTransport implements BaseTransport { - private configPath: string; - private watcher: ReturnType | null = null; - - constructor() { - this.configPath = join(homedir(), GLOBAL_CONFIG_PATH); - } - - async isAvailable(): Promise { - return existsSync(this.configPath); - } - - getMode(): TransportMode { - return 'config'; - } - - private loadConfig(): any { - try { - const content = readFileSync(this.configPath, 'utf-8'); - return JSON.parse(content); - } catch { - return { workspaces: [] }; - } - } - - async listWorkspaces(): Promise { - const config = this.loadConfig(); - return { workspaces: config.workspaces || [] }; - } - - async getCurrentWorkspace(): Promise { - const config = this.loadConfig(); - const currentPath = config.current_workspace; - const workspace = config.workspaces?.find((w: WorkspaceRef) => w.path === currentPath) || null; - return { workspace }; - } - - async listWorktrees(): Promise { - // Config file doesn't store worktree data - return empty - // This is a limitation of fallback mode - return { worktrees: [] }; - } - - async getMainWorkspaceStatus(): Promise { - throw new Error('Not available in config transport mode'); - } - - async checkWorktreeStatus(name: string): Promise { - throw new Error('Not available in config transport mode'); - } - - watch(callback: () => void): void { - if (this.watcher) return; - this.watcher = watch(this.configPath, { persistent: true }); - this.watcher.on('change', callback); - } - - stopWatching(): void { - if (this.watcher) { - this.watcher.close(); - this.watcher = null; - } - } -} \ No newline at end of file diff --git a/packages/mcp/src/transport/http.ts b/packages/mcp/src/transport/http.ts deleted file mode 100644 index fda6a21..0000000 --- a/packages/mcp/src/transport/http.ts +++ /dev/null @@ -1,126 +0,0 @@ -import axios, { AxiosInstance } from 'axios'; -import type { TransportMode } from '../types.js'; -import type { Transport } from './config.js'; - -const DEFAULT_PORT = 42819; - -export { Transport } from './config.js'; - -export class HttpTransport implements Transport { - private client: AxiosInstance; - private port: number; - private baseUrl: string; - - constructor(port: number = DEFAULT_PORT) { - this.port = port; - this.baseUrl = `http://localhost:${this.port}`; - this.client = axios.create({ - baseURL: `${this.baseUrl}/api`, - timeout: 5000, - headers: { 'Content-Type': 'application/json' }, - }); - } - - async isAvailable(): Promise { - try { - const response = await this.client.post('/get_current_workspace', {}); - return response.status === 200; - } catch { - return false; - } - } - - getMode(): TransportMode { - return 'http'; - } - - // Workspace operations - async listWorkspaces(): Promise { - const res = await this.client.post('/list_workspaces', {}); - return res.data; - } - - async getCurrentWorkspace(): Promise { - const res = await this.client.post('/get_current_workspace', {}); - return res.data; - } - - async switchWorkspace(path: string): Promise { - const res = await this.client.post('/switch_workspace', { path }); - return res.data; - } - - // Worktree operations - async listWorktrees(includeArchived: boolean = false): Promise { - const res = await this.client.post('/list_worktrees', { include_archived: includeArchived }); - return res.data; - } - - async getMainWorkspaceStatus(): Promise { - const res = await this.client.post('/get_main_workspace_status', {}); - return res.data; - } - - async createWorktree(request: { - name: string; - folder_name?: string; - projects: Array<{ name: string; base_branch?: string }>; - }): Promise { - const res = await this.client.post('/create_worktree', request); - return res.data; - } - - async archiveWorktree(name: string): Promise { - const res = await this.client.post('/archive_worktree', { name }); - return res.data; - } - - async deleteArchivedWorktree(name: string): Promise { - const res = await this.client.post('/delete_archived_worktree', { name }); - return res.data; - } - - async checkWorktreeStatus(name: string): Promise { - const res = await this.client.post('/check_worktree_status', { name }); - return res.data; - } - - // Project/Git operations - async getBranchDiffStats(projectPath: string, baseBranch: string): Promise { - const res = await this.client.post('/get_branch_diff_stats', { - path: projectPath, - base_branch: baseBranch, - }); - return res.data; - } - - async getChangedFiles(projectPath: string): Promise { - const res = await this.client.post('/get_changed_files', { path: projectPath }); - return res.data; - } - - async getRemoteBranches(projectPath: string): Promise { - const res = await this.client.post('/get_remote_branches', { path: projectPath }); - return res.data; - } - - async commitAll(projectPath: string, message: string): Promise { - const res = await this.client.post('/commit_all', { path: projectPath, message }); - return res.data; - } - - async pushToRemote(projectPath: string): Promise { - const res = await this.client.post('/push_to_remote', { path: projectPath }); - return res.data; - } - - async switchBranch(projectPath: string, branchName: string): Promise { - const res = await this.client.post('/switch_branch', { path: projectPath, branch: branchName }); - return res.data; - } - - async fetchProjectRemote(projectPath: string): Promise { - const res = await this.client.post('/fetch_project_remote', { path: projectPath }); - return res.data; - } -} diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts deleted file mode 100644 index 4b8eae5..0000000 --- a/packages/mcp/src/types.ts +++ /dev/null @@ -1,63 +0,0 @@ -// Workspace reference -export interface WorkspaceRef { - name: string; - path: string; -} - -// Worktree list item -export interface WorktreeListItem { - name: string; - path: string; - is_archived: boolean; - display_name?: string; - projects: ProjectStatus[]; -} - -// Project status -export interface ProjectStatus { - name: string; - path: string; - current_branch: string; - base_branch: string; - test_branch: string; - has_uncommitted: boolean; - uncommitted_count: number; - is_merged_to_test: boolean; - is_merged_to_base: boolean; - ahead_of_base: number; - behind_base: number; -} - -// Branch diff stats -export interface BranchDiffStats { - ahead: number; - behind: number; - changed_files: number; -} - -// Changed file -export interface ChangedFile { - path: string; - status: 'modified' | 'added' | 'deleted' | 'renamed'; -} - -// MCP error response -export interface McpError { - code: number; - message: string; - data?: unknown; -} - -// Transport mode -export type TransportMode = 'http' | 'config'; - -// Capability levels -export type CapabilityLevel = 'core' | 'details' | 'advanced'; - -// MCP config -export interface McpConfig { - version: string; - http_port: number; - installed_at: string; - capability_level: CapabilityLevel; -} diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json deleted file mode 100644 index ed1155c..0000000 --- a/packages/mcp/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true - }, - "include": ["src/**/*"] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 685b400..0000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,4844 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@dnd-kit/core': - specifier: ^6.3.1 - version: 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@dnd-kit/sortable': - specifier: ^10.0.0 - version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) - '@dnd-kit/utilities': - specifier: ^3.2.2 - version: 3.2.2(react@19.1.0) - '@radix-ui/react-dialog': - specifier: 1.1.15 - version: 1.1.15(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-dropdown-menu': - specifier: 2.1.16 - version: 2.1.16(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-popover': - specifier: 1.1.15 - version: 1.1.15(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-select': - specifier: 2.2.6 - version: 2.2.6(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': - specifier: 1.2.4 - version: 1.2.4(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-tooltip': - specifier: 1.2.8 - version: 1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@tauri-apps/api': - specifier: 2.10.1 - version: 2.10.1 - '@tauri-apps/plugin-dialog': - specifier: 2.6.0 - version: 2.6.0 - '@tauri-apps/plugin-opener': - specifier: 2.5.3 - version: 2.5.3 - '@tauri-apps/plugin-process': - specifier: 2.3.1 - version: 2.3.1 - '@tauri-apps/plugin-updater': - specifier: 2.10.0 - version: 2.10.0 - '@xterm/addon-fit': - specifier: 0.11.0 - version: 0.11.0 - '@xterm/addon-search': - specifier: ^0.16.0 - version: 0.16.0 - '@xterm/addon-unicode11': - specifier: ^0.9.0 - version: 0.9.0 - '@xterm/addon-web-links': - specifier: 0.12.0 - version: 0.12.0 - '@xterm/addon-webgl': - specifier: ^0.19.0 - version: 0.19.0 - '@xterm/xterm': - specifier: 6.0.0 - version: 6.0.0 - class-variance-authority: - specifier: 0.7.1 - version: 0.7.1 - clsx: - specifier: 2.1.1 - version: 2.1.1 - highlight.js: - specifier: 11.11.1 - version: 11.11.1 - i18next: - specifier: 25.8.13 - version: 25.8.13(typescript@5.8.3) - lucide-react: - specifier: 0.563.0 - version: 0.563.0(react@19.1.0) - qrcode.react: - specifier: 4.2.0 - version: 4.2.0(react@19.1.0) - react: - specifier: 19.1.0 - version: 19.1.0 - react-dom: - specifier: 19.1.0 - version: 19.1.0(react@19.1.0) - react-i18next: - specifier: 16.5.4 - version: 16.5.4(i18next@25.8.13(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3) - tailwind-merge: - specifier: 3.4.0 - version: 3.4.0 - devDependencies: - '@eslint/js': - specifier: 10.0.1 - version: 10.0.1(eslint@10.1.0(jiti@2.6.1)) - '@monaco-editor/react': - specifier: 4.7.0 - version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@tailwindcss/vite': - specifier: 4.1.18 - version: 4.1.18(vite@7.0.4(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)) - '@tauri-apps/cli': - specifier: 2.10.1 - version: 2.10.1 - '@testing-library/jest-dom': - specifier: 6.9.1 - version: 6.9.1 - '@testing-library/react': - specifier: 16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@types/node': - specifier: 25.2.1 - version: 25.2.1 - '@types/react': - specifier: 19.1.8 - version: 19.1.8 - '@types/react-dom': - specifier: 19.1.6 - version: 19.1.6(@types/react@19.1.8) - '@vitejs/plugin-react': - specifier: 4.6.0 - version: 4.6.0(vite@7.0.4(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)) - autoprefixer: - specifier: 10.4.24 - version: 10.4.24(postcss@8.5.6) - eslint: - specifier: 10.1.0 - version: 10.1.0(jiti@2.6.1) - eslint-plugin-boundaries: - specifier: 6.0.2 - version: 6.0.2(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3))(eslint@10.1.0(jiti@2.6.1)) - eslint-plugin-react-hooks: - specifier: 7.1.0-canary-ed69815c-20260323 - version: 7.1.0-canary-ed69815c-20260323(eslint@10.1.0(jiti@2.6.1)) - husky: - specifier: 9.1.7 - version: 9.1.7 - jsdom: - specifier: 29.0.1 - version: 29.0.1 - lint-staged: - specifier: 16.4.0 - version: 16.4.0 - pinyin-pro: - specifier: 3.28.1 - version: 3.28.1 - postcss: - specifier: 8.5.6 - version: 8.5.6 - tailwindcss: - specifier: 4.1.18 - version: 4.1.18 - typescript: - specifier: 5.8.3 - version: 5.8.3 - typescript-eslint: - specifier: 8.58.0 - version: 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3) - vite: - specifier: 7.0.4 - version: 7.0.4(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3) - vitest: - specifier: 4.1.2 - version: 4.1.2(@types/node@25.2.1)(jsdom@29.0.1)(vite@7.0.4(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)) - -packages: - - '@adobe/css-tools@4.4.4': - resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} - - '@asamuzakjp/css-color@5.1.11': - resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - '@asamuzakjp/dom-selector@7.0.10': - resolution: {integrity: sha512-KyOb19eytNSELkmdqzZZUXWCU25byIlOld5qVFg0RYdS0T3tt7jeDByxk9hIAC73frclD8GKrHttr0SUjKCCdQ==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - '@asamuzakjp/generational-cache@1.0.1': - resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - '@asamuzakjp/nwsapi@2.3.9': - resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} - - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.28.6': - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-transform-react-jsx-self@7.27.1': - resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.27.1': - resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/runtime@7.29.2': - resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} - engines: {node: '>=6.9.0'} - - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - - '@boundaries/elements@2.0.1': - resolution: {integrity: sha512-sAWO3D8PFP6pBXdxxW93SQi/KQqqhE2AAHo3AgWfdtJXwO6bfK6/wUN81XnOZk0qRC6vHzUEKhjwVD9dtDWvxg==} - engines: {node: '>=18.18'} - - '@bramus/specificity@2.4.2': - resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} - hasBin: true - - '@csstools/color-helpers@6.0.2': - resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} - engines: {node: '>=20.19.0'} - - '@csstools/css-calc@3.2.0': - resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.0 - '@csstools/css-tokenizer': ^4.0.0 - - '@csstools/css-color-parser@4.1.0': - resolution: {integrity: sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.0 - '@csstools/css-tokenizer': ^4.0.0 - - '@csstools/css-parser-algorithms@4.0.0': - resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-tokenizer': ^4.0.0 - - '@csstools/css-syntax-patches-for-csstree@1.1.3': - resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==} - peerDependencies: - css-tree: ^3.2.1 - peerDependenciesMeta: - css-tree: - optional: true - - '@csstools/css-tokenizer@4.0.0': - resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} - engines: {node: '>=20.19.0'} - - '@dnd-kit/accessibility@3.1.1': - resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} - peerDependencies: - react: '>=16.8.0' - - '@dnd-kit/core@6.3.1': - resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@dnd-kit/sortable@10.0.0': - resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} - peerDependencies: - '@dnd-kit/core': ^6.3.0 - react: '>=16.8.0' - - '@dnd-kit/utilities@3.2.2': - resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} - peerDependencies: - react: '>=16.8.0' - - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/config-array@0.23.5': - resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/config-helpers@0.5.5': - resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/core@1.2.1': - resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/js@10.0.1': - resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - peerDependencies: - eslint: ^10.0.0 - peerDependenciesMeta: - eslint: - optional: true - - '@eslint/object-schema@3.0.5': - resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/plugin-kit@0.6.1': - resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@exodus/bytes@1.15.0': - resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - peerDependencies: - '@noble/hashes': ^1.8.0 || ^2.0.0 - peerDependenciesMeta: - '@noble/hashes': - optional: true - - '@floating-ui/core@1.7.5': - resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} - - '@floating-ui/dom@1.7.6': - resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} - - '@floating-ui/react-dom@2.1.8': - resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@floating-ui/utils@0.2.11': - resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} - - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@monaco-editor/loader@1.7.0': - resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} - - '@monaco-editor/react@4.7.0': - resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} - peerDependencies: - monaco-editor: '>= 0.25.0 < 1' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - '@radix-ui/number@1.1.1': - resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} - - '@radix-ui/primitive@1.1.3': - resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} - - '@radix-ui/react-arrow@1.1.7': - resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-collection@1.1.7': - resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-compose-refs@1.1.2': - resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-context@1.1.2': - resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-dialog@1.1.15': - resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-direction@1.1.1': - resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-dismissable-layer@1.1.11': - resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-dropdown-menu@2.1.16': - resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-focus-guards@1.1.3': - resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-focus-scope@1.1.7': - resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-id@1.1.1': - resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-menu@2.1.16': - resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-popover@1.1.15': - resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-popper@1.2.8': - resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-portal@1.1.9': - resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-presence@1.1.5': - resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-primitive@2.1.3': - resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-roving-focus@1.1.11': - resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-select@2.2.6': - resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-slot@1.2.3': - resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-slot@1.2.4': - resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-tooltip@1.2.8': - resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-use-callback-ref@1.1.1': - resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-controllable-state@1.2.2': - resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-effect-event@0.0.2': - resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-escape-keydown@1.1.1': - resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-layout-effect@1.1.1': - resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-previous@1.1.1': - resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-rect@1.1.1': - resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-size@1.1.1': - resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-visually-hidden@1.2.3': - resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/rect@1.1.1': - resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - - '@rolldown/pluginutils@1.0.0-beta.19': - resolution: {integrity: sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==} - - '@rollup/rollup-android-arm-eabi@4.60.1': - resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.60.1': - resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.60.1': - resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.60.1': - resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.60.1': - resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.60.1': - resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.60.1': - resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.60.1': - resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.60.1': - resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.60.1': - resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-loong64-gnu@4.60.1': - resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-loong64-musl@4.60.1': - resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.60.1': - resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-ppc64-musl@4.60.1': - resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.60.1': - resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.60.1': - resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.60.1': - resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.60.1': - resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.60.1': - resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-openbsd-x64@4.60.1': - resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.60.1': - resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.60.1': - resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.60.1': - resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.60.1': - resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.60.1': - resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} - cpu: [x64] - os: [win32] - - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - - '@tailwindcss/node@4.1.18': - resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} - - '@tailwindcss/oxide-android-arm64@4.1.18': - resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [android] - - '@tailwindcss/oxide-darwin-arm64@4.1.18': - resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@tailwindcss/oxide-darwin-x64@4.1.18': - resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@tailwindcss/oxide-freebsd-x64@4.1.18': - resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [freebsd] - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': - resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': - resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-musl@4.1.18': - resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-gnu@4.1.18': - resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-musl@4.1.18': - resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-wasm32-wasi@4.1.18': - resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - bundledDependencies: - - '@napi-rs/wasm-runtime' - - '@emnapi/core' - - '@emnapi/runtime' - - '@tybys/wasm-util' - - '@emnapi/wasi-threads' - - tslib - - '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': - resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@tailwindcss/oxide-win32-x64-msvc@4.1.18': - resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@tailwindcss/oxide@4.1.18': - resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} - engines: {node: '>= 10'} - - '@tailwindcss/vite@4.1.18': - resolution: {integrity: sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==} - peerDependencies: - vite: ^5.2.0 || ^6 || ^7 - - '@tauri-apps/api@2.10.1': - resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} - - '@tauri-apps/cli-darwin-arm64@2.10.1': - resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@tauri-apps/cli-darwin-x64@2.10.1': - resolution: {integrity: sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@tauri-apps/cli-linux-arm-gnueabihf@2.10.1': - resolution: {integrity: sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - - '@tauri-apps/cli-linux-arm64-gnu@2.10.1': - resolution: {integrity: sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@tauri-apps/cli-linux-arm64-musl@2.10.1': - resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@tauri-apps/cli-linux-riscv64-gnu@2.10.1': - resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==} - engines: {node: '>= 10'} - cpu: [riscv64] - os: [linux] - - '@tauri-apps/cli-linux-x64-gnu@2.10.1': - resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@tauri-apps/cli-linux-x64-musl@2.10.1': - resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@tauri-apps/cli-win32-arm64-msvc@2.10.1': - resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@tauri-apps/cli-win32-ia32-msvc@2.10.1': - resolution: {integrity: sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - - '@tauri-apps/cli-win32-x64-msvc@2.10.1': - resolution: {integrity: sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@tauri-apps/cli@2.10.1': - resolution: {integrity: sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==} - engines: {node: '>= 10'} - hasBin: true - - '@tauri-apps/plugin-dialog@2.6.0': - resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==} - - '@tauri-apps/plugin-opener@2.5.3': - resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} - - '@tauri-apps/plugin-process@2.3.1': - resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} - - '@tauri-apps/plugin-updater@2.10.0': - resolution: {integrity: sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==} - - '@testing-library/dom@10.4.1': - resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} - engines: {node: '>=18'} - - '@testing-library/jest-dom@6.9.1': - resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} - engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - - '@testing-library/react@16.3.2': - resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} - engines: {node: '>=18'} - peerDependencies: - '@testing-library/dom': ^10.0.0 - '@types/react': ^18.0.0 || ^19.0.0 - '@types/react-dom': ^18.0.0 || ^19.0.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@types/aria-query@5.0.4': - resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} - - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - - '@types/chai@5.2.3': - resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - - '@types/deep-eql@4.0.2': - resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - - '@types/esrecurse@4.3.1': - resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/node@25.2.1': - resolution: {integrity: sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==} - - '@types/react-dom@19.1.6': - resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==} - peerDependencies: - '@types/react': ^19.0.0 - - '@types/react@19.1.8': - resolution: {integrity: sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==} - - '@types/trusted-types@2.0.7': - resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - - '@typescript-eslint/eslint-plugin@8.58.0': - resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.58.0 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/parser@8.58.0': - resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/project-service@8.58.0': - resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/scope-manager@8.58.0': - resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.58.0': - resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/type-utils@8.58.0': - resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/types@8.58.0': - resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.58.0': - resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/utils@8.58.0': - resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/visitor-keys@8.58.0': - resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@vitejs/plugin-react@4.6.0': - resolution: {integrity: sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0 - - '@vitest/expect@4.1.2': - resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} - - '@vitest/mocker@4.1.2': - resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==} - peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@4.1.2': - resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} - - '@vitest/runner@4.1.2': - resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==} - - '@vitest/snapshot@4.1.2': - resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==} - - '@vitest/spy@4.1.2': - resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==} - - '@vitest/utils@4.1.2': - resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} - - '@xterm/addon-fit@0.11.0': - resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} - - '@xterm/addon-search@0.16.0': - resolution: {integrity: sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==} - - '@xterm/addon-unicode11@0.9.0': - resolution: {integrity: sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==} - - '@xterm/addon-web-links@0.12.0': - resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==} - - '@xterm/addon-webgl@0.19.0': - resolution: {integrity: sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==} - - '@xterm/xterm@6.0.0': - resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - - ansi-escapes@7.3.0: - resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} - engines: {node: '>=18'} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - - aria-hidden@1.2.6: - resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} - engines: {node: '>=10'} - - aria-query@5.3.0: - resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} - - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} - - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - - autoprefixer@10.4.24: - resolution: {integrity: sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 - - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - - baseline-browser-mapping@2.10.19: - resolution: {integrity: sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==} - engines: {node: '>=6.0.0'} - hasBin: true - - bidi-js@1.0.3: - resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} - - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} - engines: {node: 18 || 20 || >=22} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - caniuse-lite@1.0.30001788: - resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==} - - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} - engines: {node: '>=18'} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - class-variance-authority@0.7.1: - resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - - cli-cursor@5.0.0: - resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} - engines: {node: '>=18'} - - cli-truncate@5.2.0: - resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} - engines: {node: '>=20'} - - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - colorette@2.0.20: - resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - - commander@14.0.3: - resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} - engines: {node: '>=20'} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - css-tree@3.2.1: - resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - - css.escape@1.5.1: - resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - data-urls@7.0.0: - resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - debug@3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decimal.js@10.6.0: - resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - detect-node-es@1.1.0: - resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} - - dom-accessibility-api@0.5.16: - resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - - dom-accessibility-api@0.6.3: - resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - - dompurify@3.2.7: - resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} - - electron-to-chromium@1.5.339: - resolution: {integrity: sha512-Is+0BBHJ4NrdpAYiperrmp53pLywG/yV/6lIMTAnhxvzj/Cmn5Q/ogSHC6AKe7X+8kPLxxFk0cs5oc/3j/fxIg==} - - emoji-regex@10.6.0: - resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} - - enhanced-resolve@5.20.1: - resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} - engines: {node: '>=10.13.0'} - - entities@6.0.1: - resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} - engines: {node: '>=0.12'} - - environment@1.1.0: - resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} - engines: {node: '>=18'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-module-lexer@2.0.0: - resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} - - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-import-resolver-node@0.3.9: - resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} - - eslint-module-utils@2.12.1: - resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: '*' - eslint-import-resolver-node: '*' - eslint-import-resolver-typescript: '*' - eslint-import-resolver-webpack: '*' - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint: - optional: true - eslint-import-resolver-node: - optional: true - eslint-import-resolver-typescript: - optional: true - eslint-import-resolver-webpack: - optional: true - - eslint-plugin-boundaries@6.0.2: - resolution: {integrity: sha512-wSHgiYeMEbziP91lH0UQ9oslgF2djG1x+LV9z/qO19ggMKZaCB8pKIGePHAY91eLF4EAgpsxQk8MRSFGRPfPzw==} - engines: {node: '>=18.18'} - peerDependencies: - eslint: '>=6.0.0' - - eslint-plugin-react-hooks@7.1.0-canary-ed69815c-20260323: - resolution: {integrity: sha512-05SifWyQ31QD76cMXlfpzscZ3LE3qDZwrmjf8r73LSVff4ouZN+q0WA+8uBYy49Py1EGHa7r87g8hp0+/GULvQ==} - engines: {node: '>=18'} - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 - - eslint-scope@9.1.2: - resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint@10.1.0: - resolution: {integrity: sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@11.2.0: - resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - eventemitter3@5.0.4: - resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} - engines: {node: '>=12.0.0'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - - fraction.js@5.3.4: - resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-east-asian-width@1.5.0: - resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} - engines: {node: '>=18'} - - get-nonce@1.0.1: - resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} - engines: {node: '>=6'} - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - handlebars@4.7.9: - resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} - engines: {node: '>=0.4.7'} - hasBin: true - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - hermes-estree@0.25.1: - resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} - - hermes-parser@0.25.1: - resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - - highlight.js@11.11.1: - resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} - engines: {node: '>=12.0.0'} - - html-encoding-sniffer@6.0.0: - resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - html-parse-stringify@3.0.1: - resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} - - husky@9.1.7: - resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} - engines: {node: '>=18'} - hasBin: true - - i18next@25.8.13: - resolution: {integrity: sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA==} - peerDependencies: - typescript: ^5 - peerDependenciesMeta: - typescript: - optional: true - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - indent-string@4.0.0: - resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} - engines: {node: '>=8'} - - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-fullwidth-code-point@5.1.0: - resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} - engines: {node: '>=18'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-potential-custom-element-name@1.0.1: - resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} - hasBin: true - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - jsdom@29.0.1: - resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} - peerDependencies: - canvas: ^3.0.0 - peerDependenciesMeta: - canvas: - optional: true - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - - lightningcss-android-arm64@1.30.2: - resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.30.2: - resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.30.2: - resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.30.2: - resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.30.2: - resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.30.2: - resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-arm64-musl@1.30.2: - resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-x64-gnu@1.30.2: - resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-linux-x64-musl@1.30.2: - resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-win32-arm64-msvc@1.30.2: - resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.30.2: - resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.30.2: - resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} - engines: {node: '>= 12.0.0'} - - lint-staged@16.4.0: - resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} - engines: {node: '>=20.17'} - hasBin: true - - listr2@9.0.5: - resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} - engines: {node: '>=20.0.0'} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - log-update@6.1.0: - resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} - engines: {node: '>=18'} - - lru-cache@11.3.5: - resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} - engines: {node: 20 || >=22} - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - lucide-react@0.563.0: - resolution: {integrity: sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - lz-string@1.5.0: - resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} - hasBin: true - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - marked@14.0.0: - resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} - engines: {node: '>= 18'} - hasBin: true - - mdn-data@2.27.1: - resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mimic-function@5.0.1: - resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} - engines: {node: '>=18'} - - min-indent@1.0.1: - resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} - engines: {node: '>=4'} - - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - monaco-editor@0.55.1: - resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - - node-releases@2.0.37: - resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} - - obug@2.1.1: - resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - - onetime@7.0.0: - resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} - engines: {node: '>=18'} - - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - parse5@8.0.0: - resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - - pinyin-pro@3.28.1: - resolution: {integrity: sha512-oqz8ulwRgtUXRi0vbqEfGNly19zpyCxYrjhkk5TibGcgSW6eNwS5woajCXRwqURi8Ehc2yOFTiB4uNoZ+NJOnA==} - - postcss-value-parser@4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} - engines: {node: ^10 || ^12 || >=14} - - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - pretty-format@27.5.1: - resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} - engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - qrcode.react@4.2.0: - resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - react-dom@19.1.0: - resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} - peerDependencies: - react: ^19.1.0 - - react-i18next@16.5.4: - resolution: {integrity: sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==} - peerDependencies: - i18next: '>= 25.6.2' - react: '>= 16.8.0' - react-dom: '*' - react-native: '*' - typescript: ^5 - peerDependenciesMeta: - react-dom: - optional: true - react-native: - optional: true - typescript: - optional: true - - react-is@17.0.2: - resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - - react-refresh@0.17.0: - resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} - engines: {node: '>=0.10.0'} - - react-remove-scroll-bar@2.3.8: - resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - react-remove-scroll@2.7.2: - resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - react-style-singleton@2.2.3: - resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - react@19.1.0: - resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} - engines: {node: '>=0.10.0'} - - redent@3.0.0: - resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} - engines: {node: '>=8'} - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - resolve@1.22.12: - resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} - engines: {node: '>= 0.4'} - hasBin: true - - restore-cursor@5.1.0: - resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} - engines: {node: '>=18'} - - rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - - rollup@4.60.1: - resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - saxes@6.0.0: - resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} - engines: {node: '>=v12.22.7'} - - scheduler@0.26.0: - resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - slice-ansi@7.1.2: - resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} - engines: {node: '>=18'} - - slice-ansi@8.0.0: - resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} - engines: {node: '>=20'} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - state-local@1.0.7: - resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} - - std-env@4.1.0: - resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} - - string-argv@0.3.2: - resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} - engines: {node: '>=0.6.19'} - - string-width@7.2.0: - resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} - engines: {node: '>=18'} - - string-width@8.2.0: - resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} - engines: {node: '>=20'} - - strip-ansi@7.2.0: - resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} - engines: {node: '>=12'} - - strip-indent@3.0.0: - resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} - engines: {node: '>=8'} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - symbol-tree@3.2.4: - resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - - tailwind-merge@3.4.0: - resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} - - tailwindcss@4.1.18: - resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} - - tapable@2.3.2: - resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} - engines: {node: '>=6'} - - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - - tinyexec@1.1.1: - resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} - engines: {node: '>=18'} - - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} - engines: {node: '>=12.0.0'} - - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} - engines: {node: '>=14.0.0'} - - tldts-core@7.0.28: - resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==} - - tldts@7.0.28: - resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==} - hasBin: true - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - tough-cookie@6.0.1: - resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} - engines: {node: '>=16'} - - tr46@6.0.0: - resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} - engines: {node: '>=20'} - - ts-api-utils@2.5.0: - resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - typescript-eslint@8.58.0: - resolution: {integrity: sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - typescript@5.8.3: - resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} - engines: {node: '>=14.17'} - hasBin: true - - uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} - engines: {node: '>=0.8.0'} - hasBin: true - - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - - undici@7.25.0: - resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} - engines: {node: '>=20.18.1'} - - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - use-callback-ref@1.3.3: - resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - use-sidecar@1.1.3: - resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - use-sync-external-store@1.6.0: - resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - vite@7.0.4: - resolution: {integrity: sha512-SkaSguuS7nnmV7mfJ8l81JGBFV7Gvzp8IzgE8A8t23+AxuNX61Q5H1Tpz5efduSN7NHC8nQXD3sKQKZAu5mNEA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitest@4.1.2: - resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.2 - '@vitest/browser-preview': 4.1.2 - '@vitest/browser-webdriverio': 4.1.2 - '@vitest/ui': 4.1.2 - happy-dom: '*' - jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - void-elements@3.1.0: - resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} - engines: {node: '>=0.10.0'} - - w3c-xmlserializer@5.0.0: - resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} - engines: {node: '>=18'} - - webidl-conversions@8.0.1: - resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} - engines: {node: '>=20'} - - whatwg-mimetype@5.0.0: - resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} - engines: {node: '>=20'} - - whatwg-url@16.0.1: - resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - - wrap-ansi@9.0.2: - resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} - engines: {node: '>=18'} - - xml-name-validator@5.0.0: - resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} - engines: {node: '>=18'} - - xmlchars@2.2.0: - resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yaml@2.8.3: - resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} - engines: {node: '>= 14.6'} - hasBin: true - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - - zod-validation-error@4.0.2: - resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} - engines: {node: '>=18.0.0'} - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - -snapshots: - - '@adobe/css-tools@4.4.4': {} - - '@asamuzakjp/css-color@5.1.11': - dependencies: - '@asamuzakjp/generational-cache': 1.0.1 - '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-color-parser': 4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 - - '@asamuzakjp/dom-selector@7.0.10': - dependencies: - '@asamuzakjp/generational-cache': 1.0.1 - '@asamuzakjp/nwsapi': 2.3.9 - bidi-js: 1.0.3 - css-tree: 3.2.1 - is-potential-custom-element-name: 1.0.1 - - '@asamuzakjp/generational-cache@1.0.1': {} - - '@asamuzakjp/nwsapi@2.3.9': {} - - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.29.0': {} - - '@babel/core@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.28.6': - dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.2 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.28.0': {} - - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.28.6': {} - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} - - '@babel/helper-validator-option@7.27.1': {} - - '@babel/helpers@7.29.2': - dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - - '@babel/parser@7.29.2': - dependencies: - '@babel/types': 7.29.0 - - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/runtime@7.29.2': {} - - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@boundaries/elements@2.0.1(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3))(eslint@10.1.0(jiti@2.6.1))': - dependencies: - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)) - handlebars: 4.7.9 - is-core-module: 2.16.1 - micromatch: 4.0.8 - transitivePeerDependencies: - - '@typescript-eslint/parser' - - eslint - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - - '@bramus/specificity@2.4.2': - dependencies: - css-tree: 3.2.1 - - '@csstools/color-helpers@6.0.2': {} - - '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': - dependencies: - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 - - '@csstools/css-color-parser@4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': - dependencies: - '@csstools/color-helpers': 6.0.2 - '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 - - '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': - dependencies: - '@csstools/css-tokenizer': 4.0.0 - - '@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)': - optionalDependencies: - css-tree: 3.2.1 - - '@csstools/css-tokenizer@4.0.0': {} - - '@dnd-kit/accessibility@3.1.1(react@19.1.0)': - dependencies: - react: 19.1.0 - tslib: 2.8.1 - - '@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@dnd-kit/accessibility': 3.1.1(react@19.1.0) - '@dnd-kit/utilities': 3.2.2(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - tslib: 2.8.1 - - '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': - dependencies: - '@dnd-kit/core': 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@dnd-kit/utilities': 3.2.2(react@19.1.0) - react: 19.1.0 - tslib: 2.8.1 - - '@dnd-kit/utilities@3.2.2(react@19.1.0)': - dependencies: - react: 19.1.0 - tslib: 2.8.1 - - '@esbuild/aix-ppc64@0.25.12': - optional: true - - '@esbuild/android-arm64@0.25.12': - optional: true - - '@esbuild/android-arm@0.25.12': - optional: true - - '@esbuild/android-x64@0.25.12': - optional: true - - '@esbuild/darwin-arm64@0.25.12': - optional: true - - '@esbuild/darwin-x64@0.25.12': - optional: true - - '@esbuild/freebsd-arm64@0.25.12': - optional: true - - '@esbuild/freebsd-x64@0.25.12': - optional: true - - '@esbuild/linux-arm64@0.25.12': - optional: true - - '@esbuild/linux-arm@0.25.12': - optional: true - - '@esbuild/linux-ia32@0.25.12': - optional: true - - '@esbuild/linux-loong64@0.25.12': - optional: true - - '@esbuild/linux-mips64el@0.25.12': - optional: true - - '@esbuild/linux-ppc64@0.25.12': - optional: true - - '@esbuild/linux-riscv64@0.25.12': - optional: true - - '@esbuild/linux-s390x@0.25.12': - optional: true - - '@esbuild/linux-x64@0.25.12': - optional: true - - '@esbuild/netbsd-arm64@0.25.12': - optional: true - - '@esbuild/netbsd-x64@0.25.12': - optional: true - - '@esbuild/openbsd-arm64@0.25.12': - optional: true - - '@esbuild/openbsd-x64@0.25.12': - optional: true - - '@esbuild/openharmony-arm64@0.25.12': - optional: true - - '@esbuild/sunos-x64@0.25.12': - optional: true - - '@esbuild/win32-arm64@0.25.12': - optional: true - - '@esbuild/win32-ia32@0.25.12': - optional: true - - '@esbuild/win32-x64@0.25.12': - optional: true - - '@eslint-community/eslint-utils@4.9.1(eslint@10.1.0(jiti@2.6.1))': - dependencies: - eslint: 10.1.0(jiti@2.6.1) - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/config-array@0.23.5': - dependencies: - '@eslint/object-schema': 3.0.5 - debug: 4.4.3 - minimatch: 10.2.5 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.5.5': - dependencies: - '@eslint/core': 1.2.1 - - '@eslint/core@1.2.1': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/js@10.0.1(eslint@10.1.0(jiti@2.6.1))': - optionalDependencies: - eslint: 10.1.0(jiti@2.6.1) - - '@eslint/object-schema@3.0.5': {} - - '@eslint/plugin-kit@0.6.1': - dependencies: - '@eslint/core': 1.2.1 - levn: 0.4.1 - - '@exodus/bytes@1.15.0': {} - - '@floating-ui/core@1.7.5': - dependencies: - '@floating-ui/utils': 0.2.11 - - '@floating-ui/dom@1.7.6': - dependencies: - '@floating-ui/core': 1.7.5 - '@floating-ui/utils': 0.2.11 - - '@floating-ui/react-dom@2.1.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@floating-ui/dom': 1.7.6 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - - '@floating-ui/utils@0.2.11': {} - - '@humanfs/core@0.19.1': {} - - '@humanfs/node@0.16.7': - dependencies: - '@humanfs/core': 0.19.1 - '@humanwhocodes/retry': 0.4.3 - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@monaco-editor/loader@1.7.0': - dependencies: - state-local: 1.0.7 - - '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@monaco-editor/loader': 1.7.0 - monaco-editor: 0.55.1 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - - '@radix-ui/number@1.1.1': {} - - '@radix-ui/primitive@1.1.3': {} - - '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) - - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) - - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.8)(react@19.1.0)': - dependencies: - react: 19.1.0 - optionalDependencies: - '@types/react': 19.1.8 - - '@radix-ui/react-context@1.1.2(@types/react@19.1.8)(react@19.1.0)': - dependencies: - react: 19.1.0 - optionalDependencies: - '@types/react': 19.1.8 - - '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) - aria-hidden: 1.2.6 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - react-remove-scroll: 2.7.2(@types/react@19.1.8)(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) - - '@radix-ui/react-direction@1.1.1(@types/react@19.1.8)(react@19.1.0)': - dependencies: - react: 19.1.0 - optionalDependencies: - '@types/react': 19.1.8 - - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.8)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) - - '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) - - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.1.8)(react@19.1.0)': - dependencies: - react: 19.1.0 - optionalDependencies: - '@types/react': 19.1.8 - - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) - - '@radix-ui/react-id@1.1.1(@types/react@19.1.8)(react@19.1.0)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) - react: 19.1.0 - optionalDependencies: - '@types/react': 19.1.8 - - '@radix-ui/react-menu@2.1.16(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0) - aria-hidden: 1.2.6 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - react-remove-scroll: 2.7.2(@types/react@19.1.8)(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) - - '@radix-ui/react-popover@1.1.15(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) - aria-hidden: 1.2.6 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - react-remove-scroll: 2.7.2(@types/react@19.1.8)(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) - - '@radix-ui/react-popper@1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/rect': 1.1.1 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) - - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) - - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) - - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) - - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) - - '@radix-ui/react-select@2.2.6(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - aria-hidden: 1.2.6 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - react-remove-scroll: 2.7.2(@types/react@19.1.8)(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) - - '@radix-ui/react-slot@1.2.3(@types/react@19.1.8)(react@19.1.0)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) - react: 19.1.0 - optionalDependencies: - '@types/react': 19.1.8 - - '@radix-ui/react-slot@1.2.4(@types/react@19.1.8)(react@19.1.0)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) - react: 19.1.0 - optionalDependencies: - '@types/react': 19.1.8 - - '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) - - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.8)(react@19.1.0)': - dependencies: - react: 19.1.0 - optionalDependencies: - '@types/react': 19.1.8 - - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.1.8)(react@19.1.0)': - dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.8)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) - react: 19.1.0 - optionalDependencies: - '@types/react': 19.1.8 - - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.1.8)(react@19.1.0)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) - react: 19.1.0 - optionalDependencies: - '@types/react': 19.1.8 - - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.1.8)(react@19.1.0)': - dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0) - react: 19.1.0 - optionalDependencies: - '@types/react': 19.1.8 - - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.1.8)(react@19.1.0)': - dependencies: - react: 19.1.0 - optionalDependencies: - '@types/react': 19.1.8 - - '@radix-ui/react-use-previous@1.1.1(@types/react@19.1.8)(react@19.1.0)': - dependencies: - react: 19.1.0 - optionalDependencies: - '@types/react': 19.1.8 - - '@radix-ui/react-use-rect@1.1.1(@types/react@19.1.8)(react@19.1.0)': - dependencies: - '@radix-ui/rect': 1.1.1 - react: 19.1.0 - optionalDependencies: - '@types/react': 19.1.8 - - '@radix-ui/react-use-size@1.1.1(@types/react@19.1.8)(react@19.1.0)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) - react: 19.1.0 - optionalDependencies: - '@types/react': 19.1.8 - - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) - - '@radix-ui/rect@1.1.1': {} - - '@rolldown/pluginutils@1.0.0-beta.19': {} - - '@rollup/rollup-android-arm-eabi@4.60.1': - optional: true - - '@rollup/rollup-android-arm64@4.60.1': - optional: true - - '@rollup/rollup-darwin-arm64@4.60.1': - optional: true - - '@rollup/rollup-darwin-x64@4.60.1': - optional: true - - '@rollup/rollup-freebsd-arm64@4.60.1': - optional: true - - '@rollup/rollup-freebsd-x64@4.60.1': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.60.1': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.60.1': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.60.1': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.60.1': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.60.1': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.60.1': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.60.1': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.60.1': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.60.1': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.60.1': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.60.1': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.60.1': - optional: true - - '@rollup/rollup-linux-x64-musl@4.60.1': - optional: true - - '@rollup/rollup-openbsd-x64@4.60.1': - optional: true - - '@rollup/rollup-openharmony-arm64@4.60.1': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.60.1': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.60.1': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.60.1': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.60.1': - optional: true - - '@standard-schema/spec@1.1.0': {} - - '@tailwindcss/node@4.1.18': - dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.20.1 - jiti: 2.6.1 - lightningcss: 1.30.2 - magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.1.18 - - '@tailwindcss/oxide-android-arm64@4.1.18': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.1.18': - optional: true - - '@tailwindcss/oxide-darwin-x64@4.1.18': - optional: true - - '@tailwindcss/oxide-freebsd-x64@4.1.18': - optional: true - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': - optional: true - - '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': - optional: true - - '@tailwindcss/oxide-linux-arm64-musl@4.1.18': - optional: true - - '@tailwindcss/oxide-linux-x64-gnu@4.1.18': - optional: true - - '@tailwindcss/oxide-linux-x64-musl@4.1.18': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.1.18': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.1.18': - optional: true - - '@tailwindcss/oxide@4.1.18': - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-x64': 4.1.18 - '@tailwindcss/oxide-freebsd-x64': 4.1.18 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-x64-musl': 4.1.18 - '@tailwindcss/oxide-wasm32-wasi': 4.1.18 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - - '@tailwindcss/vite@4.1.18(vite@7.0.4(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))': - dependencies: - '@tailwindcss/node': 4.1.18 - '@tailwindcss/oxide': 4.1.18 - tailwindcss: 4.1.18 - vite: 7.0.4(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3) - - '@tauri-apps/api@2.10.1': {} - - '@tauri-apps/cli-darwin-arm64@2.10.1': - optional: true - - '@tauri-apps/cli-darwin-x64@2.10.1': - optional: true - - '@tauri-apps/cli-linux-arm-gnueabihf@2.10.1': - optional: true - - '@tauri-apps/cli-linux-arm64-gnu@2.10.1': - optional: true - - '@tauri-apps/cli-linux-arm64-musl@2.10.1': - optional: true - - '@tauri-apps/cli-linux-riscv64-gnu@2.10.1': - optional: true - - '@tauri-apps/cli-linux-x64-gnu@2.10.1': - optional: true - - '@tauri-apps/cli-linux-x64-musl@2.10.1': - optional: true - - '@tauri-apps/cli-win32-arm64-msvc@2.10.1': - optional: true - - '@tauri-apps/cli-win32-ia32-msvc@2.10.1': - optional: true - - '@tauri-apps/cli-win32-x64-msvc@2.10.1': - optional: true - - '@tauri-apps/cli@2.10.1': - optionalDependencies: - '@tauri-apps/cli-darwin-arm64': 2.10.1 - '@tauri-apps/cli-darwin-x64': 2.10.1 - '@tauri-apps/cli-linux-arm-gnueabihf': 2.10.1 - '@tauri-apps/cli-linux-arm64-gnu': 2.10.1 - '@tauri-apps/cli-linux-arm64-musl': 2.10.1 - '@tauri-apps/cli-linux-riscv64-gnu': 2.10.1 - '@tauri-apps/cli-linux-x64-gnu': 2.10.1 - '@tauri-apps/cli-linux-x64-musl': 2.10.1 - '@tauri-apps/cli-win32-arm64-msvc': 2.10.1 - '@tauri-apps/cli-win32-ia32-msvc': 2.10.1 - '@tauri-apps/cli-win32-x64-msvc': 2.10.1 - - '@tauri-apps/plugin-dialog@2.6.0': - dependencies: - '@tauri-apps/api': 2.10.1 - - '@tauri-apps/plugin-opener@2.5.3': - dependencies: - '@tauri-apps/api': 2.10.1 - - '@tauri-apps/plugin-process@2.3.1': - dependencies: - '@tauri-apps/api': 2.10.1 - - '@tauri-apps/plugin-updater@2.10.0': - dependencies: - '@tauri-apps/api': 2.10.1 - - '@testing-library/dom@10.4.1': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.29.2 - '@types/aria-query': 5.0.4 - aria-query: 5.3.0 - dom-accessibility-api: 0.5.16 - lz-string: 1.5.0 - picocolors: 1.1.1 - pretty-format: 27.5.1 - - '@testing-library/jest-dom@6.9.1': - dependencies: - '@adobe/css-tools': 4.4.4 - aria-query: 5.3.2 - css.escape: 1.5.1 - dom-accessibility-api: 0.6.3 - picocolors: 1.1.1 - redent: 3.0.0 - - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@babel/runtime': 7.29.2 - '@testing-library/dom': 10.4.1 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) - - '@types/aria-query@5.0.4': {} - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/chai@5.2.3': - dependencies: - '@types/deep-eql': 4.0.2 - assertion-error: 2.0.1 - - '@types/deep-eql@4.0.2': {} - - '@types/esrecurse@4.3.1': {} - - '@types/estree@1.0.8': {} - - '@types/json-schema@7.0.15': {} - - '@types/node@25.2.1': - dependencies: - undici-types: 7.16.0 - - '@types/react-dom@19.1.6(@types/react@19.1.8)': - dependencies: - '@types/react': 19.1.8 - - '@types/react@19.1.8': - dependencies: - csstype: 3.2.3 - - '@types/trusted-types@2.0.7': - optional: true - - '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/type-utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3) - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3) - '@typescript-eslint/visitor-keys': 8.58.0 - eslint: 10.1.0(jiti@2.6.1) - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.8.3) - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.8.3) - '@typescript-eslint/visitor-keys': 8.58.0 - debug: 4.4.3 - eslint: 10.1.0(jiti@2.6.1) - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.58.0(typescript@5.8.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.8.3) - '@typescript-eslint/types': 8.58.0 - debug: 4.4.3 - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.58.0': - dependencies: - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/visitor-keys': 8.58.0 - - '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.8.3)': - dependencies: - typescript: 5.8.3 - - '@typescript-eslint/type-utils@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3)': - dependencies: - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.8.3) - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3) - debug: 4.4.3 - eslint: 10.1.0(jiti@2.6.1) - ts-api-utils: 2.5.0(typescript@5.8.3) - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@8.58.0': {} - - '@typescript-eslint/typescript-estree@8.58.0(typescript@5.8.3)': - dependencies: - '@typescript-eslint/project-service': 8.58.0(typescript@5.8.3) - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.8.3) - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/visitor-keys': 8.58.0 - debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.7.4 - tinyglobby: 0.2.16 - ts-api-utils: 2.5.0(typescript@5.8.3) - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.8.3) - eslint: 10.1.0(jiti@2.6.1) - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.58.0': - dependencies: - '@typescript-eslint/types': 8.58.0 - eslint-visitor-keys: 5.0.1 - - '@vitejs/plugin-react@4.6.0(vite@7.0.4(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))': - dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) - '@rolldown/pluginutils': 1.0.0-beta.19 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 7.0.4(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3) - transitivePeerDependencies: - - supports-color - - '@vitest/expect@4.1.2': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.1.2 - '@vitest/utils': 4.1.2 - chai: 6.2.2 - tinyrainbow: 3.1.0 - - '@vitest/mocker@4.1.2(vite@7.0.4(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))': - dependencies: - '@vitest/spy': 4.1.2 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.0.4(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3) - - '@vitest/pretty-format@4.1.2': - dependencies: - tinyrainbow: 3.1.0 - - '@vitest/runner@4.1.2': - dependencies: - '@vitest/utils': 4.1.2 - pathe: 2.0.3 - - '@vitest/snapshot@4.1.2': - dependencies: - '@vitest/pretty-format': 4.1.2 - '@vitest/utils': 4.1.2 - magic-string: 0.30.21 - pathe: 2.0.3 - - '@vitest/spy@4.1.2': {} - - '@vitest/utils@4.1.2': - dependencies: - '@vitest/pretty-format': 4.1.2 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 - - '@xterm/addon-fit@0.11.0': {} - - '@xterm/addon-search@0.16.0': {} - - '@xterm/addon-unicode11@0.9.0': {} - - '@xterm/addon-web-links@0.12.0': {} - - '@xterm/addon-webgl@0.19.0': {} - - '@xterm/xterm@6.0.0': {} - - acorn-jsx@5.3.2(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - - acorn@8.16.0: {} - - ajv@6.14.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - ansi-escapes@7.3.0: - dependencies: - environment: 1.1.0 - - ansi-regex@5.0.1: {} - - ansi-regex@6.2.2: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansi-styles@5.2.0: {} - - ansi-styles@6.2.3: {} - - aria-hidden@1.2.6: - dependencies: - tslib: 2.8.1 - - aria-query@5.3.0: - dependencies: - dequal: 2.0.3 - - aria-query@5.3.2: {} - - assertion-error@2.0.1: {} - - autoprefixer@10.4.24(postcss@8.5.6): - dependencies: - browserslist: 4.28.2 - caniuse-lite: 1.0.30001788 - fraction.js: 5.3.4 - picocolors: 1.1.1 - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - - balanced-match@4.0.4: {} - - baseline-browser-mapping@2.10.19: {} - - bidi-js@1.0.3: - dependencies: - require-from-string: 2.0.2 - - brace-expansion@5.0.5: - dependencies: - balanced-match: 4.0.4 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - browserslist@4.28.2: - dependencies: - baseline-browser-mapping: 2.10.19 - caniuse-lite: 1.0.30001788 - electron-to-chromium: 1.5.339 - node-releases: 2.0.37 - update-browserslist-db: 1.2.3(browserslist@4.28.2) - - caniuse-lite@1.0.30001788: {} - - chai@6.2.2: {} - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - class-variance-authority@0.7.1: - dependencies: - clsx: 2.1.1 - - cli-cursor@5.0.0: - dependencies: - restore-cursor: 5.1.0 - - cli-truncate@5.2.0: - dependencies: - slice-ansi: 8.0.0 - string-width: 8.2.0 - - clsx@2.1.1: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - colorette@2.0.20: {} - - commander@14.0.3: {} - - convert-source-map@2.0.0: {} - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - css-tree@3.2.1: - dependencies: - mdn-data: 2.27.1 - source-map-js: 1.2.1 - - css.escape@1.5.1: {} - - csstype@3.2.3: {} - - data-urls@7.0.0: - dependencies: - whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 - transitivePeerDependencies: - - '@noble/hashes' - - debug@3.2.7: - dependencies: - ms: 2.1.3 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decimal.js@10.6.0: {} - - deep-is@0.1.4: {} - - dequal@2.0.3: {} - - detect-libc@2.1.2: {} - - detect-node-es@1.1.0: {} - - dom-accessibility-api@0.5.16: {} - - dom-accessibility-api@0.6.3: {} - - dompurify@3.2.7: - optionalDependencies: - '@types/trusted-types': 2.0.7 - - electron-to-chromium@1.5.339: {} - - emoji-regex@10.6.0: {} - - enhanced-resolve@5.20.1: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.2 - - entities@6.0.1: {} - - environment@1.1.0: {} - - es-errors@1.3.0: {} - - es-module-lexer@2.0.0: {} - - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 - - escalade@3.2.0: {} - - escape-string-regexp@4.0.0: {} - - eslint-import-resolver-node@0.3.9: - dependencies: - debug: 3.2.7 - is-core-module: 2.16.1 - resolve: 1.22.12 - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3) - eslint: 10.1.0(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - transitivePeerDependencies: - - supports-color - - eslint-plugin-boundaries@6.0.2(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3))(eslint@10.1.0(jiti@2.6.1)): - dependencies: - '@boundaries/elements': 2.0.1(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3))(eslint@10.1.0(jiti@2.6.1)) - chalk: 4.1.2 - eslint: 10.1.0(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)) - handlebars: 4.7.9 - micromatch: 4.0.8 - transitivePeerDependencies: - - '@typescript-eslint/parser' - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - - eslint-plugin-react-hooks@7.1.0-canary-ed69815c-20260323(eslint@10.1.0(jiti@2.6.1)): - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 - eslint: 10.1.0(jiti@2.6.1) - hermes-parser: 0.25.1 - zod: 4.3.6 - zod-validation-error: 4.0.2(zod@4.3.6) - transitivePeerDependencies: - - supports-color - - eslint-scope@9.1.2: - dependencies: - '@types/esrecurse': 4.3.1 - '@types/estree': 1.0.8 - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@5.0.1: {} - - eslint@10.1.0(jiti@2.6.1): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 - '@eslint/config-helpers': 0.5.5 - '@eslint/core': 1.2.1 - '@eslint/plugin-kit': 0.6.1 - '@humanfs/node': 0.16.7 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.14.0 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 9.1.2 - eslint-visitor-keys: 5.0.1 - espree: 11.2.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.5 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 2.6.1 - transitivePeerDependencies: - - supports-color - - espree@11.2.0: - dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 5.0.1 - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.8 - - esutils@2.0.3: {} - - eventemitter3@5.0.4: {} - - expect-type@1.3.0: {} - - fast-deep-equal@3.1.3: {} - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@4.0.1: - dependencies: - flatted: 3.4.2 - keyv: 4.5.4 - - flatted@3.4.2: {} - - fraction.js@5.3.4: {} - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - gensync@1.0.0-beta.2: {} - - get-east-asian-width@1.5.0: {} - - get-nonce@1.0.1: {} - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - graceful-fs@4.2.11: {} - - handlebars@4.7.9: - dependencies: - minimist: 1.2.8 - neo-async: 2.6.2 - source-map: 0.6.1 - wordwrap: 1.0.0 - optionalDependencies: - uglify-js: 3.19.3 - - has-flag@4.0.0: {} - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - hermes-estree@0.25.1: {} - - hermes-parser@0.25.1: - dependencies: - hermes-estree: 0.25.1 - - highlight.js@11.11.1: {} - - html-encoding-sniffer@6.0.0: - dependencies: - '@exodus/bytes': 1.15.0 - transitivePeerDependencies: - - '@noble/hashes' - - html-parse-stringify@3.0.1: - dependencies: - void-elements: 3.1.0 - - husky@9.1.7: {} - - i18next@25.8.13(typescript@5.8.3): - dependencies: - '@babel/runtime': 7.29.2 - optionalDependencies: - typescript: 5.8.3 - - ignore@5.3.2: {} - - ignore@7.0.5: {} - - imurmurhash@0.1.4: {} - - indent-string@4.0.0: {} - - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - - is-extglob@2.1.1: {} - - is-fullwidth-code-point@5.1.0: - dependencies: - get-east-asian-width: 1.5.0 - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-number@7.0.0: {} - - is-potential-custom-element-name@1.0.1: {} - - isexe@2.0.0: {} - - jiti@2.6.1: {} - - js-tokens@4.0.0: {} - - jsdom@29.0.1: - dependencies: - '@asamuzakjp/css-color': 5.1.11 - '@asamuzakjp/dom-selector': 7.0.10 - '@bramus/specificity': 2.4.2 - '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) - '@exodus/bytes': 1.15.0 - css-tree: 3.2.1 - data-urls: 7.0.0 - decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0 - is-potential-custom-element-name: 1.0.1 - lru-cache: 11.3.5 - parse5: 8.0.0 - saxes: 6.0.0 - symbol-tree: 3.2.4 - tough-cookie: 6.0.1 - undici: 7.25.0 - w3c-xmlserializer: 5.0.0 - webidl-conversions: 8.0.1 - whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 - xml-name-validator: 5.0.0 - transitivePeerDependencies: - - '@noble/hashes' - - jsesc@3.1.0: {} - - json-buffer@3.0.1: {} - - json-schema-traverse@0.4.1: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - json5@2.2.3: {} - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - lightningcss-android-arm64@1.30.2: - optional: true - - lightningcss-darwin-arm64@1.30.2: - optional: true - - lightningcss-darwin-x64@1.30.2: - optional: true - - lightningcss-freebsd-x64@1.30.2: - optional: true - - lightningcss-linux-arm-gnueabihf@1.30.2: - optional: true - - lightningcss-linux-arm64-gnu@1.30.2: - optional: true - - lightningcss-linux-arm64-musl@1.30.2: - optional: true - - lightningcss-linux-x64-gnu@1.30.2: - optional: true - - lightningcss-linux-x64-musl@1.30.2: - optional: true - - lightningcss-win32-arm64-msvc@1.30.2: - optional: true - - lightningcss-win32-x64-msvc@1.30.2: - optional: true - - lightningcss@1.30.2: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.30.2 - lightningcss-darwin-arm64: 1.30.2 - lightningcss-darwin-x64: 1.30.2 - lightningcss-freebsd-x64: 1.30.2 - lightningcss-linux-arm-gnueabihf: 1.30.2 - lightningcss-linux-arm64-gnu: 1.30.2 - lightningcss-linux-arm64-musl: 1.30.2 - lightningcss-linux-x64-gnu: 1.30.2 - lightningcss-linux-x64-musl: 1.30.2 - lightningcss-win32-arm64-msvc: 1.30.2 - lightningcss-win32-x64-msvc: 1.30.2 - - lint-staged@16.4.0: - dependencies: - commander: 14.0.3 - listr2: 9.0.5 - picomatch: 4.0.4 - string-argv: 0.3.2 - tinyexec: 1.1.1 - yaml: 2.8.3 - - listr2@9.0.5: - dependencies: - cli-truncate: 5.2.0 - colorette: 2.0.20 - eventemitter3: 5.0.4 - log-update: 6.1.0 - rfdc: 1.4.1 - wrap-ansi: 9.0.2 - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - log-update@6.1.0: - dependencies: - ansi-escapes: 7.3.0 - cli-cursor: 5.0.0 - slice-ansi: 7.1.2 - strip-ansi: 7.2.0 - wrap-ansi: 9.0.2 - - lru-cache@11.3.5: {} - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - lucide-react@0.563.0(react@19.1.0): - dependencies: - react: 19.1.0 - - lz-string@1.5.0: {} - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - marked@14.0.0: {} - - mdn-data@2.27.1: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - - mimic-function@5.0.1: {} - - min-indent@1.0.1: {} - - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.5 - - minimist@1.2.8: {} - - monaco-editor@0.55.1: - dependencies: - dompurify: 3.2.7 - marked: 14.0.0 - - ms@2.1.3: {} - - nanoid@3.3.11: {} - - natural-compare@1.4.0: {} - - neo-async@2.6.2: {} - - node-releases@2.0.37: {} - - obug@2.1.1: {} - - onetime@7.0.0: - dependencies: - mimic-function: 5.0.1 - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - parse5@8.0.0: - dependencies: - entities: 6.0.1 - - path-exists@4.0.0: {} - - path-key@3.1.1: {} - - path-parse@1.0.7: {} - - pathe@2.0.3: {} - - picocolors@1.1.1: {} - - picomatch@2.3.2: {} - - picomatch@4.0.4: {} - - pinyin-pro@3.28.1: {} - - postcss-value-parser@4.2.0: {} - - postcss@8.5.6: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prelude-ls@1.2.1: {} - - pretty-format@27.5.1: - dependencies: - ansi-regex: 5.0.1 - ansi-styles: 5.2.0 - react-is: 17.0.2 - - punycode@2.3.1: {} - - qrcode.react@4.2.0(react@19.1.0): - dependencies: - react: 19.1.0 - - react-dom@19.1.0(react@19.1.0): - dependencies: - react: 19.1.0 - scheduler: 0.26.0 - - react-i18next@16.5.4(i18next@25.8.13(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3): - dependencies: - '@babel/runtime': 7.29.2 - html-parse-stringify: 3.0.1 - i18next: 25.8.13(typescript@5.8.3) - react: 19.1.0 - use-sync-external-store: 1.6.0(react@19.1.0) - optionalDependencies: - react-dom: 19.1.0(react@19.1.0) - typescript: 5.8.3 - - react-is@17.0.2: {} - - react-refresh@0.17.0: {} - - react-remove-scroll-bar@2.3.8(@types/react@19.1.8)(react@19.1.0): - dependencies: - react: 19.1.0 - react-style-singleton: 2.2.3(@types/react@19.1.8)(react@19.1.0) - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.1.8 - - react-remove-scroll@2.7.2(@types/react@19.1.8)(react@19.1.0): - dependencies: - react: 19.1.0 - react-remove-scroll-bar: 2.3.8(@types/react@19.1.8)(react@19.1.0) - react-style-singleton: 2.2.3(@types/react@19.1.8)(react@19.1.0) - tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.1.8)(react@19.1.0) - use-sidecar: 1.1.3(@types/react@19.1.8)(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.8 - - react-style-singleton@2.2.3(@types/react@19.1.8)(react@19.1.0): - dependencies: - get-nonce: 1.0.1 - react: 19.1.0 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.1.8 - - react@19.1.0: {} - - redent@3.0.0: - dependencies: - indent-string: 4.0.0 - strip-indent: 3.0.0 - - require-from-string@2.0.2: {} - - resolve@1.22.12: - dependencies: - es-errors: 1.3.0 - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - restore-cursor@5.1.0: - dependencies: - onetime: 7.0.0 - signal-exit: 4.1.0 - - rfdc@1.4.1: {} - - rollup@4.60.1: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.1 - '@rollup/rollup-android-arm64': 4.60.1 - '@rollup/rollup-darwin-arm64': 4.60.1 - '@rollup/rollup-darwin-x64': 4.60.1 - '@rollup/rollup-freebsd-arm64': 4.60.1 - '@rollup/rollup-freebsd-x64': 4.60.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 - '@rollup/rollup-linux-arm-musleabihf': 4.60.1 - '@rollup/rollup-linux-arm64-gnu': 4.60.1 - '@rollup/rollup-linux-arm64-musl': 4.60.1 - '@rollup/rollup-linux-loong64-gnu': 4.60.1 - '@rollup/rollup-linux-loong64-musl': 4.60.1 - '@rollup/rollup-linux-ppc64-gnu': 4.60.1 - '@rollup/rollup-linux-ppc64-musl': 4.60.1 - '@rollup/rollup-linux-riscv64-gnu': 4.60.1 - '@rollup/rollup-linux-riscv64-musl': 4.60.1 - '@rollup/rollup-linux-s390x-gnu': 4.60.1 - '@rollup/rollup-linux-x64-gnu': 4.60.1 - '@rollup/rollup-linux-x64-musl': 4.60.1 - '@rollup/rollup-openbsd-x64': 4.60.1 - '@rollup/rollup-openharmony-arm64': 4.60.1 - '@rollup/rollup-win32-arm64-msvc': 4.60.1 - '@rollup/rollup-win32-ia32-msvc': 4.60.1 - '@rollup/rollup-win32-x64-gnu': 4.60.1 - '@rollup/rollup-win32-x64-msvc': 4.60.1 - fsevents: 2.3.3 - - saxes@6.0.0: - dependencies: - xmlchars: 2.2.0 - - scheduler@0.26.0: {} - - semver@6.3.1: {} - - semver@7.7.4: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - siginfo@2.0.0: {} - - signal-exit@4.1.0: {} - - slice-ansi@7.1.2: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - - slice-ansi@8.0.0: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - - source-map-js@1.2.1: {} - - source-map@0.6.1: {} - - stackback@0.0.2: {} - - state-local@1.0.7: {} - - std-env@4.1.0: {} - - string-argv@0.3.2: {} - - string-width@7.2.0: - dependencies: - emoji-regex: 10.6.0 - get-east-asian-width: 1.5.0 - strip-ansi: 7.2.0 - - string-width@8.2.0: - dependencies: - get-east-asian-width: 1.5.0 - strip-ansi: 7.2.0 - - strip-ansi@7.2.0: - dependencies: - ansi-regex: 6.2.2 - - strip-indent@3.0.0: - dependencies: - min-indent: 1.0.1 - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-preserve-symlinks-flag@1.0.0: {} - - symbol-tree@3.2.4: {} - - tailwind-merge@3.4.0: {} - - tailwindcss@4.1.18: {} - - tapable@2.3.2: {} - - tinybench@2.9.0: {} - - tinyexec@1.1.1: {} - - tinyglobby@0.2.16: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - tinyrainbow@3.1.0: {} - - tldts-core@7.0.28: {} - - tldts@7.0.28: - dependencies: - tldts-core: 7.0.28 - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - tough-cookie@6.0.1: - dependencies: - tldts: 7.0.28 - - tr46@6.0.0: - dependencies: - punycode: 2.3.1 - - ts-api-utils@2.5.0(typescript@5.8.3): - dependencies: - typescript: 5.8.3 - - tslib@2.8.1: {} - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - typescript-eslint@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3): - dependencies: - '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3) - '@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3) - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.8.3) - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.8.3) - eslint: 10.1.0(jiti@2.6.1) - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - typescript@5.8.3: {} - - uglify-js@3.19.3: - optional: true - - undici-types@7.16.0: {} - - undici@7.25.0: {} - - update-browserslist-db@1.2.3(browserslist@4.28.2): - dependencies: - browserslist: 4.28.2 - escalade: 3.2.0 - picocolors: 1.1.1 - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - use-callback-ref@1.3.3(@types/react@19.1.8)(react@19.1.0): - dependencies: - react: 19.1.0 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.1.8 - - use-sidecar@1.1.3(@types/react@19.1.8)(react@19.1.0): - dependencies: - detect-node-es: 1.1.0 - react: 19.1.0 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.1.8 - - use-sync-external-store@1.6.0(react@19.1.0): - dependencies: - react: 19.1.0 - - vite@7.0.4(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.6 - rollup: 4.60.1 - tinyglobby: 0.2.16 - optionalDependencies: - '@types/node': 25.2.1 - fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.30.2 - yaml: 2.8.3 - - vitest@4.1.2(@types/node@25.2.1)(jsdom@29.0.1)(vite@7.0.4(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)): - dependencies: - '@vitest/expect': 4.1.2 - '@vitest/mocker': 4.1.2(vite@7.0.4(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)) - '@vitest/pretty-format': 4.1.2 - '@vitest/runner': 4.1.2 - '@vitest/snapshot': 4.1.2 - '@vitest/spy': 4.1.2 - '@vitest/utils': 4.1.2 - es-module-lexer: 2.0.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.1.1 - tinyglobby: 0.2.16 - tinyrainbow: 3.1.0 - vite: 7.0.4(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 25.2.1 - jsdom: 29.0.1 - transitivePeerDependencies: - - msw - - void-elements@3.1.0: {} - - w3c-xmlserializer@5.0.0: - dependencies: - xml-name-validator: 5.0.0 - - webidl-conversions@8.0.1: {} - - whatwg-mimetype@5.0.0: {} - - whatwg-url@16.0.1: - dependencies: - '@exodus/bytes': 1.15.0 - tr46: 6.0.0 - webidl-conversions: 8.0.1 - transitivePeerDependencies: - - '@noble/hashes' - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - - word-wrap@1.2.5: {} - - wordwrap@1.0.0: {} - - wrap-ansi@9.0.2: - dependencies: - ansi-styles: 6.2.3 - string-width: 7.2.0 - strip-ansi: 7.2.0 - - xml-name-validator@5.0.0: {} - - xmlchars@2.2.0: {} - - yallist@3.1.1: {} - - yaml@2.8.3: {} - - yocto-queue@0.1.0: {} - - zod-validation-error@4.0.2(zod@4.3.6): - dependencies: - zod: 4.3.6 - - zod@4.3.6: {} diff --git a/public/app-icon.svg b/public/app-icon.svg deleted file mode 100644 index 8daa3b2..0000000 --- a/public/app-icon.svg +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/tauri.svg b/public/tauri.svg deleted file mode 100644 index 31b62c9..0000000 --- a/public/tauri.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs deleted file mode 100644 index e55b08e..0000000 --- a/scripts/check-i18n.mjs +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env node -/** - * check-i18n.mjs - * - * Validates that every t() key used in source files exists in the locale files. - * Checks against en-US.json + zh-CN.json (flat key format). - * - * Run: node scripts/check-i18n.mjs - */ -import { readFileSync, readdirSync } from "fs"; -import { join, dirname } from "path"; -import { fileURLToPath } from "url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const SRC_DIR = join(__dirname, "../src"); -const LOCALES_DIR = join(__dirname, "../src/locales"); - -function loadLocales() { - const en = JSON.parse( - readFileSync(join(LOCALES_DIR, "en-US.json"), "utf-8") - ); - const zh = JSON.parse( - readFileSync(join(LOCALES_DIR, "zh-CN.json"), "utf-8") - ); - return { en, zh }; -} - -function flattenObject(obj, prefix = "") { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const newKey = prefix ? `${prefix}.${key}` : key; - if (value && typeof value === "object" && !Array.isArray(value)) { - Object.assign(result, flattenObject(value, newKey)); - } else { - result[newKey] = value; - } - } - return result; -} - -function extractKeysFromFile(filePath) { - const content = readFileSync(filePath, "utf-8"); - const tCallRegex = - /\bt\s*\(\s*(["'`])([^"'`\\]+?)\1\s*(?:,|\))/g; - const results = []; - let match; - while ((match = tCallRegex.exec(content)) !== null) { - const key = match[2]; - if (!key) continue; - // Skip dynamic keys - if (key.includes("/") || key.startsWith("@")) continue; - results.push(key); - } - return results; -} - -function scanSource(dir) { - const allKeys = []; - const entries = readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = join(dir, entry.name); - if (entry.isDirectory()) { - if (["node_modules", ".git"].includes(entry.name)) continue; - // Skip components/ui directory (Radix generated, no i18n) - if (dir.endsWith("components") && entry.name === "ui") continue; - allKeys.push(...scanSource(fullPath)); - } else if ( - entry.name.endsWith(".tsx") || - entry.name.endsWith(".ts") - ) { - allKeys.push(...extractKeysFromFile(fullPath)); - } - } - return allKeys; -} - -function validate() { - const { en, zh } = loadLocales(); - const enFlat = flattenObject(en); - const zhFlat = flattenObject(zh); - const sourceKeys = scanSource(SRC_DIR); - const allKeys = new Set([ - ...Object.keys(enFlat), - ...Object.keys(zhFlat), - ]); - let hasErrors = false; - for (const key of sourceKeys) { - if (!allKeys.has(key)) { - console.error( - `❌ Missing key: '${key}' — not found in any locale file` - ); - hasErrors = true; - } - } - if (hasErrors) { - console.error( - "\n💥 i18n validation failed — missing keys above" - ); - process.exit(1); - } else { - console.log( - `✅ All ${sourceKeys.length} i18n keys are valid (checked against en-US.json + zh-CN.json)` - ); - process.exit(0); - } -} - -validate(); diff --git a/scripts/command-contracts.mjs b/scripts/command-contracts.mjs deleted file mode 100644 index e2d55f4..0000000 --- a/scripts/command-contracts.mjs +++ /dev/null @@ -1,359 +0,0 @@ -#!/usr/bin/env node - -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const projectRoot = path.resolve(__dirname, '..'); -const srcRoot = path.join(projectRoot, 'src'); -const backendPath = path.join(projectRoot, 'src', 'lib', 'backend.ts'); -const ipcPath = path.join(projectRoot, 'src-tauri', 'src', 'lib.rs'); -const httpPaths = [ - path.join(projectRoot, 'src-tauri', 'src', 'http_server.rs'), - path.join(projectRoot, 'src-tauri', 'src', 'http_server', 'routing.rs'), -]; -const docsPath = path.join(projectRoot, 'docs', 'generated', 'command-contracts.md'); - -const mode = process.argv[2] || 'check'; - -function walk(dir, files = []) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - walk(fullPath, files); - continue; - } - if (/\.(ts|tsx)$/.test(entry.name)) { - files.push(fullPath); - } - } - return files; -} - -function toSortedSet(values) { - return [...new Set(values)].sort(); -} - -function lineNumber(content, index) { - return content.slice(0, index).split('\n').length; -} - -function addUsage(map, key, usage) { - if (!map.has(key)) { - map.set(key, []); - } - map.get(key).push(usage); -} - -function uniq(values) { - return [...new Set(values)]; -} - -function diff(source, target) { - const targetSet = new Set(target); - return source.filter((item) => !targetSet.has(item)); -} - -function formatFrontendUsage(usage) { - return `${usage.file}:${usage.line}`; -} - -function formatBackendEndpointUsage(usage) { - return `${usage.functionName}() @ ${usage.file}:${usage.line}`; -} - -function collectExportedFunctions(content) { - const functions = []; - const functionRegex = /export\s+(?:async\s+)?function\s+([A-Za-z0-9_]+)\s*\(/g; - - for (const match of content.matchAll(functionRegex)) { - functions.push({ - name: match[1], - index: match.index, - line: lineNumber(content, match.index), - }); - } - - return functions.sort((a, b) => a.index - b.index); -} - -function findNearestFunction(functions, index) { - let current = null; - for (const item of functions) { - if (item.index > index) break; - current = item; - } - return current; -} - -function extractFrontendCommands() { - const files = walk(srcRoot); - const commands = []; - const usages = new Map(); - const commandRegex = /callBackend(?:<[^>]+>)?\(\s*['"]([a-z0-9_]+)['"]/g; - - for (const filePath of files) { - const content = fs.readFileSync(filePath, 'utf8'); - for (const match of content.matchAll(commandRegex)) { - const command = match[1]; - commands.push(command); - addUsage(usages, command, { - file: path.relative(projectRoot, filePath), - line: lineNumber(content, match.index), - }); - } - } - - return { - commands: toSortedSet(commands), - usages, - }; -} - -function extractBackendHttpEndpoints() { - const content = fs.readFileSync(backendPath, 'utf8'); - const functions = collectExportedFunctions(content); - const endpointRegex = /fetch\(\s*`\$\{getApiBase\(\)\}\/([^`]+)`/g; - const usages = new Map(); - const endpoints = []; - - for (const match of content.matchAll(endpointRegex)) { - const endpoint = match[1]; - if (endpoint.includes('${')) { - continue; - } - - const owner = findNearestFunction(functions, match.index); - if (owner?.name === 'callBackend') { - continue; - } - - const nearby = content.slice(match.index, match.index + 500); - const methodMatch = nearby.match(/method:\s*['"]([A-Z]+)['"]/i); - const method = (methodMatch?.[1] || 'GET').toUpperCase(); - - endpoints.push(endpoint); - addUsage(usages, endpoint, { - file: path.relative(projectRoot, backendPath), - functionName: owner?.name || '(unknown)', - line: lineNumber(content, match.index), - method, - }); - } - - return { - endpoints: toSortedSet(endpoints), - usages, - }; -} - -function extractIpcCommands() { - const content = fs.readFileSync(ipcPath, 'utf8'); - const match = content.match(/generate_handler!\[(?[\s\S]*?)\]\)/); - if (!match?.groups?.body) { - throw new Error('Failed to locate tauri::generate_handler! block in src-tauri/src/lib.rs'); - } - - const cleaned = match.groups.body - .replace(/\/\/.*$/gm, '') - .replace(/\/\*[\s\S]*?\*\//g, ''); - - return { - commands: toSortedSet( - cleaned - .split(',') - .map((part) => part.trim()) - .filter((part) => /^[a-z_][a-z0-9_]*$/i.test(part)), - ), - }; -} - -function extractHttpRoutes() { - const routes = new Map(); - const endpoints = []; - const routeRegex = /\.route\(\s*"\/api\/([^"]+)"\s*,\s*(get|post)\(\s*([A-Za-z0-9_]+)\s*\)\s*,?\s*\)/g; - - for (const httpPath of httpPaths) { - if (!fs.existsSync(httpPath)) { - continue; - } - - const content = fs.readFileSync(httpPath, 'utf8'); - for (const match of content.matchAll(routeRegex)) { - const endpoint = match[1]; - endpoints.push(endpoint); - addUsage(routes, endpoint, { - file: path.relative(projectRoot, httpPath), - handler: match[3], - line: lineNumber(content, match.index), - method: match[2].toUpperCase(), - }); - } - } - - return { - endpoints: toSortedSet(endpoints), - routes, - }; -} - -function classifyHttpEndpoint(endpoint, backendHttpOnlySet) { - if (backendHttpOnlySet.has(endpoint)) { - return 'backend-http-only'; - } - if (endpoint.includes('/') || endpoint.includes('.')) { - return 'infra-http-only'; - } - return 'mirrored-command'; -} - -function firstRouteInfo(routeEntries) { - return routeEntries?.[0] || null; -} - -function writeDocs(frontend, backendHttpOnly, ipc, http) { - const backendHttpOnlySet = new Set(backendHttpOnly.endpoints); - const mirroredHttpEndpoints = toSortedSet( - http.endpoints.filter((endpoint) => classifyHttpEndpoint(endpoint, backendHttpOnlySet) === 'mirrored-command'), - ); - const mirroredCommands = toSortedSet([ - ...frontend.commands, - ...ipc.commands, - ...mirroredHttpEndpoints, - ]); - const auxiliaryHttpEndpoints = toSortedSet( - http.endpoints.filter((endpoint) => classifyHttpEndpoint(endpoint, backendHttpOnlySet) !== 'mirrored-command'), - ); - - const lines = [ - '# Command Contracts', - '', - `Generated on ${new Date().toISOString()}.`, - '', - 'This file is generated by `scripts/command-contracts.mjs`.', - 'Route scanning includes both `src-tauri/src/http_server.rs` and `src-tauri/src/http_server/routing.rs`.', - '', - '## Summary', - '', - `- Frontend \`callBackend()\` usages: ${frontend.commands.length}`, - `- backend.ts direct HTTP endpoints: ${backendHttpOnly.endpoints.length}`, - `- Tauri IPC commands: ${ipc.commands.length}`, - `- HTTP API routes: ${http.endpoints.length}`, - '', - '## Mirrored Command Matrix', - '', - '| Command | Frontend | IPC | HTTP | Method | Handler |', - '| --- | --- | --- | --- | --- | --- |', - ]; - - for (const command of mirroredCommands) { - const routeEntries = http.routes.get(command) || []; - const methods = uniq(routeEntries.map((item) => item.method)).join(', '); - const handlers = uniq(routeEntries.map((item) => `\`${item.handler}\``)).join(', '); - lines.push( - `| \`${command}\` | ${frontend.commands.includes(command) ? 'yes' : ''} | ${ipc.commands.includes(command) ? 'yes' : ''} | ${routeEntries.length ? 'yes' : ''} | ${methods} | ${handlers} |`, - ); - } - - lines.push('', '## HTTP-only Endpoints', '', '| Endpoint | Source | Method | Handler | Kind |', '| --- | --- | --- | --- | --- |'); - - if (auxiliaryHttpEndpoints.length === 0) { - lines.push('| _none_ | | | | |'); - } else { - for (const endpoint of auxiliaryHttpEndpoints) { - const routeInfo = firstRouteInfo(http.routes.get(endpoint)); - const backendUsage = backendHttpOnly.usages.get(endpoint) || []; - const source = backendUsage.length - ? backendUsage.map((item) => `\`${formatBackendEndpointUsage(item)}\``).join('
') - : routeInfo - ? `\`${routeInfo.file}:${routeInfo.line}\`` - : ''; - const kind = classifyHttpEndpoint(endpoint, backendHttpOnlySet) === 'backend-http-only' - ? 'backend.ts direct fetch' - : 'HTTP infrastructure'; - lines.push( - `| \`${endpoint}\` | ${source} | ${routeInfo?.method || ''} | ${routeInfo ? `\`${routeInfo.handler}\`` : ''} | ${kind} |`, - ); - } - } - - const sections = [ - ['Frontend missing IPC', diff(frontend.commands, ipc.commands), frontend.usages, formatFrontendUsage], - ['Frontend missing HTTP', diff(frontend.commands, http.endpoints), frontend.usages, formatFrontendUsage], - ['backend.ts HTTP-only endpoints missing HTTP route', diff(backendHttpOnly.endpoints, http.endpoints), backendHttpOnly.usages, formatBackendEndpointUsage], - ['IPC missing HTTP route', diff(ipc.commands, http.endpoints), null, null], - ['HTTP mirrored routes missing IPC command', diff(mirroredHttpEndpoints, ipc.commands), http.routes, (usage) => `${usage.file}:${usage.line}`], - ]; - - for (const [title, items, usageMap, formatter] of sections) { - lines.push('', `## ${title}`, ''); - if (items.length === 0) { - lines.push('- None'); - continue; - } - - for (const item of items) { - if (!usageMap || !formatter) { - lines.push(`- \`${item}\``); - continue; - } - - const usages = usageMap.get(item) || []; - const rendered = usages.map((usage) => formatter(usage)).join(', '); - lines.push(rendered ? `- \`${item}\` - ${rendered}` : `- \`${item}\``); - } - } - - fs.mkdirSync(path.dirname(docsPath), { recursive: true }); - fs.writeFileSync(docsPath, `${lines.join('\n')}\n`); -} - -function reportDiffs(frontend, backendHttpOnly, ipc, http) { - const backendHttpOnlySet = new Set(backendHttpOnly.endpoints); - const mirroredHttpEndpoints = http.endpoints.filter( - (endpoint) => classifyHttpEndpoint(endpoint, backendHttpOnlySet) === 'mirrored-command', - ); - const mismatches = [ - ['Frontend -> IPC', diff(frontend.commands, ipc.commands)], - ['Frontend -> HTTP', diff(frontend.commands, http.endpoints)], - ['backend.ts HTTP-only -> HTTP', diff(backendHttpOnly.endpoints, http.endpoints)], - ['IPC -> HTTP', diff(ipc.commands, http.endpoints)], - ['HTTP mirrored -> IPC', diff(mirroredHttpEndpoints, ipc.commands)], - ]; - - let failed = false; - for (const [label, items] of mismatches) { - if (items.length === 0) { - continue; - } - failed = true; - console.error(`${label} mismatch (${items.length}): ${items.join(', ')}`); - } - return failed; -} - -const frontend = extractFrontendCommands(); -const backendHttpOnly = extractBackendHttpEndpoints(); -const ipc = extractIpcCommands(); -const http = extractHttpRoutes(); - -if (mode === 'generate') { - writeDocs(frontend, backendHttpOnly, ipc, http); - console.log(`Generated ${path.relative(projectRoot, docsPath)}`); - process.exit(0); -} - -if (mode === 'check') { - const failed = reportDiffs(frontend, backendHttpOnly, ipc, http); - if (failed) { - console.error(''); - console.error('Run `npm run docs:contracts` after fixing mismatches to refresh the command matrix.'); - process.exit(1); - } - console.log('Command contracts are in sync.'); - process.exit(0); -} - -console.error(`Unknown mode: ${mode}`); -process.exit(1); diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore deleted file mode 100644 index 6de57f3..0000000 --- a/src-tauri/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Generated by Cargo -# will have compiled files and executables -/target/ -/target - -# Generated by Tauri -# will have schema files for capabilities auto-completion -/gen/schemas diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock deleted file mode 100644 index 094b594..0000000 --- a/src-tauri/Cargo.lock +++ /dev/null @@ -1,7794 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "version_check", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" -dependencies = [ - "alloc-no-stdlib", -] - -[[package]] -name = "android_log-sys" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" - -[[package]] -name = "android_logger" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" -dependencies = [ - "android_log-sys", - "env_filter", - "log", -] - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - -[[package]] -name = "arc-swap" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" -dependencies = [ - "rustversion", -] - -[[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] - -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "async-signal" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "atk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" -dependencies = [ - "atk-sys", - "glib", - "libc", -] - -[[package]] -name = "atk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "av-scenechange" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror 2.0.18", - "v_frame", - "y4m", -] - -[[package]] -name = "av1-grain" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" -dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom", - "num-rational", - "v_frame", -] - -[[package]] -name = "avif-serialize" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "awaitdrop" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "771051cdc7eec2dc1b23fbf870bb7fbb89136fe374227c875e377f1eed99a429" -dependencies = [ - "futures", - "generational-arena", - "parking_lot", - "slotmap", -] - -[[package]] -name = "aws-lc-rs" -version = "1.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.37.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - -[[package]] -name = "axum" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" -dependencies = [ - "axum-core 0.5.6", - "base64 0.22.1", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sha1", - "sync_wrapper", - "tokio", - "tokio-tungstenite 0.28.0", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" -dependencies = [ - "serde_core", -] - -[[package]] -name = "bitstream-io" -version = "4.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" -dependencies = [ - "no_std_io2", -] - -[[package]] -name = "bitvec" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - -[[package]] -name = "borsh" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" -dependencies = [ - "borsh-derive", - "cfg_aliases 0.2.1", -] - -[[package]] -name = "borsh-derive" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" -dependencies = [ - "once_cell", - "proc-macro-crate 3.4.0", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "brotli" -version = "8.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] - -[[package]] -name = "brotli-decompressor" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - -[[package]] -name = "built" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "byte-unit" -version = "5.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d" -dependencies = [ - "rust_decimal", - "schemars 1.2.1", - "serde", - "utf8-width", -] - -[[package]] -name = "bytecheck" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" -dependencies = [ - "bytecheck_derive", - "ptr_meta", - "simdutf8", -] - -[[package]] -name = "bytecheck_derive" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -dependencies = [ - "serde", -] - -[[package]] -name = "cairo-rs" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" -dependencies = [ - "bitflags 2.10.0", - "cairo-sys-rs", - "glib", - "libc", - "once_cell", - "thiserror 1.0.69", -] - -[[package]] -name = "cairo-sys-rs" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "camino" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" -dependencies = [ - "serde_core", -] - -[[package]] -name = "cargo-platform" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo_metadata" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror 2.0.18", -] - -[[package]] -name = "cargo_toml" -version = "0.22.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" -dependencies = [ - "serde", - "toml 0.9.11+spec-1.1.0", -] - -[[package]] -name = "cc" -version = "1.2.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cfb" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" -dependencies = [ - "byteorder", - "fnv", - "uuid", -] - -[[package]] -name = "cfg-expr" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" -dependencies = [ - "smallvec", - "target-lexicon", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chrono" -version = "0.4.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link 0.2.1", -] - -[[package]] -name = "cmake" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" -dependencies = [ - "cc", -] - -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "time", - "version_check", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core-graphics" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" -dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.10.1", - "core-graphics-types", - "foreign-types", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" -dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.10.1", - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "cssparser" -version = "0.29.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "matches", - "phf 0.10.1", - "proc-macro2", - "quote", - "smallvec", - "syn 1.0.109", -] - -[[package]] -name = "cssparser-macros" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" -dependencies = [ - "quote", - "syn 2.0.114", -] - -[[package]] -name = "ctor" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" -dependencies = [ - "quote", - "syn 2.0.114", -] - -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.114", -] - -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.114", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core 0.21.3", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "data-encoding" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - -[[package]] -name = "deranged" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" -dependencies = [ - "powerfmt", - "serde_core", -] - -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn 2.0.114", -] - -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.114", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - -[[package]] -name = "dispatch2" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" -dependencies = [ - "bitflags 2.10.0", - "block2", - "libc", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "dlopen2" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" -dependencies = [ - "dlopen2_derive", - "libc", - "once_cell", - "winapi", -] - -[[package]] -name = "dlopen2_derive" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "doc-comment" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" - -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - -[[package]] -name = "dpi" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" -dependencies = [ - "serde", -] - -[[package]] -name = "dtoa" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" - -[[package]] -name = "dtoa-short" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" -dependencies = [ - "dtoa", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "embed-resource" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" -dependencies = [ - "cc", - "memchr", - "rustc_version", - "toml 0.9.11+spec-1.1.0", - "vswhom", - "winreg 0.55.0", -] - -[[package]] -name = "embed_plist" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" - -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "env_filter" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "equator" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "erased-serde" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - -[[package]] -name = "exr" -version = "1.74.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "fax" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "fern" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" -dependencies = [ - "log", -] - -[[package]] -name = "field-offset" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" -dependencies = [ - "memoffset", - "rustc_version", -] - -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "futures-rustls" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" -dependencies = [ - "futures-io", - "rustls", - "rustls-pki-types", -] - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - -[[package]] -name = "gdk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" -dependencies = [ - "cairo-rs", - "gdk-pixbuf", - "gdk-sys", - "gio", - "glib", - "libc", - "pango", -] - -[[package]] -name = "gdk-pixbuf" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" -dependencies = [ - "gdk-pixbuf-sys", - "gio", - "glib", - "libc", - "once_cell", -] - -[[package]] -name = "gdk-pixbuf-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gdk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" -dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "pkg-config", - "system-deps", -] - -[[package]] -name = "gdkwayland-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" -dependencies = [ - "gdk-sys", - "glib-sys", - "gobject-sys", - "libc", - "pkg-config", - "system-deps", -] - -[[package]] -name = "gdkx11" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" -dependencies = [ - "gdk", - "gdkx11-sys", - "gio", - "glib", - "libc", - "x11", -] - -[[package]] -name = "gdkx11-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" -dependencies = [ - "gdk-sys", - "glib-sys", - "libc", - "system-deps", - "x11", -] - -[[package]] -name = "generational-arena" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877e94aff08e743b651baaea359664321055749b398adff8740a7399af7796e7" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix", - "windows-link 0.2.1", -] - -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "getset" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" -dependencies = [ - "proc-macro-error2", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "gif" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" -dependencies = [ - "color_quant", - "weezl", -] - -[[package]] -name = "gio" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "gio-sys", - "glib", - "libc", - "once_cell", - "pin-project-lite", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "gio-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", - "winapi", -] - -[[package]] -name = "git2" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" -dependencies = [ - "bitflags 2.10.0", - "libc", - "libgit2-sys", - "log", - "openssl-probe 0.1.6", - "openssl-sys", - "url", -] - -[[package]] -name = "glib" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" -dependencies = [ - "bitflags 2.10.0", - "futures-channel", - "futures-core", - "futures-executor", - "futures-task", - "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", - "libc", - "memchr", - "once_cell", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "glib-macros" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" -dependencies = [ - "heck 0.4.1", - "proc-macro-crate 2.0.2", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "glib-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" -dependencies = [ - "libc", - "system-deps", -] - -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "gobject-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gtk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" -dependencies = [ - "atk", - "cairo-rs", - "field-offset", - "futures-channel", - "gdk", - "gdk-pixbuf", - "gio", - "glib", - "gtk-sys", - "gtk3-macros", - "libc", - "pango", - "pkg-config", -] - -[[package]] -name = "gtk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" -dependencies = [ - "atk-sys", - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "system-deps", -] - -[[package]] -name = "gtk3-macros" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" -dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "headers" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" -dependencies = [ - "base64 0.22.1", - "bytes", - "headers-core", - "http", - "httpdate", - "mime", - "sha1", -] - -[[package]] -name = "headers-core" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" -dependencies = [ - "http", -] - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hostname" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" -dependencies = [ - "libc", - "match_cfg", - "winapi", -] - -[[package]] -name = "html5ever" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" -dependencies = [ - "log", - "mac", - "markup5ever", - "match_token", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "http-range-header" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-http-proxy" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ad4b0a1e37510028bc4ba81d0e38d239c39671b0f0ce9e02dfa93a8133f7c08" -dependencies = [ - "bytes", - "futures-util", - "headers", - "http", - "hyper", - "hyper-rustls", - "hyper-util", - "pin-project-lite", - "rustls-native-certs 0.7.3", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-native-certs 0.8.3", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots 1.0.6", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core 0.62.2", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "ico" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" -dependencies = [ - "byteorder", - "png 0.17.16", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "color_quant", - "exr", - "gif", - "image-webp", - "moxcms", - "num-traits", - "png 0.18.1", - "qoi", - "ravif", - "rayon", - "rgb", - "tiff", - "zune-core", - "zune-jpeg", -] - -[[package]] -name = "image-webp" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" -dependencies = [ - "byteorder-lite", - "quick-error", -] - -[[package]] -name = "imgref" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "infer" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" -dependencies = [ - "cfb", -] - -[[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "is-docker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", -] - -[[package]] -name = "is-wsl" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" -dependencies = [ - "is-docker", - "once_cell", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "javascriptcore-rs" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" -dependencies = [ - "bitflags 1.3.2", - "glib", - "javascriptcore-rs-sys", -] - -[[package]] -name = "javascriptcore-rs-sys" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni-sys" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.99" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "json-patch" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" -dependencies = [ - "jsonptr", - "serde", - "serde_json", - "thiserror 1.0.69", -] - -[[package]] -name = "jsonptr" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "keyboard-types" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" -dependencies = [ - "bitflags 2.10.0", - "serde", - "unicode-segmentation", -] - -[[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" -dependencies = [ - "cssparser", - "html5ever", - "indexmap 2.13.0", - "selectors", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "lebe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - -[[package]] -name = "libappindicator" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" -dependencies = [ - "glib", - "gtk", - "gtk-sys", - "libappindicator-sys", - "log", -] - -[[package]] -name = "libappindicator-sys" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" -dependencies = [ - "gtk-sys", - "libloading", - "once_cell", -] - -[[package]] -name = "libc" -version = "0.2.180" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" - -[[package]] -name = "libfuzzer-sys" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" -dependencies = [ - "arbitrary", - "cc", -] - -[[package]] -name = "libgit2-sys" -version = "0.18.3+1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" -dependencies = [ - "cc", - "libc", - "libssh2-sys", - "libz-sys", - "openssl-sys", - "pkg-config", -] - -[[package]] -name = "libloading" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" -dependencies = [ - "cfg-if", - "winapi", -] - -[[package]] -name = "libredox" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" -dependencies = [ - "bitflags 2.10.0", - "libc", - "plain", - "redox_syscall 0.8.1", -] - -[[package]] -name = "libssh2-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" -dependencies = [ - "cc", - "libc", - "libz-sys", - "openssl-sys", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "libz-sys" -version = "1.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "local-ip-address" -version = "0.6.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79ef8c257c92ade496781a32a581d43e3d512cf8ce714ecf04ea80f93ed0ff4a" -dependencies = [ - "libc", - "neli", - "windows-sys 0.61.2", -] - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -dependencies = [ - "value-bag", -] - -[[package]] -name = "loop9" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - -[[package]] -name = "markup5ever" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" -dependencies = [ - "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache", - "string_cache_codegen", - "tendril", -] - -[[package]] -name = "match_cfg" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" - -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "maybe-rayon" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" -dependencies = [ - "cfg-if", - "rayon", -] - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - -[[package]] -name = "minisign-verify" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e856fdd13623a2f5f2f54676a4ee49502a96a80ef4a62bcedd23d52427c44d43" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" -dependencies = [ - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.61.2", -] - -[[package]] -name = "moxcms" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" -dependencies = [ - "num-traits", - "pxfm", -] - -[[package]] -name = "muda" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" -dependencies = [ - "crossbeam-channel", - "dpi", - "gtk", - "keyboard-types", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "once_cell", - "png 0.17.16", - "serde", - "thiserror 2.0.18", - "windows-sys 0.60.2", -] - -[[package]] -name = "muxado" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a2d52e2794a4ecef95b6e9b9930ae5e0a73a8518dc2a388f5fe066af824a2f9" -dependencies = [ - "async-trait", - "awaitdrop", - "bitflags 1.3.2", - "bytes", - "futures", - "pin-project", - "rand 0.8.5", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "ndk" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" -dependencies = [ - "bitflags 2.10.0", - "jni-sys", - "log", - "ndk-sys", - "num_enum", - "raw-window-handle", - "thiserror 1.0.69", -] - -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "ndk-sys" -version = "0.6.0+11769913" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" -dependencies = [ - "jni-sys", -] - -[[package]] -name = "neli" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" -dependencies = [ - "bitflags 2.10.0", - "byteorder", - "derive_builder", - "getset", - "libc", - "log", - "neli-proc-macros", - "parking_lot", -] - -[[package]] -name = "neli-proc-macros" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609" -dependencies = [ - "either", - "proc-macro2", - "quote", - "serde", - "syn 2.0.114", -] - -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "ngrok" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41ad94d0fd5a5e1f5bb6a99f06b8babc62594799785451f4aeb048c9b2848ef8" -dependencies = [ - "arc-swap", - "async-trait", - "awaitdrop", - "axum-core 0.4.5", - "base64 0.21.7", - "bitflags 2.10.0", - "bytes", - "futures", - "futures-rustls", - "futures-util", - "hostname", - "hyper-http-proxy", - "hyper-util", - "muxado", - "once_cell", - "parking_lot", - "pin-project", - "proxy-protocol", - "regex", - "rustls-native-certs 0.7.3", - "rustls-pemfile", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tokio-retry", - "tokio-socks", - "tokio-util", - "tower-service", - "tracing", - "url", - "windows-sys 0.45.0", -] - -[[package]] -name = "nix" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "cfg_aliases 0.1.1", - "libc", -] - -[[package]] -name = "no_std_io2" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550" -dependencies = [ - "memchr", -] - -[[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" - -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_enum" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" -dependencies = [ - "num_enum_derive", - "rustversion", -] - -[[package]] -name = "num_enum_derive" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" -dependencies = [ - "proc-macro-crate 3.4.0", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - -[[package]] -name = "objc2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" -dependencies = [ - "objc2-encode", - "objc2-exception-helper", -] - -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags 2.10.0", - "block2", - "libc", - "objc2", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-text", - "objc2-core-video", - "objc2-foundation", - "objc2-quartz-core", -] - -[[package]] -name = "objc2-cloud-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-data" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.10.0", - "dispatch2", - "objc2", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags 2.10.0", - "dispatch2", - "objc2", - "objc2-core-foundation", - "objc2-io-surface", -] - -[[package]] -name = "objc2-core-image" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-text" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", -] - -[[package]] -name = "objc2-core-video" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-io-surface", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-exception-helper" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" -dependencies = [ - "cc", -] - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.10.0", - "block2", - "libc", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-javascript-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" -dependencies = [ - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-osa-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-app-kit", - "objc2-foundation", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-security" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-web-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" -dependencies = [ - "bitflags 2.10.0", - "block2", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "objc2-javascript-core", - "objc2-security", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "open" -version = "5.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" -dependencies = [ - "dunce", - "is-wsl", - "libc", - "pathdiff", -] - -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-src" -version = "300.6.0+3.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" -dependencies = [ - "cc", -] - -[[package]] -name = "openssl-sys" -version = "0.9.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" -dependencies = [ - "cc", - "libc", - "openssl-src", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "osakit" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" -dependencies = [ - "objc2", - "objc2-foundation", - "objc2-osa-kit", - "serde", - "serde_json", - "thiserror 2.0.18", -] - -[[package]] -name = "pango" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" -dependencies = [ - "gio", - "glib", - "libc", - "once_cell", - "pango-sys", -] - -[[package]] -name = "pango-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link 0.2.1", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64 0.22.1", - "serde_core", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_codegen" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_generator" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" -dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.5", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.5", -] - -[[package]] -name = "phf_macros" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "phf_shared" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher 1.0.2", -] - -[[package]] -name = "pin-project" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "piper" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - -[[package]] -name = "plist" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" -dependencies = [ - "base64 0.22.1", - "indexmap 2.13.0", - "quick-xml", - "serde", - "time", -] - -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "png" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags 2.10.0", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "portable-pty" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" -dependencies = [ - "anyhow", - "bitflags 1.3.2", - "downcast-rs", - "filedescriptor", - "lazy_static", - "libc", - "log", - "nix", - "serial2", - "shared_library", - "shell-words", - "winapi", - "winreg 0.10.1", -] - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - -[[package]] -name = "proc-macro-crate" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] - -[[package]] -name = "proc-macro-crate" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" -dependencies = [ - "toml_datetime 0.6.3", - "toml_edit 0.20.2", -] - -[[package]] -name = "proc-macro-crate" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" -dependencies = [ - "toml_edit 0.23.10+spec-1.0.0", -] - -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "profiling" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" -dependencies = [ - "profiling-procmacros", -] - -[[package]] -name = "profiling-procmacros" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" -dependencies = [ - "quote", - "syn 2.0.114", -] - -[[package]] -name = "proxy-protocol" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e50c72c21c738f5c5f350cc33640aee30bf7cd20f9d9da20ed41bce2671d532" -dependencies = [ - "bytes", - "snafu", -] - -[[package]] -name = "ptr_meta" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" -dependencies = [ - "ptr_meta_derive", -] - -[[package]] -name = "ptr_meta_derive" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "pxfm" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" - -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "quick-error" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" - -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases 0.2.1", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.2", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases 0.2.1", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.52.0", -] - -[[package]] -name = "quote" -version = "1.0.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_pcg" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand 0.9.2", - "rand_chacha 0.9.0", - "simd_helpers", - "thiserror 2.0.18", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", -] - -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "rcgen" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" -dependencies = [ - "pem", - "ring", - "rustls-pki-types", - "time", - "yasna", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.10.0", -] - -[[package]] -name = "redox_syscall" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" -dependencies = [ - "bitflags 2.10.0", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", -] - -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" - -[[package]] -name = "rend" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" -dependencies = [ - "bytecheck", -] - -[[package]] -name = "reqwest" -version = "0.12.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e98ff6b0dbbe4d5a37318f433d4fc82babd21631f194d370409ceb2e40b2f0b5" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "ipnet", - "js-sys", - "log", - "mime", - "once_cell", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams 0.4.2", - "web-sys", - "webpki-roots 1.0.6", -] - -[[package]] -name = "reqwest" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams 0.5.0", - "web-sys", -] - -[[package]] -name = "rfd" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" -dependencies = [ - "block2", - "dispatch2", - "glib-sys", - "gobject-sys", - "gtk-sys", - "js-sys", - "log", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "raw-window-handle", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows-sys 0.60.2", -] - -[[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rkyv" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" -dependencies = [ - "bitvec", - "bytecheck", - "bytes", - "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", - "seahash", - "tinyvec", - "uuid", -] - -[[package]] -name = "rkyv_derive" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "rust_decimal" -version = "1.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" -dependencies = [ - "arrayvec", - "borsh", - "bytes", - "num-traits", - "rand 0.8.5", - "rkyv", - "serde", - "serde_json", -] - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" -dependencies = [ - "bitflags 2.10.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" -dependencies = [ - "aws-lc-rs", - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" -dependencies = [ - "openssl-probe 0.1.6", - "rustls-pemfile", - "rustls-pki-types", - "schannel", - "security-framework 2.11.1", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe 0.2.1", - "rustls-pki-types", - "schannel", - "security-framework 3.5.1", -] - -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs 0.8.3", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework 3.5.1", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "scc" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" -dependencies = [ - "sdd", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "schemars" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" -dependencies = [ - "dyn-clone", - "indexmap 1.9.3", - "schemars_derive", - "serde", - "serde_json", - "url", - "uuid", -] - -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars_derive" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.114", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "sdd" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" - -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework" -version = "3.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" -dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "selectors" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" -dependencies = [ - "bitflags 1.3.2", - "cssparser", - "derive_more", - "fxhash", - "log", - "phf 0.8.0", - "phf_codegen 0.8.0", - "precomputed-hash", - "servo_arc", - "smallvec", -] - -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde-untagged" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" -dependencies = [ - "erased-serde", - "serde", - "serde_core", - "typeid", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "serde_derive_internals" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_spanned" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_with" -version = "3.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" -dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.13.0", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" -dependencies = [ - "darling 0.21.3", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "serial2" -version = "0.2.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cc76fa68e25e771492ca1e3c53d447ef0be3093e05cd3b47f4b712ba10c6f3c" -dependencies = [ - "cfg-if", - "libc", - "winapi", -] - -[[package]] -name = "serial_test" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" -dependencies = [ - "futures", - "log", - "once_cell", - "parking_lot", - "scc", - "serial_test_derive", -] - -[[package]] -name = "serial_test_derive" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "serialize-to-javascript" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" -dependencies = [ - "serde", - "serde_json", - "serialize-to-javascript-impl", -] - -[[package]] -name = "serialize-to-javascript-impl" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "servo_arc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" -dependencies = [ - "nodrop", - "stable_deref_trait", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shared_library" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" -dependencies = [ - "lazy_static", - "libc", -] - -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - -[[package]] -name = "simd_helpers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "siphasher" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "slotmap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" -dependencies = [ - "version_check", -] - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "snafu" -version = "0.6.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eab12d3c261b2308b0d80c26fffb58d17eba81a4be97890101f416b478c79ca7" -dependencies = [ - "doc-comment", - "snafu-derive", -] - -[[package]] -name = "snafu-derive" -version = "0.6.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1508efa03c362e23817f96cde18abed596a25219a8b2c66e8db33c03543d315b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "socket2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "softbuffer" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" -dependencies = [ - "bytemuck", - "js-sys", - "ndk", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "objc2-quartz-core", - "raw-window-handle", - "redox_syscall 0.5.18", - "tracing", - "wasm-bindgen", - "web-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "soup3" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" -dependencies = [ - "futures-channel", - "gio", - "glib", - "libc", - "soup3-sys", -] - -[[package]] -name = "soup3-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "swift-rs" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" -dependencies = [ - "base64 0.21.7", - "serde", - "serde_json", -] - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "system-deps" -version = "6.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" -dependencies = [ - "cfg-expr", - "heck 0.5.0", - "pkg-config", - "toml 0.8.2", - "version-compare", -] - -[[package]] -name = "tao" -version = "0.34.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" -dependencies = [ - "bitflags 2.10.0", - "block2", - "core-foundation 0.10.1", - "core-graphics", - "crossbeam-channel", - "dispatch", - "dlopen2", - "dpi", - "gdkwayland-sys", - "gdkx11-sys", - "gtk", - "jni", - "lazy_static", - "libc", - "log", - "ndk", - "ndk-context", - "ndk-sys", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "once_cell", - "parking_lot", - "raw-window-handle", - "scopeguard", - "tao-macros", - "unicode-segmentation", - "url", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-version", - "x11-dl", -] - -[[package]] -name = "tao-macros" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - -[[package]] -name = "tar" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" -dependencies = [ - "filetime", - "libc", - "xattr", -] - -[[package]] -name = "target-lexicon" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" - -[[package]] -name = "tauri" -version = "2.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "463ae8677aa6d0f063a900b9c41ecd4ac2b7ca82f0b058cc4491540e55b20129" -dependencies = [ - "anyhow", - "bytes", - "cookie", - "dirs", - "dunce", - "embed_plist", - "getrandom 0.3.4", - "glob", - "gtk", - "heck 0.5.0", - "http", - "jni", - "libc", - "log", - "mime", - "muda", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "objc2-ui-kit", - "objc2-web-kit", - "percent-encoding", - "plist", - "raw-window-handle", - "reqwest 0.13.2", - "serde", - "serde_json", - "serde_repr", - "serialize-to-javascript", - "swift-rs", - "tauri-build", - "tauri-macros", - "tauri-runtime", - "tauri-runtime-wry", - "tauri-utils", - "thiserror 2.0.18", - "tokio", - "tray-icon", - "url", - "webkit2gtk", - "webview2-com", - "window-vibrancy", - "windows 0.61.3", -] - -[[package]] -name = "tauri-build" -version = "2.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca7bd893329425df750813e95bd2b643d5369d929438da96d5bbb7cc2c918f74" -dependencies = [ - "anyhow", - "cargo_toml", - "dirs", - "glob", - "heck 0.5.0", - "json-patch", - "schemars 0.8.22", - "semver", - "serde", - "serde_json", - "tauri-utils", - "tauri-winres", - "toml 0.9.11+spec-1.1.0", - "walkdir", -] - -[[package]] -name = "tauri-codegen" -version = "2.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac423e5859d9f9ccdd32e3cf6a5866a15bedbf25aa6630bcb2acde9468f6ae3" -dependencies = [ - "base64 0.22.1", - "brotli", - "ico", - "json-patch", - "plist", - "png 0.17.16", - "proc-macro2", - "quote", - "semver", - "serde", - "serde_json", - "sha2", - "syn 2.0.114", - "tauri-utils", - "thiserror 2.0.18", - "time", - "url", - "uuid", - "walkdir", -] - -[[package]] -name = "tauri-macros" -version = "2.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6a1bd2861ff0c8766b1d38b32a6a410f6dc6532d4ef534c47cfb2236092f59" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.114", - "tauri-codegen", - "tauri-utils", -] - -[[package]] -name = "tauri-plugin" -version = "2.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692a77abd8b8773e107a42ec0e05b767b8d2b7ece76ab36c6c3947e34df9f53f" -dependencies = [ - "anyhow", - "glob", - "plist", - "schemars 0.8.22", - "serde", - "serde_json", - "tauri-utils", - "toml 0.9.11+spec-1.1.0", - "walkdir", -] - -[[package]] -name = "tauri-plugin-dialog" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" -dependencies = [ - "log", - "raw-window-handle", - "rfd", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "tauri-plugin-fs", - "thiserror 2.0.18", - "url", -] - -[[package]] -name = "tauri-plugin-fs" -version = "2.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" -dependencies = [ - "anyhow", - "dunce", - "glob", - "percent-encoding", - "schemars 0.8.22", - "serde", - "serde_json", - "serde_repr", - "tauri", - "tauri-plugin", - "tauri-utils", - "thiserror 2.0.18", - "toml 0.9.11+spec-1.1.0", - "url", -] - -[[package]] -name = "tauri-plugin-log" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" -dependencies = [ - "android_logger", - "byte-unit", - "fern", - "log", - "objc2", - "objc2-foundation", - "serde", - "serde_json", - "serde_repr", - "swift-rs", - "tauri", - "tauri-plugin", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "tauri-plugin-opener" -version = "2.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" -dependencies = [ - "dunce", - "glob", - "objc2-app-kit", - "objc2-foundation", - "open", - "schemars 0.8.22", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "thiserror 2.0.18", - "url", - "windows 0.61.3", - "zbus", -] - -[[package]] -name = "tauri-plugin-process" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" -dependencies = [ - "tauri", - "tauri-plugin", -] - -[[package]] -name = "tauri-plugin-updater" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" -dependencies = [ - "base64 0.22.1", - "dirs", - "flate2", - "futures-util", - "http", - "infer", - "log", - "minisign-verify", - "osakit", - "percent-encoding", - "reqwest 0.13.2", - "rustls", - "semver", - "serde", - "serde_json", - "tar", - "tauri", - "tauri-plugin", - "tempfile", - "thiserror 2.0.18", - "time", - "tokio", - "url", - "windows-sys 0.60.2", - "zip", -] - -[[package]] -name = "tauri-runtime" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b885ffeac82b00f1f6fd292b6e5aabfa7435d537cef57d11e38a489956535651" -dependencies = [ - "cookie", - "dpi", - "gtk", - "http", - "jni", - "objc2", - "objc2-ui-kit", - "objc2-web-kit", - "raw-window-handle", - "serde", - "serde_json", - "tauri-utils", - "thiserror 2.0.18", - "url", - "webkit2gtk", - "webview2-com", - "windows 0.61.3", -] - -[[package]] -name = "tauri-runtime-wry" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5204682391625e867d16584fedc83fc292fb998814c9f7918605c789cd876314" -dependencies = [ - "gtk", - "http", - "jni", - "log", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "once_cell", - "percent-encoding", - "raw-window-handle", - "softbuffer", - "tao", - "tauri-runtime", - "tauri-utils", - "url", - "webkit2gtk", - "webview2-com", - "windows 0.61.3", - "wry", -] - -[[package]] -name = "tauri-utils" -version = "2.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcd169fccdff05eff2c1033210b9b94acd07a47e6fa9a3431cf09cfd4f01c87e" -dependencies = [ - "anyhow", - "brotli", - "cargo_metadata", - "ctor", - "dunce", - "glob", - "html5ever", - "http", - "infer", - "json-patch", - "kuchikiki", - "log", - "memchr", - "phf 0.11.3", - "proc-macro2", - "quote", - "regex", - "schemars 0.8.22", - "semver", - "serde", - "serde-untagged", - "serde_json", - "serde_with", - "swift-rs", - "thiserror 2.0.18", - "toml 0.9.11+spec-1.1.0", - "url", - "urlpattern", - "uuid", - "walkdir", -] - -[[package]] -name = "tauri-winres" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" -dependencies = [ - "dunce", - "embed-resource", - "toml 0.9.11+spec-1.1.0", -] - -[[package]] -name = "tempfile" -version = "3.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" -dependencies = [ - "fastrand", - "getrandom 0.3.4", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "tiff" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" -dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", - "zune-jpeg", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "libc", - "num-conv", - "num_threads", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.49.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "tokio-retry" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f57eb36ecbe0fc510036adff84824dd3c24bb781e21bfa67b69d556aa85214f" -dependencies = [ - "pin-project", - "rand 0.8.5", - "tokio", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-socks" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" -dependencies = [ - "either", - "futures-util", - "thiserror 1.0.69", - "tokio", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.26.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" -dependencies = [ - "futures-util", - "log", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tungstenite 0.26.2", - "webpki-roots 0.26.11", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.28.0", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-io", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "toml_edit 0.20.2", -] - -[[package]] -name = "toml" -version = "0.9.11+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" -dependencies = [ - "indexmap 2.13.0", - "serde_core", - "serde_spanned 1.0.4", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.14", -] - -[[package]] -name = "toml_datetime" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.19.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap 2.13.0", - "toml_datetime 0.6.3", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" -dependencies = [ - "indexmap 2.13.0", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.23.10+spec-1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" -dependencies = [ - "indexmap 2.13.0", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "winnow 0.7.14", -] - -[[package]] -name = "toml_parser" -version = "1.0.6+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" -dependencies = [ - "winnow 0.7.14", -] - -[[package]] -name = "toml_writer" -version = "1.0.6+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "bitflags 2.10.0", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "http-range-header", - "httpdate", - "iri-string", - "mime", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "tokio", - "tokio-util", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "tray-icon" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" -dependencies = [ - "crossbeam-channel", - "dirs", - "libappindicator", - "muda", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "once_cell", - "png 0.17.16", - "serde", - "thiserror 2.0.18", - "windows-sys 0.60.2", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "tungstenite" -version = "0.26.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.2", - "rustls", - "rustls-pki-types", - "sha1", - "thiserror 2.0.18", - "utf-8", -] - -[[package]] -name = "tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.2", - "sha1", - "thiserror 2.0.18", - "utf-8", -] - -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "uds_windows" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" -dependencies = [ - "memoffset", - "tempfile", - "winapi", -] - -[[package]] -name = "unic-char-property" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" -dependencies = [ - "unic-char-range", -] - -[[package]] -name = "unic-char-range" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" - -[[package]] -name = "unic-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" - -[[package]] -name = "unic-ucd-ident" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - -[[package]] -name = "unic-ucd-version" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" -dependencies = [ - "unic-common", -] - -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - -[[package]] -name = "unicode-ident" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" - -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", -] - -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - -[[package]] -name = "urlpattern" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" -dependencies = [ - "regex", - "serde", - "unic-ucd-ident", - "url", -] - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf8-width" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" -dependencies = [ - "getrandom 0.3.4", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "v_frame" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", -] - -[[package]] -name = "value-bag" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version-compare" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vswhom" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" -dependencies = [ - "libc", - "vswhom-sys", -] - -[[package]] -name = "vswhom-sys" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" -dependencies = [ - "cc", - "libc", -] - -[[package]] -name = "wait-timeout" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" -dependencies = [ - "libc", -] - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.122" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.72" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.122" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.122" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.114", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.122" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "wasm-streams" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "web-sys" -version = "0.3.99" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webkit2gtk" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" -dependencies = [ - "bitflags 1.3.2", - "cairo-rs", - "gdk", - "gdk-sys", - "gio", - "gio-sys", - "glib", - "glib-sys", - "gobject-sys", - "gtk", - "gtk-sys", - "javascriptcore-rs", - "libc", - "once_cell", - "soup3", - "webkit2gtk-sys", -] - -[[package]] -name = "webkit2gtk-sys" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" -dependencies = [ - "bitflags 1.3.2", - "cairo-sys-rs", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "gtk-sys", - "javascriptcore-rs-sys", - "libc", - "pkg-config", - "soup3-sys", - "system-deps", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.6", -] - -[[package]] -name = "webpki-roots" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "webview2-com" -version = "0.38.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" -dependencies = [ - "webview2-com-macros", - "webview2-com-sys", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-implement 0.60.2", - "windows-interface", -] - -[[package]] -name = "webview2-com-macros" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "webview2-com-sys" -version = "0.38.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" -dependencies = [ - "thiserror 2.0.18", - "windows 0.61.3", - "windows-core 0.61.2", -] - -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "window-vibrancy" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" -dependencies = [ - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "raw-window-handle", - "windows-sys 0.59.0", - "windows-version", -] - -[[package]] -name = "windows" -version = "0.60.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddf874e74c7a99773e62b1c671427abf01a425e77c3d3fb9fb1e4883ea934529" -dependencies = [ - "windows-collections 0.1.1", - "windows-core 0.60.1", - "windows-future 0.1.1", - "windows-link 0.1.3", - "windows-numerics 0.1.1", -] - -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections 0.2.0", - "windows-core 0.61.2", - "windows-future 0.2.1", - "windows-link 0.1.3", - "windows-numerics 0.2.0", -] - -[[package]] -name = "windows-collections" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5467f79cc1ba3f52ebb2ed41dbb459b8e7db636cc3429458d9a852e15bc24dec" -dependencies = [ - "windows-core 0.60.1", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.60.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca21a92a9cae9bf4ccae5cf8368dce0837100ddf6e6d57936749e85f152f6247" -dependencies = [ - "windows-implement 0.59.0", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.3.1", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-future" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a787db4595e7eb80239b74ce8babfb1363d8e343ab072f2ffe901400c03349f0" -dependencies = [ - "windows-core 0.60.1", - "windows-link 0.1.3", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading", -] - -[[package]] -name = "windows-icons" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90b2e71dcb74254f9fb600acfe804e343fd1770ea6df166a48c4b54c6380c6d7" -dependencies = [ - "base64 0.22.1", - "glob", - "image", - "windows 0.60.0", -] - -[[package]] -name = "windows-implement" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005dea54e2f6499f2cee279b8f703b3cf3b5734a2d8d21867c8f44003182eeed" -dependencies = [ - "windows-core 0.60.1", - "windows-link 0.1.3", -] - -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-version" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winnow" -version = "0.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "0.7.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" -dependencies = [ - "memchr", -] - -[[package]] -name = "winreg" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" -dependencies = [ - "winapi", -] - -[[package]] -name = "winreg" -version = "0.55.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" -dependencies = [ - "cfg-if", - "windows-sys 0.59.0", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "worktree-manager" -version = "0.1.2" -dependencies = [ - "axum", - "base64 0.22.1", - "chrono", - "dirs", - "futures-util", - "gethostname", - "git2", - "hex", - "hyper", - "hyper-util", - "libc", - "local-ip-address", - "log", - "ngrok", - "once_cell", - "openssl-sys", - "portable-pty", - "rcgen", - "reqwest 0.12.18", - "ring", - "rustls", - "rustls-pemfile", - "rustls-pki-types", - "serde", - "serde_json", - "serial_test", - "tauri", - "tauri-build", - "tauri-plugin-dialog", - "tauri-plugin-log", - "tauri-plugin-opener", - "tauri-plugin-process", - "tauri-plugin-updater", - "tempfile", - "time", - "tokio", - "tokio-rustls", - "tokio-tungstenite 0.26.2", - "tower", - "tower-http", - "url", - "urlencoding", - "uuid", - "wait-timeout", - "windows-icons", - "windows-sys 0.61.2", -] - -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "wry" -version = "0.54.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ed1a195b0375491dd15a7066a10251be217ce743cf4bbbbdcf5391d6473bee0" -dependencies = [ - "base64 0.22.1", - "block2", - "cookie", - "crossbeam-channel", - "dirs", - "dpi", - "dunce", - "gdkx11", - "gtk", - "html5ever", - "http", - "javascriptcore-rs", - "jni", - "kuchikiki", - "libc", - "ndk", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "objc2-ui-kit", - "objc2-web-kit", - "once_cell", - "percent-encoding", - "raw-window-handle", - "sha2", - "soup3", - "tao-macros", - "thiserror 2.0.18", - "url", - "webkit2gtk", - "webkit2gtk-sys", - "webview2-com", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-version", - "x11-dl", -] - -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - -[[package]] -name = "x11" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" -dependencies = [ - "libc", - "pkg-config", -] - -[[package]] -name = "x11-dl" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" -dependencies = [ - "libc", - "once_cell", - "pkg-config", -] - -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - -[[package]] -name = "yasna" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" -dependencies = [ - "time", -] - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", - "synstructure", -] - -[[package]] -name = "zbus" -version = "5.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow 0.7.14", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "5.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" -dependencies = [ - "proc-macro-crate 3.4.0", - "proc-macro2", - "quote", - "syn 2.0.114", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" -dependencies = [ - "serde", - "winnow 0.7.14", - "zvariant", -] - -[[package]] -name = "zerocopy" -version = "0.8.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "zip" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" -dependencies = [ - "arbitrary", - "crc32fast", - "indexmap 2.13.0", - "memchr", -] - -[[package]] -name = "zmij" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" - -[[package]] -name = "zune-core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" - -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "zune-jpeg" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" -dependencies = [ - "zune-core", -] - -[[package]] -name = "zvariant" -version = "5.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" -dependencies = [ - "endi", - "enumflags2", - "serde", - "winnow 0.7.14", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" -dependencies = [ - "proc-macro-crate 3.4.0", - "proc-macro2", - "quote", - "syn 2.0.114", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 2.0.114", - "winnow 0.7.14", -] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml deleted file mode 100644 index 3f1efdf..0000000 --- a/src-tauri/Cargo.toml +++ /dev/null @@ -1,73 +0,0 @@ -[package] -name = "worktree-manager" -version = "0.1.2" -description = "A Tauri App" -authors = ["you"] -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[features] -default = ["devtools"] -devtools = ["tauri/devtools"] - -[lib] -# The `_lib` suffix may seem redundant but it is necessary -# to make the lib name unique and wouldn't conflict with the bin name. -# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 -name = "worktree_manager_lib" -crate-type = ["staticlib", "cdylib", "rlib"] - -[build-dependencies] -tauri-build = { version = "=2.5.5", features = [] } - -[dependencies] -tauri = { version = "=2.10.2", features = [] } -tauri-plugin-opener = "=2.5.3" -tauri-plugin-dialog = "=2.6.0" -tauri-plugin-updater = "=2.10.0" -tauri-plugin-process = "=2.3.1" -serde = { version = "=1.0.228", features = ["derive"] } -serde_json = "=1.0.149" -git2 = "=0.20.4" -chrono = { version = "=0.4.43", features = ["serde"] } -once_cell = "=1.21.3" -portable-pty = "=0.9.0" -wait-timeout = "=0.2.1" -openssl-sys = { version = "=0.9.111", features = ["vendored"] } -log = "=0.4.29" -tauri-plugin-log = "=2.8.0" -urlencoding = "=2.1.3" -axum = { version = "=0.8.8", features = ["ws"] } -tokio = { version = "=1.49.0", features = ["full"] } -futures-util = "=0.3.31" -tower-http = { version = "=0.6.8", features = ["cors", "fs", "limit"] } -local-ip-address = "=0.6.10" -ngrok = "=0.18.0" -url = "=2.5.8" -rustls = { version = "=0.23.36", features = ["aws-lc-rs"] } -ring = "=0.17.14" -hex = "=0.4.3" -uuid = { version = "=1.20.0", features = ["v4"] } -tokio-tungstenite = { version = "=0.26.2", features = ["rustls-tls-webpki-roots"] } -base64 = "=0.22.1" -reqwest = { version = "=0.12.18", features = ["json", "rustls-tls", "stream"], default-features = false } -rcgen = "=0.13.2" -tokio-rustls = "=0.26.4" -rustls-pki-types = "=1.14.0" -hyper = { version = "=1.8.1", features = ["server", "http1"] } -hyper-util = { version = "=0.1.20", features = ["tokio"] } -rustls-pemfile = "=2.2.0" -tower = "=0.5.3" -time = "=0.3.47" -gethostname = "=1.1.0" -dirs = "6" -libc = "=0.2.180" - -[target.'cfg(target_os = "windows")'.dependencies] -windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_System_RestartManager"] } -windows-icons = "=0.3.0" - -[dev-dependencies] -tempfile = "=3.24.0" -serial_test = "=3.2.0" diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist deleted file mode 100644 index 969cf92..0000000 --- a/src-tauri/Info.plist +++ /dev/null @@ -1,10 +0,0 @@ - - - - - NSMicrophoneUsageDescription - Worktree Manager 需要使用麦克风进行语音输入,将语音转写为文字注入终端。 - NSSpeechRecognitionUsageDescription - Worktree Manager 使用系统语音识别将语音转写为文字,所有处理在本地完成。 - - diff --git a/src-tauri/build.rs b/src-tauri/build.rs deleted file mode 100644 index 6e2df9c..0000000 --- a/src-tauri/build.rs +++ /dev/null @@ -1,32 +0,0 @@ -// Windows app manifest: tauri-build 默认 manifest(Common-Controls 6.0) -// 基础上增加 requireAdministrator,使应用始终以管理员权限运行, -// 避免 git/worktree 文件操作出现权限不足。 -const WINDOWS_APP_MANIFEST: &str = r#" - - - - - - - - - - - - - -"#; - -fn main() { - tauri_build::try_build(tauri_build::Attributes::new().windows_attributes( - tauri_build::WindowsAttributes::new().app_manifest(WINDOWS_APP_MANIFEST), - )) - .expect("failed to run tauri-build"); -} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json deleted file mode 100644 index eb74a88..0000000 --- a/src-tauri/capabilities/default.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "default", - "description": "Default capabilities for the application", - "windows": ["main", "workspace-*"], - "permissions": [ - "core:default", - "core:window:allow-set-title", - "dialog:allow-open", - "dialog:allow-save", - "updater:default", - "process:allow-restart", - "log:default", - "opener:default" - ] -} diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png deleted file mode 100644 index 681a5ef..0000000 Binary files a/src-tauri/icons/128x128.png and /dev/null differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png deleted file mode 100644 index a2b485a..0000000 Binary files a/src-tauri/icons/128x128@2x.png and /dev/null differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png deleted file mode 100644 index 2a7aded..0000000 Binary files a/src-tauri/icons/32x32.png and /dev/null differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png deleted file mode 100644 index f8a4645..0000000 Binary files a/src-tauri/icons/64x64.png and /dev/null differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png deleted file mode 100644 index e6fe498..0000000 Binary files a/src-tauri/icons/Square107x107Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png deleted file mode 100644 index 92064ed..0000000 Binary files a/src-tauri/icons/Square142x142Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png deleted file mode 100644 index 2ad154b..0000000 Binary files a/src-tauri/icons/Square150x150Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png deleted file mode 100644 index 00257f5..0000000 Binary files a/src-tauri/icons/Square284x284Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png deleted file mode 100644 index 28860eb..0000000 Binary files a/src-tauri/icons/Square30x30Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png deleted file mode 100644 index 409526f..0000000 Binary files a/src-tauri/icons/Square310x310Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png deleted file mode 100644 index f1aa3b4..0000000 Binary files a/src-tauri/icons/Square44x44Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png deleted file mode 100644 index c042364..0000000 Binary files a/src-tauri/icons/Square71x71Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png deleted file mode 100644 index 1661422..0000000 Binary files a/src-tauri/icons/Square89x89Logo.png and /dev/null differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png deleted file mode 100644 index dc6b4b4..0000000 Binary files a/src-tauri/icons/StoreLogo.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 2ffbf24..0000000 --- a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index e1eddd8..0000000 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index 007bb02..0000000 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index a3a6b6d..0000000 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index cc45b78..0000000 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png deleted file mode 100644 index 83c7711..0000000 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index 46dd38b..0000000 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index dd04447..0000000 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index f124b43..0000000 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index b27471e..0000000 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index e033baa..0000000 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index fca7309..0000000 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index 0526b02..0000000 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 93b1723..0000000 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index bb0e4ec..0000000 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index ac3ad37..0000000 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/values/ic_launcher_background.xml b/src-tauri/icons/android/values/ic_launcher_background.xml deleted file mode 100644 index ea9c223..0000000 --- a/src-tauri/icons/android/values/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #fff - \ No newline at end of file diff --git a/src-tauri/icons/app-icon.svg b/src-tauri/icons/app-icon.svg deleted file mode 100644 index 8daa3b2..0000000 --- a/src-tauri/icons/app-icon.svg +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns deleted file mode 100644 index fc878e9..0000000 Binary files a/src-tauri/icons/icon.icns and /dev/null differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico deleted file mode 100644 index ef71f46..0000000 Binary files a/src-tauri/icons/icon.ico and /dev/null differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png deleted file mode 100644 index ec75c26..0000000 Binary files a/src-tauri/icons/icon.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png deleted file mode 100644 index 3f2eae3..0000000 Binary files a/src-tauri/icons/ios/AppIcon-20x20@1x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png deleted file mode 100644 index a77841d..0000000 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png deleted file mode 100644 index a77841d..0000000 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png deleted file mode 100644 index 5bcf59d..0000000 Binary files a/src-tauri/icons/ios/AppIcon-20x20@3x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png deleted file mode 100644 index a96e56a..0000000 Binary files a/src-tauri/icons/ios/AppIcon-29x29@1x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png deleted file mode 100644 index f5cd282..0000000 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png deleted file mode 100644 index f5cd282..0000000 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png deleted file mode 100644 index a114914..0000000 Binary files a/src-tauri/icons/ios/AppIcon-29x29@3x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png deleted file mode 100644 index a77841d..0000000 Binary files a/src-tauri/icons/ios/AppIcon-40x40@1x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png deleted file mode 100644 index 897d42d..0000000 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png deleted file mode 100644 index 897d42d..0000000 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png deleted file mode 100644 index 1cd38ec..0000000 Binary files a/src-tauri/icons/ios/AppIcon-40x40@3x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png deleted file mode 100644 index 0e6136a..0000000 Binary files a/src-tauri/icons/ios/AppIcon-512@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png deleted file mode 100644 index 1cd38ec..0000000 Binary files a/src-tauri/icons/ios/AppIcon-60x60@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png deleted file mode 100644 index acec3e9..0000000 Binary files a/src-tauri/icons/ios/AppIcon-60x60@3x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png deleted file mode 100644 index 0982cc2..0000000 Binary files a/src-tauri/icons/ios/AppIcon-76x76@1x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png deleted file mode 100644 index 9bec3f0..0000000 Binary files a/src-tauri/icons/ios/AppIcon-76x76@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png deleted file mode 100644 index 0724faf..0000000 Binary files a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and /dev/null differ diff --git a/src-tauri/shell-integration/bash-init.sh b/src-tauri/shell-integration/bash-init.sh deleted file mode 100644 index 350ebe9..0000000 --- a/src-tauri/shell-integration/bash-init.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash -# worktree-manager bash init wrapper -# Sources user's rc files first, then loads shell integration - -# Source system-wide bashrc if it exists -[ -f /etc/bash.bashrc ] && source /etc/bash.bashrc - -# Source user's bashrc if it exists -[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" - -# Source shell integration -_WM_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$_WM_SCRIPT_DIR/bash-integration.sh" diff --git a/src-tauri/shell-integration/bash-integration.sh b/src-tauri/shell-integration/bash-integration.sh deleted file mode 100644 index f03cd3c..0000000 --- a/src-tauri/shell-integration/bash-integration.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/bin/bash -# OSC 633 shell integration for bash - -# Guard: only load once -[ -n "$_WM_SHELL_INTEGRATION_LOADED" ] && return -_WM_SHELL_INTEGRATION_LOADED=1 - -# Escape control characters, backslashes, and semicolons as \xHH for OSC 633 values. -_wm_escape_value() { - local input="$1" output="" i ch val - for (( i=0; i<${#input}; i++ )); do - ch="${input:$i:1}" - case "$ch" in - \\) output+="\\\\" ;; - \;) output+="\\x3b" ;; - *) - printf -v val '%d' "'$ch" - if [ "$val" -lt 32 ] 2>/dev/null; then - printf -v val '\\x%02x' "$val" - output+="$val" - else - output+="$ch" - fi - ;; - esac - done - printf '%s' "$output" -} - -# Save original PROMPT_COMMAND -_wm_original_prompt_command="$PROMPT_COMMAND" - -# Guard variable: prevents DEBUG trap from firing during PROMPT_COMMAND -_wm_in_prompt_command=0 - -_wm_prompt_start() { - # Reset guard so next DEBUG trap fires for user commands - _wm_in_prompt_command=0 - printf '\e]633;A\a' -} - -_wm_prompt_end() { - printf '\e]633;B\a' -} - -_wm_pre_execution() { - # Skip if we're inside PROMPT_COMMAND (DEBUG trap fires for every simple command) - [ "$_wm_in_prompt_command" = "1" ] && return - printf '\e]633;C\a' -} - -_wm_command_finished() { - local exit_code=$? - # Set guard to prevent DEBUG trap from firing during prompt functions - _wm_in_prompt_command=1 - printf '\e]633;D;%s\a' "$exit_code" - # Report CWD with \xHH escaping for OSC 633;P - printf '\e]633;P;Cwd=%s\a' "$(_wm_escape_value "$PWD")" - # OSC 7: percent-encode special characters for a valid file URI - local encoded_pwd="" - local i ch - for (( i=0; i<${#PWD}; i++ )); do - ch="${PWD:$i:1}" - case "$ch" in - [a-zA-Z0-9/._~:@!-]) encoded_pwd+="$ch" ;; - *) encoded_pwd+="$(printf '%%%02X' "'$ch")" ;; - esac - done - printf '\e]7;file://%s%s\a' "$(hostname)" "$encoded_pwd" - return $exit_code -} - -# Install hooks -PROMPT_COMMAND="_wm_command_finished;${_wm_original_prompt_command:+$_wm_original_prompt_command;}_wm_prompt_start" - -# Wrap PS1 with prompt-end marker only (prompt-start A is emitted by PROMPT_COMMAND) -PS1="${PS1}\[\e]633;B\a\]" - -# Trap DEBUG for pre-execution (guarded by _wm_in_prompt_command) -trap '_wm_pre_execution' DEBUG diff --git a/src-tauri/shell-integration/pwsh-integration.ps1 b/src-tauri/shell-integration/pwsh-integration.ps1 deleted file mode 100644 index b3e019d..0000000 --- a/src-tauri/shell-integration/pwsh-integration.ps1 +++ /dev/null @@ -1,108 +0,0 @@ -# OSC 633 shell integration for PowerShell -# Supports Windows PowerShell 5.1 and PowerShell 7+ -# -# Sequences emitted: A (prompt start), B (prompt end), D (command finish), P;Cwd -# Sequences NOT emitted: E (command text), C (command started) -# -# OSC 633;E and OSC 633;C require wrapping PSReadLine's ReadLine or -# PSConsoleHostReadLine, which is fragile across PS 5.x / 7+ versions and -# breaks some user profiles. The trade-off is intentional: command-text -# tracking and the "command started" marker are unavailable in PowerShell -# terminals. Shell prompt boundaries and CWD tracking still work normally. - -# Only load inside worktree-manager terminals -if ($env:WORKTREE_MANAGER_SHELL_INTEGRATION -ne '1') { return } - -# Guard: only load once -if ($Global:__WMState) { return } - -# Protect against user profiles that set ErrorActionPreference = 'Stop' -$__wmOrigEAP = $ErrorActionPreference -$ErrorActionPreference = 'Continue' -try { - -# Load user profiles (all four paths, standard order) -$__wmProfiles = @( - $PROFILE.AllUsersAllHosts, - $PROFILE.AllUsersCurrentHost, - $PROFILE.CurrentUserAllHosts, - $PROFILE.CurrentUserCurrentHost -) -foreach ($__wmP in $__wmProfiles) { - if ($__wmP -and (Test-Path $__wmP)) { . $__wmP } -} - -# Initialize global state (after profile load to capture user's Prompt) -$Global:__WMState = @{ - OriginalPrompt = $function:Prompt - LastHistoryId = -1 -} - -# Escape control characters (\x00-\x1f), backslashes, and semicolons as \xHH. -# Compatible with PowerShell 5.1+ ([regex]::Replace with scriptblock). -function Global:__WM-Escape-Value([string]$value) { - [regex]::Replace($value, '[\x00-\x1f\\;]', { - param($match) - -Join ( - [System.Text.Encoding]::UTF8.GetBytes($match.Value) | - ForEach-Object { '\x{0:x2}' -f $_ } - ) - }) -} - -# Custom Prompt function: emits OSC 633 sequences around the original prompt. -function Global:Prompt { - # Exit code: prefer $LASTEXITCODE for native commands, fall back to $? boolean. - # Known limitation: $? in Prompt context reflects the Prompt function's own last - # expression, not the user's last command, so it may report 0 even when a native - # command returned a non-zero exit code. $LASTEXITCODE is authoritative for native - # executables; $? is used only as a fallback for PowerShell-native commands that - # don't set $LASTEXITCODE. OSC 633;D is used for command navigation, not for - # precise exit-code display, so this imprecision is acceptable. - $ExitCode = if ($global:?) { 0 } - elseif ($global:LASTEXITCODE) { $global:LASTEXITCODE } - else { 1 } - $LastHistoryEntry = Get-History -Count 1 - $Result = "" - - # D: previous command finished. Without PSConsoleHostReadLine wrapping, - # history growth is the safest signal that a command actually ran. - if ( - $Global:__WMState.LastHistoryId -ne -1 -and - $LastHistoryEntry -and - $LastHistoryEntry.Id -ne $Global:__WMState.LastHistoryId - ) { - $Result += "$([char]0x1b)]633;D;$ExitCode`a" - } - - # Update history tracking - if ($LastHistoryEntry) { - $Global:__WMState.LastHistoryId = $LastHistoryEntry.Id - } - - # A: prompt start - $Result += "$([char]0x1b)]633;A`a" - - # P;Cwd: report current directory (FileSystem provider only) - if ($pwd.Provider.Name -eq 'FileSystem') { - $Result += "$([char]0x1b)]633;P;Cwd=$(__WM-Escape-Value $pwd.ProviderPath)`a" - } - - # Execute original prompt - $OriginalPrompt = "" - try { - $OriginalPrompt = & $Global:__WMState.OriginalPrompt - } catch {} - $Result += $OriginalPrompt - - # B: prompt end (user input begins) - $Result += "$([char]0x1b)]633;B`a" - - return $Result -} - -} catch { - # Shell integration failed to load; shell still works normally -} finally { - $ErrorActionPreference = $__wmOrigEAP -} diff --git a/src-tauri/shell-integration/zsh-integration.sh b/src-tauri/shell-integration/zsh-integration.sh deleted file mode 100644 index e991800..0000000 --- a/src-tauri/shell-integration/zsh-integration.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/bin/zsh -# OSC 633 shell integration for zsh - -# Guard: only load once -[ -n "$_WM_SHELL_INTEGRATION_LOADED" ] && return -_WM_SHELL_INTEGRATION_LOADED=1 - -# Escape control characters, backslashes, and semicolons as \xHH for OSC 633 values. -_wm_escape_value() { - local input="$1" output="" i ch val - for (( i=0; i<${#input}; i++ )); do - ch="${input:$i:1}" - case "$ch" in - \\) output+="\\\\" ;; - \;) output+="\\x3b" ;; - *) - printf -v val '%d' "'$ch" - if [ "$val" -lt 32 ] 2>/dev/null; then - printf -v val '\\x%02x' "$val" - output+="$val" - else - output+="$ch" - fi - ;; - esac - done - printf '%s' "$output" -} - -_wm_preexec() { - printf '\e]633;C\a' - # Send command text with \xHH escaping - printf '\e]633;E;%s\a' "$(_wm_escape_value "$1")" -} - -_wm_precmd() { - local exit_code=$? - printf '\e]633;D;%s\a' "$exit_code" - # Report CWD with \xHH escaping for OSC 633;P - printf '\e]633;P;Cwd=%s\a' "$(_wm_escape_value "$PWD")" - # OSC 7: percent-encode special characters for a valid file URI - local encoded_pwd="" - local i ch - for (( i=0; i<${#PWD}; i++ )); do - ch="${PWD:$i:1}" - case "$ch" in - [a-zA-Z0-9/._~:@!-]) encoded_pwd+="$ch" ;; - *) encoded_pwd+="$(printf '%%%02X' "'$ch")" ;; - esac - done - printf '\e]7;file://%s%s\a' "$(hostname)" "$encoded_pwd" - # Emit prompt-start (A). PS1 wrapping only adds prompt-end (B). - printf '\e]633;A\a' -} - -# Install hooks via add-zsh-hook (safe, does not clobber user hooks) -autoload -Uz add-zsh-hook -add-zsh-hook precmd _wm_precmd -add-zsh-hook preexec _wm_preexec - -# Wrap PS1 with prompt-end marker only. -# precmd emits A (prompt-start), so PS1 only needs B (prompt-end) at the end. -_wm_set_prompt() { - PS1="${PS1}%{$(printf '\e]633;B\a')%}" -} -_wm_set_prompt diff --git a/src-tauri/src/cloud_client.rs b/src-tauri/src/cloud_client.rs deleted file mode 100644 index c602aa4..0000000 --- a/src-tauri/src/cloud_client.rs +++ /dev/null @@ -1,1102 +0,0 @@ -use crate::config::{load_global_config, save_global_config_internal}; -use reqwest::header; -use serde_json::Value; - -#[derive(Debug)] -pub enum CloudError { - NotConfigured, - AuthExpired, - Network(String), - Http(u16, String), -} - -impl CloudError { - pub fn is_network_error(&self) -> bool { - matches!(self, Self::Network(_)) - } - pub fn is_auth_failed(&self) -> bool { - matches!(self, Self::AuthExpired) - } -} - -impl std::fmt::Display for CloudError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::NotConfigured => write!(f, "cloud not configured"), - Self::AuthExpired => write!(f, "cloud auth expired, please re-pair"), - Self::Network(e) => write!(f, "network error: {}", e), - Self::Http(code, msg) => write!(f, "HTTP {}: {}", code, msg), - } - } -} - -pub fn is_cloud_configured() -> bool { - let config = load_global_config(); - config.cloud.server_url.is_some() && config.cloud.access_token.is_some() -} - -pub async fn cloud_ai_chat( - messages: &Value, - model: Option<&str>, - stream: bool, - purpose: &str, - temperature: Option, -) -> Result { - let config = load_global_config(); - let server_url = config - .cloud - .server_url - .as_ref() - .ok_or(CloudError::NotConfigured)? - .clone(); - let access_token = config - .cloud - .access_token - .as_ref() - .ok_or(CloudError::NotConfigured)? - .clone(); - - let url = format!( - "{}/api/ai/v1/chat/completions", - server_url.trim_end_matches('/') - ); - let client = reqwest::Client::new(); - - let mut body = - serde_json::json!({ "messages": messages, "stream": stream, "purpose": purpose }); - if let Some(m) = model { - body["model"] = Value::String(m.to_string()); - } - if let Some(t) = temperature { - body["temperature"] = Value::from(t); - } - - let resp = client - .post(&url) - .header(header::AUTHORIZATION, format!("Bearer {}", access_token)) - .header(header::CONTENT_TYPE, "application/json") - .json(&body) - .timeout(std::time::Duration::from_secs(60)) - .send() - .await - .map_err(|e| { - if e.is_connect() || e.is_timeout() { - CloudError::Network(e.to_string()) - } else { - CloudError::Http(0, e.to_string()) - } - })?; - - let status = resp.status().as_u16(); - if status == 401 { - // Try refresh - let refresh_token = config - .cloud - .refresh_token - .as_ref() - .ok_or(CloudError::AuthExpired)? - .clone(); - match refresh_access_token(&server_url, &refresh_token).await { - Ok(new_token) => { - let mut config = load_global_config(); - config.cloud.access_token = Some(new_token.clone()); - let _ = save_global_config_internal(&config); - // Retry with new token - let resp2 = client - .post(&url) - .header(header::AUTHORIZATION, format!("Bearer {}", new_token)) - .header(header::CONTENT_TYPE, "application/json") - .json(&body) - .timeout(std::time::Duration::from_secs(60)) - .send() - .await - .map_err(|e| CloudError::Network(e.to_string()))?; - if !resp2.status().is_success() { - return Err(CloudError::Http( - resp2.status().as_u16(), - "retry failed".to_string(), - )); - } - resp2 - .text() - .await - .map_err(|e| CloudError::Network(e.to_string())) - } - Err(_) => { - let mut config = load_global_config(); - config.cloud.access_token = None; - config.cloud.refresh_token = None; - let _ = save_global_config_internal(&config); - Err(CloudError::AuthExpired) - } - } - } else if !resp.status().is_success() { - Err(CloudError::Http( - status, - resp.text().await.unwrap_or_default(), - )) - } else { - resp.text() - .await - .map_err(|e| CloudError::Network(e.to_string())) - } -} - -async fn refresh_access_token(server_url: &str, refresh_token: &str) -> Result { - let url = format!("{}/api/auth/refresh", server_url.trim_end_matches('/')); - let client = reqwest::Client::new(); - let resp = client - .post(&url) - .json(&serde_json::json!({ "refresh_token": refresh_token })) - .send() - .await - .map_err(|e| CloudError::Network(e.to_string()))?; - if !resp.status().is_success() { - return Err(CloudError::AuthExpired); - } - #[derive(serde::Deserialize)] - struct R { - access_token: String, - } - let data: R = resp - .json() - .await - .map_err(|e| CloudError::Network(e.to_string()))?; - Ok(data.access_token) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use serial_test::serial; - use std::path::PathBuf; - - struct ConfigCacheGuard { - previous: Option, - _lock: FileLockGuard, - } - - impl ConfigCacheGuard { - fn with_cloud( - server_url: String, - access_token: Option<&str>, - refresh_token: Option<&str>, - ) -> Self { - let lock = FileLockGuard::acquire(); - let mut config = crate::types::GlobalConfig::default(); - config.cloud.server_url = Some(server_url); - config.cloud.access_token = access_token.map(ToOwned::to_owned); - config.cloud.refresh_token = refresh_token.map(ToOwned::to_owned); - - let previous = { - let mut cache = crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::replace(&mut *cache, Some(config)) - }; - - Self { - previous, - _lock: lock, - } - } - - fn not_configured() -> Self { - let lock = FileLockGuard::acquire(); - let previous = { - let mut cache = crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::replace(&mut *cache, Some(crate::types::GlobalConfig::default())) - }; - Self { - previous, - _lock: lock, - } - } - } - - impl Drop for ConfigCacheGuard { - fn drop(&mut self) { - let mut cache = crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = self.previous.take(); - } - } - - struct FileLockGuard { - path: PathBuf, - } - - impl FileLockGuard { - fn acquire() -> Self { - let path = std::env::temp_dir().join("worktree-manager-global-config-cache.lock"); - for _ in 0..500 { - match std::fs::create_dir(&path) { - Ok(()) => return Self { path }, - Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { - std::thread::sleep(std::time::Duration::from_millis(2)); - } - Err(err) => panic!("failed to create test lock {:?}: {}", path, err), - } - } - panic!("timed out waiting for test lock {:?}", path); - } - } - - impl Drop for FileLockGuard { - fn drop(&mut self) { - let _ = std::fs::remove_dir(&self.path); - } - } - - #[serial] - #[test] - fn cloud_error_predicates_and_display_are_specific() { - let network = CloudError::Network("dns failed".to_string()); - let auth = CloudError::AuthExpired; - let http = CloudError::Http(503, "busy".to_string()); - - assert!(network.is_network_error()); - assert!(!network.is_auth_failed()); - assert!(auth.is_auth_failed()); - assert_eq!(auth.to_string(), "cloud auth expired, please re-pair"); - assert_eq!(http.to_string(), "HTTP 503: busy"); - assert_eq!( - CloudError::NotConfigured.to_string(), - "cloud not configured" - ); - } - - #[serial] - #[test] - fn is_cloud_configured_requires_server_url_and_access_token() { - let _config = ConfigCacheGuard::with_cloud("https://cloud.example".to_string(), None, None); - assert!(!is_cloud_configured()); - drop(_config); - - let _config = ConfigCacheGuard::with_cloud( - "https://cloud.example".to_string(), - Some("access-token"), - None, - ); - assert!(is_cloud_configured()); - } - - #[serial] - #[tokio::test] - async fn cloud_ai_chat_maps_invalid_server_url_before_network() { - let _config = - ConfigCacheGuard::with_cloud("://bad-url".to_string(), Some("access-token"), None); - let messages = json!([{ "role": "user", "content": "Hi" }]); - - let err = cloud_ai_chat( - &messages, - Some("qwen-test"), - false, - "voice_refine", - Some(0.25), - ) - .await - .unwrap_err(); - - match err { - CloudError::Http(0, msg) => assert!(msg.contains("builder error")), - other => panic!("expected pre-network HTTP(0) builder error, got {other:?}"), - } - } - - #[serial] - #[tokio::test] - async fn cloud_ai_chat_missing_refresh_token_still_uses_access_token_path() { - let _config = - ConfigCacheGuard::with_cloud("://bad-url".to_string(), Some("access-token"), None); - let messages = json!([{ "role": "user", "content": "Hi" }]); - - let err = cloud_ai_chat(&messages, None, true, "commit_ai", None) - .await - .unwrap_err(); - - match err { - CloudError::Http(0, msg) => assert!(msg.contains("builder error")), - other => panic!("expected pre-network HTTP(0) builder error, got {other:?}"), - } - } - - #[serial] - #[tokio::test] - async fn cloud_ai_chat_returns_not_configured_before_network() { - let _config = ConfigCacheGuard::not_configured(); - let messages = json!([{ "role": "user", "content": "Hi" }]); - - let err = cloud_ai_chat(&messages, None, false, "voice_refine", None) - .await - .unwrap_err(); - - assert!(matches!(err, CloudError::NotConfigured)); - } - - #[serial] - #[tokio::test] - async fn refresh_access_token_maps_invalid_url_before_network() { - let err = refresh_access_token("://bad-url", "refresh-token") - .await - .unwrap_err(); - - match err { - CloudError::Network(msg) => assert!(msg.contains("builder error")), - other => panic!("expected pre-network builder error, got {other:?}"), - } - } - - #[serial] - #[tokio::test] - async fn refresh_access_token_maps_missing_scheme_before_network() { - let err = refresh_access_token("cloud.example", "refresh-token") - .await - .unwrap_err(); - - match err { - CloudError::Network(msg) => assert!(msg.contains("builder error")), - other => panic!("expected pre-network builder error, got {other:?}"), - } - } - - // Successful response parsing and concrete request capture require a local HTTP server. - // The current sandbox denies binding even 127.0.0.1:0, and external APIs are skipped. -} - -#[cfg(test)] -mod coverage_completion_tests { - use super::*; - #[cfg(any())] - use axum::{ - extract::State, - http::{HeaderMap, StatusCode}, - response::{IntoResponse, Response}, - routing::post, - Json, Router, - }; - use serde_json::json; - use serial_test::serial; - #[cfg(any())] - use std::collections::VecDeque; - use std::ffi::OsString; - use std::path::PathBuf; - #[cfg(any())] - use std::sync::{Arc, Mutex}; - #[cfg(any())] - use tokio::net::TcpListener; - - struct ConfigCacheGuard { - previous: Option, - previous_home: Option, - _temp_home: tempfile::TempDir, - _lock: FileLockGuard, - } - - impl ConfigCacheGuard { - fn with_config(config: crate::types::GlobalConfig) -> Self { - let lock = FileLockGuard::acquire(); - let temp_home = tempfile::tempdir().expect("create temp config home"); - let previous_home = std::env::var_os("HOME"); - std::env::set_var("HOME", temp_home.path()); - let previous = { - let mut cache = crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::replace(&mut *cache, Some(config)) - }; - Self { - previous, - previous_home, - _temp_home: temp_home, - _lock: lock, - } - } - } - - impl Drop for ConfigCacheGuard { - fn drop(&mut self) { - let mut cache = crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = self.previous.take(); - match &self.previous_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } - } - } - - // Local mock-server coverage is intentionally compiled out in this sandbox: - // binding 127.0.0.1:0 returns PermissionDenied, while external cloud APIs are flaky. - #[cfg(any())] - #[derive(Clone)] - struct MockResponse { - status: StatusCode, - body: &'static str, - } - - #[cfg(any())] - #[derive(Default)] - struct MockCloudState { - chat_responses: VecDeque, - refresh_responses: VecDeque, - chat_authorizations: Vec, - chat_bodies: Vec, - refresh_bodies: Vec, - } - - #[cfg(any())] - fn header_string(headers: &HeaderMap, name: &str) -> String { - headers - .get(name) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string() - } - - #[cfg(any())] - async fn spawn_cloud_server(state: Arc>) -> String { - let app = Router::new() - .route( - "/api/ai/v1/chat/completions", - post( - |headers: HeaderMap, - State(state): State>>, - Json(body): Json| async move { - let response = { - let mut state = state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state - .chat_authorizations - .push(header_string(&headers, "authorization")); - state.chat_bodies.push(body); - state - .chat_responses - .pop_front() - .expect("queued chat response") - }; - Response::from((response.status, response.body).into_response()) - }, - ), - ) - .route( - "/api/auth/refresh", - post( - |State(state): State>>, - Json(body): Json| async move { - let response = { - let mut state = state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state.refresh_bodies.push(body); - state - .refresh_responses - .pop_front() - .expect("queued refresh response") - }; - Response::from((response.status, response.body).into_response()) - }, - ), - ) - .with_state(state); - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("bind cloud client mock server"); - let addr = listener.local_addr().expect("cloud client mock addr"); - tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - format!("http://{}", addr) - } - - struct FileLockGuard { - path: PathBuf, - } - - impl FileLockGuard { - fn acquire() -> Self { - let path = std::env::temp_dir().join("worktree-manager-global-config-cache.lock"); - for _ in 0..500 { - match std::fs::create_dir(&path) { - Ok(()) => return Self { path }, - Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { - std::thread::sleep(std::time::Duration::from_millis(2)); - } - Err(err) => panic!("failed to create test lock {:?}: {}", path, err), - } - } - panic!("timed out waiting for test lock {:?}", path); - } - } - - impl Drop for FileLockGuard { - fn drop(&mut self) { - let _ = std::fs::remove_dir(&self.path); - } - } - - fn cloud_config( - server_url: Option<&str>, - access_token: Option<&str>, - refresh_token: Option<&str>, - ) -> crate::types::GlobalConfig { - let mut config = crate::types::GlobalConfig::default(); - config.cloud.server_url = server_url.map(ToOwned::to_owned); - config.cloud.access_token = access_token.map(ToOwned::to_owned); - config.cloud.refresh_token = refresh_token.map(ToOwned::to_owned); - config - } - - #[serial] - #[test] - fn is_cloud_configured_requires_both_url_and_access_token() { - let _config = - ConfigCacheGuard::with_config(cloud_config(Some("https://cloud.example"), None, None)); - assert!(!is_cloud_configured()); - drop(_config); - - let _config = ConfigCacheGuard::with_config(cloud_config(None, Some("access"), None)); - assert!(!is_cloud_configured()); - drop(_config); - - let _config = ConfigCacheGuard::with_config(cloud_config( - Some("https://cloud.example"), - Some("access"), - None, - )); - assert!(is_cloud_configured()); - } - - #[serial] - #[test] - fn cloud_error_display_covers_network_and_http_empty_body() { - assert_eq!( - CloudError::Network("timeout".to_string()).to_string(), - "network error: timeout" - ); - assert_eq!( - CloudError::Http(418, String::new()).to_string(), - "HTTP 418: " - ); - assert!(!CloudError::Http(500, "server".to_string()).is_network_error()); - assert!(!CloudError::Network("dns".to_string()).is_auth_failed()); - } - - #[serial] - #[tokio::test] - async fn cloud_ai_chat_requires_access_token_even_when_server_is_configured() { - let _config = ConfigCacheGuard::with_config(cloud_config( - Some("https://cloud.example"), - None, - Some("refresh"), - )); - - let err = cloud_ai_chat(&json!([]), None, false, "unit_test", None) - .await - .unwrap_err(); - - assert!(matches!(err, CloudError::NotConfigured)); - } - - #[serial] - #[tokio::test] - async fn cloud_ai_chat_maps_empty_server_url_to_request_builder_error() { - let _config = ConfigCacheGuard::with_config(cloud_config(Some(""), Some("access"), None)); - - let err = cloud_ai_chat( - &json!([{ "role": "user", "content": "hello" }]), - Some("model-a"), - true, - "unit_test", - Some(0.5), - ) - .await - .unwrap_err(); - - match err { - CloudError::Http(0, msg) => assert!(msg.contains("builder error"), "{msg}"), - other => panic!("expected builder error, got {other:?}"), - } - } - - #[serial] - #[tokio::test] - async fn refresh_access_token_maps_empty_server_url_to_network_builder_error() { - let err = refresh_access_token("", "refresh-token").await.unwrap_err(); - - match err { - CloudError::Network(msg) => assert!(msg.contains("builder error"), "{msg}"), - other => panic!("expected builder error, got {other:?}"), - } - } - - #[serial] - #[test] - fn response_json_shapes_parse_without_http() { - let chat_response = json!({ - "id": "chatcmpl-test", - "choices": [{ - "message": { "role": "assistant", "content": "done" }, - "finish_reason": "stop" - }] - }); - assert_eq!(chat_response["id"], "chatcmpl-test"); - assert_eq!(chat_response["choices"][0]["message"]["content"], "done"); - - let refresh_response = json!({ "access_token": "new-access" }); - assert_eq!( - refresh_response - .get("access_token") - .and_then(serde_json::Value::as_str), - Some("new-access") - ); - } - - #[serial] - #[test] - fn cloud_configuration_matrix_requires_url_and_access_token() { - let cases = [ - ( - None, - None, - None, - false, - "empty cloud config is disconnected", - ), - ( - Some("https://cloud.example"), - None, - None, - false, - "server alone is not configured", - ), - ( - None, - Some("access-a"), - None, - false, - "access token alone is not configured", - ), - ( - None, - None, - Some("refresh-a"), - false, - "refresh token alone is not configured", - ), - ( - Some("https://cloud.example"), - Some("access-a"), - None, - true, - "server and access token are configured", - ), - ( - Some("https://cloud.example/"), - Some("access-b"), - Some("refresh-b"), - true, - "refresh token does not affect configured status", - ), - ( - Some(""), - Some("access-c"), - None, - true, - "empty server string is still present to the config check", - ), - ( - Some("https://cloud.example/base/path"), - Some("access-d"), - Some("refresh-d"), - true, - "nested server url is accepted by the config check", - ), - ]; - - for (server_url, access_token, refresh_token, expected, label) in cases { - let _config = ConfigCacheGuard::with_config(cloud_config( - server_url, - access_token, - refresh_token, - )); - - assert_eq!(is_cloud_configured(), expected, "{label}"); - } - } - - #[serial] - #[test] - fn cloud_error_display_and_predicate_matrix_is_specific() { - let cases = [ - ( - CloudError::NotConfigured, - "cloud not configured", - false, - false, - ), - ( - CloudError::AuthExpired, - "cloud auth expired, please re-pair", - false, - true, - ), - ( - CloudError::Network("dns failed".to_string()), - "network error: dns failed", - true, - false, - ), - ( - CloudError::Network("timeout".to_string()), - "network error: timeout", - true, - false, - ), - ( - CloudError::Http(0, "builder error".to_string()), - "HTTP 0: builder error", - false, - false, - ), - ( - CloudError::Http(400, "bad request".to_string()), - "HTTP 400: bad request", - false, - false, - ), - ( - CloudError::Http(401, "unauthorized".to_string()), - "HTTP 401: unauthorized", - false, - false, - ), - ( - CloudError::Http(503, "maintenance".to_string()), - "HTTP 503: maintenance", - false, - false, - ), - ( - CloudError::Http(599, String::new()), - "HTTP 599: ", - false, - false, - ), - ]; - - for (error, display, is_network, is_auth) in cases { - assert_eq!(error.to_string(), display); - assert_eq!(error.is_network_error(), is_network); - assert_eq!(error.is_auth_failed(), is_auth); - } - } - - #[serial] - #[tokio::test] - async fn cloud_ai_chat_pre_network_validation_matrix_is_stable() { - let messages = json!([ - { - "role": "system", - "content": "You are a concise assistant." - }, - { - "role": "user", - "content": "Summarize the branch." - } - ]); - let cases = [ - ( - cloud_config(None, None, None), - None, - false, - "voice_refine", - None, - "not-configured empty config", - ), - ( - cloud_config(Some("https://cloud.example"), None, None), - Some("model-a"), - false, - "voice_refine", - Some(0.1), - "not-configured missing access token", - ), - ( - cloud_config(None, Some("access-a"), Some("refresh-a")), - Some("model-b"), - true, - "commit_ai", - Some(0.2), - "not-configured missing server url", - ), - ( - cloud_config(Some(""), Some("access-b"), None), - None, - false, - "unit_test", - None, - "empty server url builder error", - ), - ( - cloud_config(Some("://bad-url"), Some("access-c"), None), - Some("model-c"), - true, - "unit_test", - Some(0.3), - "invalid server url builder error", - ), - ( - cloud_config(Some("http://[::1"), Some("access-d"), Some("refresh-d")), - None, - false, - "unit_test", - None, - "malformed ipv6 server url builder error", - ), - ]; - - for (config, model, stream, purpose, temperature, label) in cases { - let _config = ConfigCacheGuard::with_config(config); - let error = cloud_ai_chat(&messages, model, stream, purpose, temperature) - .await - .expect_err(label); - - match label { - label if label.starts_with("not-configured") => { - assert!( - matches!(error, CloudError::NotConfigured), - "{label}: {error:?}" - ); - } - _ => match error { - CloudError::Http(0, message) => { - assert!(message.contains("builder error"), "{label}: {message}"); - } - other => panic!("{label}: expected request builder error, got {other:?}"), - }, - } - } - } - - #[serial] - #[tokio::test] - async fn refresh_access_token_pre_network_error_matrix_is_network_error() { - let cases = [ - ( - "", - "refresh-a", - "empty server url cannot build refresh request", - ), - ( - "://bad-url", - "refresh-b", - "bad scheme cannot build refresh request", - ), - ( - "cloud.example", - "refresh-c", - "missing scheme cannot build refresh request", - ), - ( - "http://[::1", - "refresh-d", - "malformed ipv6 literal cannot build refresh request", - ), - ]; - - for (server_url, refresh_token, label) in cases { - let error = refresh_access_token(server_url, refresh_token) - .await - .expect_err(label); - - match error { - CloudError::Network(message) => { - assert!(message.contains("builder error"), "{label}: {message}"); - } - other => panic!("{label}: expected network builder error, got {other:?}"), - } - } - } - - #[cfg(any())] - #[serial] - #[tokio::test] - async fn cloud_ai_chat_posts_expected_body_and_returns_success() { - let state = Arc::new(Mutex::new(MockCloudState { - chat_responses: VecDeque::from([MockResponse { - status: StatusCode::OK, - body: "assistant reply", - }]), - ..MockCloudState::default() - })); - let server_url = spawn_cloud_server(state.clone()).await; - let _config = - ConfigCacheGuard::with_config(cloud_config(Some(&server_url), Some("access-a"), None)); - - let result = cloud_ai_chat( - &json!([{ "role": "user", "content": "hello" }]), - Some("model-a"), - true, - "voice_refine", - Some(0.25), - ) - .await - .expect("chat succeeds"); - - let state = state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - assert_eq!(result, "assistant reply"); - assert_eq!(state.chat_authorizations, vec!["Bearer access-a"]); - assert_eq!(state.chat_bodies[0]["model"], "model-a"); - assert_eq!(state.chat_bodies[0]["stream"], true); - assert_eq!(state.chat_bodies[0]["purpose"], "voice_refine"); - assert_eq!(state.chat_bodies[0]["temperature"], 0.25); - assert_eq!(state.chat_bodies[0]["messages"][0]["content"], "hello"); - } - - #[cfg(any())] - #[serial] - #[tokio::test] - async fn cloud_ai_chat_refreshes_access_token_and_retries_once() { - let state = Arc::new(Mutex::new(MockCloudState { - chat_responses: VecDeque::from([ - MockResponse { - status: StatusCode::UNAUTHORIZED, - body: "expired", - }, - MockResponse { - status: StatusCode::OK, - body: "retry reply", - }, - ]), - refresh_responses: VecDeque::from([MockResponse { - status: StatusCode::OK, - body: r#"{"access_token":"fresh-access"}"#, - }]), - ..MockCloudState::default() - })); - let server_url = spawn_cloud_server(state.clone()).await; - let _config = ConfigCacheGuard::with_config(cloud_config( - Some(&server_url), - Some("expired-access"), - Some("refresh-a"), - )); - - let result = cloud_ai_chat(&json!([]), None, false, "commit_ai", None) - .await - .expect("refresh then retry succeeds"); - - let state = state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - assert_eq!(result, "retry reply"); - assert_eq!( - state.chat_authorizations, - vec!["Bearer expired-access", "Bearer fresh-access"] - ); - assert_eq!( - state.refresh_bodies, - vec![json!({ "refresh_token": "refresh-a" })] - ); - } - - #[cfg(any())] - #[serial] - #[tokio::test] - async fn cloud_ai_chat_reports_retry_failure_and_http_error_bodies() { - let retry_state = Arc::new(Mutex::new(MockCloudState { - chat_responses: VecDeque::from([ - MockResponse { - status: StatusCode::UNAUTHORIZED, - body: "expired", - }, - MockResponse { - status: StatusCode::BAD_GATEWAY, - body: "still bad", - }, - ]), - refresh_responses: VecDeque::from([MockResponse { - status: StatusCode::OK, - body: r#"{"access_token":"fresh-access"}"#, - }]), - ..MockCloudState::default() - })); - let retry_server = spawn_cloud_server(retry_state).await; - let _config = ConfigCacheGuard::with_config(cloud_config( - Some(&retry_server), - Some("expired-access"), - Some("refresh-a"), - )); - - let retry_err = cloud_ai_chat(&json!([]), None, false, "commit_ai", None) - .await - .unwrap_err(); - assert!(matches!( - retry_err, - CloudError::Http(502, ref message) if message == "retry failed" - )); - drop(_config); - - let http_state = Arc::new(Mutex::new(MockCloudState { - chat_responses: VecDeque::from([MockResponse { - status: StatusCode::SERVICE_UNAVAILABLE, - body: "maintenance", - }]), - ..MockCloudState::default() - })); - let http_server = spawn_cloud_server(http_state).await; - let _config = - ConfigCacheGuard::with_config(cloud_config(Some(&http_server), Some("access-a"), None)); - - let http_err = cloud_ai_chat(&json!([]), None, false, "commit_ai", None) - .await - .unwrap_err(); - assert!(matches!( - http_err, - CloudError::Http(503, ref message) if message == "maintenance" - )); - } - - #[cfg(any())] - #[serial] - #[tokio::test] - async fn refresh_access_token_maps_status_and_malformed_json_to_auth_or_network() { - let denied_state = Arc::new(Mutex::new(MockCloudState { - refresh_responses: VecDeque::from([MockResponse { - status: StatusCode::UNAUTHORIZED, - body: "no", - }]), - ..MockCloudState::default() - })); - let denied_server = spawn_cloud_server(denied_state).await; - - let denied = refresh_access_token(&denied_server, "refresh-a") - .await - .unwrap_err(); - assert!(matches!(denied, CloudError::AuthExpired)); - - let malformed_state = Arc::new(Mutex::new(MockCloudState { - refresh_responses: VecDeque::from([MockResponse { - status: StatusCode::OK, - body: "not-json", - }]), - ..MockCloudState::default() - })); - let malformed_server = spawn_cloud_server(malformed_state).await; - - let malformed = refresh_access_token(&malformed_server, "refresh-b") - .await - .unwrap_err(); - assert!( - matches!(malformed, CloudError::Network(ref message) if message.contains("expected")) - ); - } -} diff --git a/src-tauri/src/commands/cloud.rs b/src-tauri/src/commands/cloud.rs deleted file mode 100644 index 7b8cbf0..0000000 --- a/src-tauri/src/commands/cloud.rs +++ /dev/null @@ -1,1703 +0,0 @@ -use once_cell::sync::Lazy; -use std::sync::Mutex; - -use crate::config::{load_global_config, save_global_config_internal}; - -const DEFAULT_WMS_URL: &str = "https://wms.kirov-opensource.com/"; - -fn get_default_device_name() -> String { - if let Ok(output) = std::process::Command::new("hostname").output() { - if output.status.success() { - let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !name.is_empty() { - return name; - } - } - } - "Worktree Manager".to_string() -} - -// ==================== JWT helpers ==================== - -/// Decode the JWT payload (middle base64 part) without signature verification -/// to extract exp and sub claims. Returns (exp_iso8601, sub). -fn decode_jwt_claims(token: &str) -> Option<(String, String)> { - let parts: Vec<&str> = token.splitn(3, '.').collect(); - if parts.len() < 2 { - return None; - } - use base64::Engine; - let payload_b64 = parts[1]; - let padded = match payload_b64.len() % 4 { - 2 => format!("{}==", payload_b64), - 3 => format!("{}=", payload_b64), - _ => payload_b64.to_string(), - }; - let decoded = base64::engine::general_purpose::URL_SAFE - .decode(&padded) - .ok()?; - let claims: serde_json::Value = serde_json::from_slice(&decoded).ok()?; - let exp = claims["exp"].as_u64()?; - let sub = claims["sub"].as_str().unwrap_or("").to_string(); - let exp_dt = chrono::DateTime::::from_timestamp(exp as i64, 0)?; - Some((exp_dt.to_rfc3339(), sub)) -} - -#[derive(serde::Deserialize, Debug)] -struct MeResponse { - email: Option, - username: Option, -} - -// ==================== Pairing State ==================== - -struct PairingState { - code: String, - device_secret: String, - server_url: String, -} - -static PAIRING_STATE: Lazy>> = Lazy::new(|| Mutex::new(None)); - -// ==================== Return Types ==================== - -#[derive(serde::Serialize)] -pub struct CloudStatus { - pub connected: bool, - pub pairing: bool, - pub server_url: Option, - pub user_email: Option, - pub username: Option, - pub token_expires_at: Option, // ISO 8601 -} - -#[derive(serde::Serialize, serde::Deserialize, Debug)] -pub struct DeviceCodeResponse { - pub code: String, - pub device_secret: String, - pub expires_in: Option, - pub poll_interval: Option, -} - -#[derive(serde::Serialize, serde::Deserialize, Debug)] -pub struct DeviceCodeStatusResponse { - pub status: String, - pub access_token: Option, - pub refresh_token: Option, - pub user_email: Option, -} - -#[derive(serde::Serialize, serde::Deserialize, Debug)] -pub struct ApproveResponse { - pub access_token: Option, - pub refresh_token: Option, - pub user_email: Option, -} - -// ==================== Commands ==================== - -/// Returns current cloud connection status. -/// If connected, decodes JWT for expiry and fetches /api/me for user info. -#[tauri::command] -pub(crate) async fn cloud_get_status() -> Result { - let config = load_global_config(); - let pairing = { - let state = PAIRING_STATE.lock().map_err(|e| e.to_string())?; - state.is_some() - }; - - let access_token = config - .cloud - .access_token - .as_ref() - .filter(|t| !t.is_empty()) - .cloned(); - let connected = access_token.is_some(); - - if !connected { - return Ok(CloudStatus { - connected: false, - pairing, - server_url: config.cloud.server_url, - user_email: None, - username: None, - token_expires_at: None, - }); - } - - let token = access_token.unwrap(); - let server_url = config.cloud.server_url.clone(); - - // Decode JWT locally to get expiry - let token_expires_at = decode_jwt_claims(&token).map(|(exp, _)| exp); - - // Fetch /api/me for user info - let (user_email, username) = if let Some(ref url) = server_url { - let client = reqwest::Client::new(); - match client - .get(format!("{}/api/me", url.trim_end_matches('/'))) - .header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)) - .timeout(std::time::Duration::from_secs(5)) - .send() - .await - { - Ok(resp) if resp.status().is_success() => match resp.json::().await { - Ok(me) => (me.email, me.username), - Err(_) => (None, None), - }, - _ => (None, None), - } - } else { - (None, None) - }; - - Ok(CloudStatus { - connected, - pairing, - server_url, - user_email, - username, - token_expires_at, - }) -} - -/// Initiates device-code pairing flow. -/// POSTs to `{server_url}/api/device-codes`, stores server_url + device_name in config, -/// and stores the returned code + secret in PAIRING_STATE. -#[tauri::command] -pub(crate) async fn cloud_start_pairing() -> Result { - let server_url = DEFAULT_WMS_URL.to_string(); - let device_name = get_default_device_name(); - let client = reqwest::Client::new(); - - let resp = client - .post(format!( - "{}/api/device-codes", - server_url.trim_end_matches('/') - )) - .json(&serde_json::json!({ "device_name": device_name })) - .send() - .await - .map_err(|e| format!("请求失败: {}", e))?; - - if !resp.status().is_success() { - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - return Err(format!("服务器返回错误 {}: {}", status, text)); - } - - let data: DeviceCodeResponse = resp - .json() - .await - .map_err(|e| format!("解析响应失败: {}", e))?; - - // Store server_url + device_name in config - { - let mut config = load_global_config(); - config.cloud.server_url = Some(server_url.clone()); - config.cloud.device_name = Some(device_name); - // Clear any existing tokens on new pairing - config.cloud.access_token = None; - config.cloud.refresh_token = None; - save_global_config_internal(&config)?; - } - - // Store pairing state - { - let mut state = PAIRING_STATE.lock().map_err(|e| e.to_string())?; - *state = Some(PairingState { - code: data.code.clone(), - device_secret: data.device_secret.clone(), - server_url, - }); - } - - Ok(data) -} - -/// Polls the device-code status endpoint to check if the code has been approved. -#[tauri::command] -pub(crate) async fn cloud_check_pairing_status() -> Result { - let (code, device_secret, server_url) = { - let state = PAIRING_STATE.lock().map_err(|e| e.to_string())?; - let s = state - .as_ref() - .ok_or_else(|| "没有进行中的配对流程".to_string())?; - ( - s.code.clone(), - s.device_secret.clone(), - s.server_url.clone(), - ) - }; - - let client = reqwest::Client::new(); - - let resp = client - .post(format!( - "{}/api/device-codes/status", - server_url.trim_end_matches('/') - )) - .json(&serde_json::json!({ - "code": code, - "device_secret": device_secret, - })) - .send() - .await - .map_err(|e| format!("请求失败: {}", e))?; - - if !resp.status().is_success() { - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - return Err(format!("服务器返回错误 {}: {}", status, text)); - } - - let data: DeviceCodeStatusResponse = resp - .json() - .await - .map_err(|e| format!("解析响应失败: {}", e))?; - - // If approved, store the tokens - if data.status == "approved" { - if let (Some(access_token), Some(refresh_token)) = - (data.access_token.clone(), data.refresh_token.clone()) - { - let mut config = load_global_config(); - config.cloud.access_token = Some(access_token); - config.cloud.refresh_token = Some(refresh_token); - save_global_config_internal(&config)?; - } - // Clear pairing state - let mut state = PAIRING_STATE.lock().map_err(|e| e.to_string())?; - *state = None; - } - - Ok(data) -} - -/// Approves the pairing on this device (admin flow). -/// POSTs to `{server_url}/api/device-codes/{code}/approve` and stores received tokens. -#[tauri::command] -pub(crate) async fn cloud_approve_pairing() -> Result { - let (code, device_secret, server_url) = { - let state = PAIRING_STATE.lock().map_err(|e| e.to_string())?; - let s = state - .as_ref() - .ok_or_else(|| "没有进行中的配对流程".to_string())?; - ( - s.code.clone(), - s.device_secret.clone(), - s.server_url.clone(), - ) - }; - - let client = reqwest::Client::new(); - - let resp = client - .post(format!( - "{}/api/device-codes/{}/approve", - server_url.trim_end_matches('/'), - code - )) - .json(&serde_json::json!({ "device_secret": device_secret })) - .send() - .await - .map_err(|e| format!("请求失败: {}", e))?; - - if !resp.status().is_success() { - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - return Err(format!("服务器返回错误 {}: {}", status, text)); - } - - let data: ApproveResponse = resp - .json() - .await - .map_err(|e| format!("解析响应失败: {}", e))?; - - // Store tokens in config - { - let mut config = load_global_config(); - if let Some(ref token) = data.access_token { - config.cloud.access_token = Some(token.clone()); - } - if let Some(ref token) = data.refresh_token { - config.cloud.refresh_token = Some(token.clone()); - } - save_global_config_internal(&config)?; - } - - // Clear pairing state - { - let mut state = PAIRING_STATE.lock().map_err(|e| e.to_string())?; - *state = None; - } - - Ok(data) -} - -/// Rejects the pairing for this device code and clears PAIRING_STATE. -#[tauri::command] -pub(crate) async fn cloud_reject_pairing() -> Result<(), String> { - let (code, device_secret, server_url) = { - let state = PAIRING_STATE.lock().map_err(|e| e.to_string())?; - let s = state - .as_ref() - .ok_or_else(|| "没有进行中的配对流程".to_string())?; - ( - s.code.clone(), - s.device_secret.clone(), - s.server_url.clone(), - ) - }; - - let client = reqwest::Client::new(); - - let resp = client - .post(format!( - "{}/api/device-codes/{}/reject", - server_url.trim_end_matches('/'), - code - )) - .json(&serde_json::json!({ "device_secret": device_secret })) - .send() - .await - .map_err(|e| format!("请求失败: {}", e))?; - - if !resp.status().is_success() { - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - return Err(format!("服务器返回错误 {}: {}", status, text)); - } - - // Clear pairing state regardless - { - let mut state = PAIRING_STATE.lock().map_err(|e| e.to_string())?; - *state = None; - } - - Ok(()) -} - -/// Disconnects from cloud by clearing tokens from config. -#[tauri::command] -pub(crate) async fn cloud_disconnect() -> Result<(), String> { - let mut config = load_global_config(); - config.cloud.access_token = None; - config.cloud.refresh_token = None; - save_global_config_internal(&config)?; - - // Also clear any in-progress pairing - { - let mut state = PAIRING_STATE.lock().map_err(|e| e.to_string())?; - *state = None; - } - - Ok(()) -} - -#[cfg(test)] -pub(crate) fn clear_pairing_state_for_test() { - let mut state = PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *state = None; -} - -#[cfg(test)] -mod tests { - use super::*; - use base64::Engine; - use once_cell::sync::Lazy; - use serde_json::json; - use serial_test::serial; - use std::sync::{Mutex, MutexGuard}; - - static PAIRING_TEST_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); - - fn lock_pairing_state() -> MutexGuard<'static, ()> { - PAIRING_TEST_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - } - - struct PairingStateGuard { - previous: Option, - } - - impl PairingStateGuard { - fn isolated() -> Self { - let previous = { - let mut state = PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state.take() - }; - Self { previous } - } - - fn set_pending(&self, code: &str, device_secret: &str, server_url: &str) { - let mut state = PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *state = Some(PairingState { - code: code.to_string(), - device_secret: device_secret.to_string(), - server_url: server_url.to_string(), - }); - } - } - - impl Drop for PairingStateGuard { - fn drop(&mut self) { - let mut state = PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *state = self.previous.take(); - } - } - - fn token_with_claims(claims: serde_json::Value) -> String { - let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(serde_json::to_vec(&claims).unwrap()); - format!("header.{}.signature", payload) - } - - fn token_with_payload_mod(target_mod: usize) -> String { - for pad_len in 0..32 { - let token = token_with_claims(json!({ - "exp": 4_102_444_800u64, - "sub": "user-123", - "pad": "x".repeat(pad_len), - })); - let payload = token.split('.').nth(1).unwrap(); - if payload.len() % 4 == target_mod { - return token; - } - } - panic!("could not construct JWT payload with len % 4 == {target_mod}"); - } - - #[serial] - #[test] - fn decode_jwt_claims_accepts_unpadded_payload_variants() { - for target_mod in [0usize, 2, 3] { - let token = token_with_payload_mod(target_mod); - let payload = token.split('.').nth(1).unwrap(); - assert_eq!(payload.len() % 4, target_mod); - - let (expires_at, sub) = decode_jwt_claims(&token).unwrap(); - - assert_eq!(expires_at, "2100-01-01T00:00:00+00:00"); - assert_eq!(sub, "user-123"); - } - } - - #[serial] - #[test] - fn decode_jwt_claims_returns_past_and_future_expirations() { - let expired = token_with_claims(json!({ "exp": 946_684_800u64, "sub": "old-user" })); - let valid = token_with_claims(json!({ "exp": 4_102_444_800u64, "sub": "new-user" })); - - assert_eq!( - decode_jwt_claims(&expired).unwrap(), - ( - "2000-01-01T00:00:00+00:00".to_string(), - "old-user".to_string() - ) - ); - assert_eq!( - decode_jwt_claims(&valid).unwrap(), - ( - "2100-01-01T00:00:00+00:00".to_string(), - "new-user".to_string() - ) - ); - } - - #[serial] - #[test] - fn decode_jwt_claims_rejects_malformed_tokens() { - assert_eq!(decode_jwt_claims("not-a-jwt"), None); - assert_eq!(decode_jwt_claims("header.%%%bad%%%.sig"), None); - assert_eq!( - decode_jwt_claims(&token_with_claims(json!({ "sub": "missing-exp" }))), - None - ); - assert_eq!( - decode_jwt_claims(&token_with_claims( - json!({ "exp": "not-a-number", "sub": "u" }) - )), - None - ); - } - - #[serial] - #[test] - fn cloud_response_types_round_trip_json() { - let device_code = DeviceCodeResponse { - code: "ABCD-EFGH".to_string(), - device_secret: "secret".to_string(), - expires_in: Some(600), - poll_interval: Some(3), - }; - let encoded = serde_json::to_string(&device_code).unwrap(); - let decoded: DeviceCodeResponse = serde_json::from_str(&encoded).unwrap(); - assert_eq!(decoded.code, "ABCD-EFGH"); - assert_eq!(decoded.device_secret, "secret"); - assert_eq!(decoded.expires_in, Some(600)); - assert_eq!(decoded.poll_interval, Some(3)); - - let status: DeviceCodeStatusResponse = serde_json::from_value(json!({ - "status": "approved", - "access_token": "access", - "refresh_token": "refresh", - "user_email": "user@example.com" - })) - .unwrap(); - assert_eq!(status.status, "approved"); - assert_eq!(status.access_token.as_deref(), Some("access")); - assert_eq!(status.refresh_token.as_deref(), Some("refresh")); - assert_eq!(status.user_email.as_deref(), Some("user@example.com")); - } - - #[serial] - #[test] - fn decode_jwt_claims_ignores_signature_text_with_extra_dots() { - let token = format!( - "{}.signature.with.extra.parts", - token_with_claims(json!({ "exp": 4_102_444_800u64, "sub": "dotted-user" })) - .trim_end_matches(".signature") - ); - - let decoded = decode_jwt_claims(&token).expect("decode token with dotted signature"); - - assert_eq!( - decoded, - ( - "2100-01-01T00:00:00+00:00".to_string(), - "dotted-user".to_string() - ) - ); - } - - #[serial] - #[test] - fn decode_jwt_claims_rejects_non_json_payloads() { - let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode("not-json"); - let token = format!("header.{payload}.signature"); - - assert_eq!(decode_jwt_claims(&token), None); - } - - #[serial] - #[test] - fn decode_jwt_claims_rejects_null_negative_and_fractional_exp_values() { - assert_eq!( - decode_jwt_claims(&token_with_claims(json!({ "exp": null, "sub": "u" }))), - None - ); - assert_eq!( - decode_jwt_claims(&token_with_claims(json!({ "exp": -1, "sub": "u" }))), - None - ); - assert_eq!( - decode_jwt_claims(&token_with_claims(json!({ "exp": 123.45, "sub": "u" }))), - None - ); - } - - #[serial] - #[test] - fn decode_jwt_claims_uses_empty_subject_for_non_string_sub() { - let token = token_with_claims(json!({ "exp": 4_102_444_800u64, "sub": 123 })); - - let decoded = decode_jwt_claims(&token).expect("decode numeric sub token"); - - assert_eq!(decoded.0, "2100-01-01T00:00:00+00:00"); - assert_eq!(decoded.1, ""); - } - - #[serial] - #[test] - fn cloud_status_serializes_snake_case_fields_and_null_optionals() { - let status = CloudStatus { - connected: true, - pairing: false, - server_url: Some("https://cloud.example".to_string()), - user_email: None, - username: Some("tester".to_string()), - token_expires_at: Some("2100-01-01T00:00:00+00:00".to_string()), - }; - - let value = serde_json::to_value(status).expect("serialize cloud status"); - - assert_eq!(value["connected"], true); - assert_eq!(value["pairing"], false); - assert_eq!(value["server_url"], "https://cloud.example"); - assert_eq!(value["user_email"], serde_json::Value::Null); - assert_eq!(value["username"], "tester"); - assert_eq!(value["token_expires_at"], "2100-01-01T00:00:00+00:00"); - } - - #[serial] - #[test] - fn approve_and_device_code_responses_deserialize_missing_optional_fields() { - let approve: ApproveResponse = - serde_json::from_value(json!({})).expect("approve response without tokens"); - assert_eq!(approve.access_token, None); - assert_eq!(approve.refresh_token, None); - assert_eq!(approve.user_email, None); - - let device_code: DeviceCodeResponse = serde_json::from_value(json!({ - "code": "PAIR-000", - "device_secret": "secret" - })) - .expect("device code without optional timing"); - assert_eq!(device_code.code, "PAIR-000"); - assert_eq!(device_code.device_secret, "secret"); - assert_eq!(device_code.expires_in, None); - assert_eq!(device_code.poll_interval, None); - } - - #[serial] - #[test] - fn decode_jwt_claims_accepts_two_segment_tokens_without_signature() { - let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( - serde_json::to_vec(&json!({ "exp": 4_102_444_800u64, "sub": "unsigned" })).unwrap(), - ); - let token = format!("header.{payload}"); - - let decoded = decode_jwt_claims(&token).expect("decode unsigned token shape"); - - assert_eq!( - decoded, - ( - "2100-01-01T00:00:00+00:00".to_string(), - "unsigned".to_string() - ) - ); - } - - #[serial] - #[test] - fn decode_jwt_claims_rejects_empty_payload_segment() { - assert_eq!(decode_jwt_claims("header..signature"), None); - } - - #[serial] - #[test] - fn device_code_response_requires_code_and_device_secret() { - let missing_code: Result = - serde_json::from_value(json!({ "device_secret": "secret" })); - let missing_secret: Result = - serde_json::from_value(json!({ "code": "PAIR-000" })); - - assert!(missing_code.is_err()); - assert!(missing_secret.is_err()); - } - - #[serial] - #[test] - fn default_device_name_returns_non_empty_trimmed_name() { - let name = get_default_device_name(); - - assert!(!name.is_empty()); - assert_eq!(name, name.trim()); - } - - #[serial] - #[test] - fn pairing_state_guard_restores_existing_pending_state() { - let _lock = lock_pairing_state(); - { - let mut state = PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *state = Some(PairingState { - code: "ORIGINAL".to_string(), - device_secret: "original-secret".to_string(), - server_url: "https://original.example".to_string(), - }); - } - - { - let guard = PairingStateGuard::isolated(); - assert!(PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_none()); - guard.set_pending("TEMP", "temp-secret", "https://temp.example"); - let state = PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let state = state.as_ref().expect("temporary pairing state"); - assert_eq!(state.code, "TEMP"); - assert_eq!(state.device_secret, "temp-secret"); - assert_eq!(state.server_url, "https://temp.example"); - } - - let restored = PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .as_ref() - .map(|state| { - ( - state.code.clone(), - state.device_secret.clone(), - state.server_url.clone(), - ) - }); - assert_eq!( - restored, - Some(( - "ORIGINAL".to_string(), - "original-secret".to_string(), - "https://original.example".to_string() - )) - ); - - *PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; - } - - #[serial] - #[tokio::test] - async fn pairing_commands_fail_before_network_without_pending_state() { - let _lock = lock_pairing_state(); - let _guard = PairingStateGuard::isolated(); - - assert_eq!( - cloud_check_pairing_status().await.unwrap_err(), - "没有进行中的配对流程" - ); - assert_eq!( - cloud_approve_pairing().await.unwrap_err(), - "没有进行中的配对流程" - ); - assert_eq!( - cloud_reject_pairing().await.unwrap_err(), - "没有进行中的配对流程" - ); - } - - #[serial] - #[tokio::test] - async fn reject_pairing_preserves_pending_state_when_request_cannot_be_built() { - let _lock = lock_pairing_state(); - let _guard = PairingStateGuard::isolated(); - _guard.set_pending("PAIR-123", "device-secret", "://bad-url"); - - let err = cloud_reject_pairing().await.unwrap_err(); - - assert!(err.starts_with("请求失败:")); - let state = PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .as_ref() - .map(|state| { - ( - state.code.clone(), - state.device_secret.clone(), - state.server_url.clone(), - ) - }); - assert_eq!( - state, - Some(( - "PAIR-123".to_string(), - "device-secret".to_string(), - "://bad-url".to_string() - )) - ); - } - - // Posting the reject/approve/status request body is not covered with a live server: - // this sandbox denies binding even 127.0.0.1:0, and external APIs would be flaky. -} - -#[cfg(test)] -mod coverage_completion_tests { - use super::*; - #[cfg(any())] - use axum::{ - extract::{Path as AxumPath, State}, - http::{HeaderMap, StatusCode}, - response::{IntoResponse, Response}, - routing::{get, post}, - Json, Router, - }; - use base64::Engine; - use once_cell::sync::Lazy; - use serde_json::json; - use serial_test::serial; - #[cfg(any())] - use std::collections::VecDeque; - use std::path::{Path, PathBuf}; - #[cfg(any())] - use std::sync::Arc; - use std::sync::{Mutex, MutexGuard}; - #[cfg(any())] - use tokio::net::TcpListener; - - static CLOUD_EXTRA_TEST_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); - - struct GlobalConfigGuard { - _command_lock: TestFileLock, - _config_lock: TestFileLock, - previous_config: Option, - previous_home: Option, - #[cfg(target_os = "windows")] - previous_appdata: Option, - #[cfg(target_os = "windows")] - previous_userprofile: Option, - _temp_home: tempfile::TempDir, - } - - impl GlobalConfigGuard { - fn with_config(config: crate::types::GlobalConfig) -> Self { - let command_lock = TestFileLock::acquire("worktree-manager-command-test-global-lock"); - let config_lock = TestFileLock::acquire("worktree-manager-global-config-cache.lock"); - let temp_home = tempfile::tempdir().expect("create temp cloud config home"); - let previous_home = std::env::var("HOME").ok(); - #[cfg(target_os = "windows")] - let previous_appdata = std::env::var("APPDATA").ok(); - #[cfg(target_os = "windows")] - let previous_userprofile = std::env::var("USERPROFILE").ok(); - set_config_root(temp_home.path()); - let previous_config = { - let mut cache = crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::replace(&mut *cache, Some(config)) - }; - - Self { - _command_lock: command_lock, - _config_lock: config_lock, - previous_config, - previous_home, - #[cfg(target_os = "windows")] - previous_appdata, - #[cfg(target_os = "windows")] - previous_userprofile, - _temp_home: temp_home, - } - } - } - - struct TestFileLock { - path: PathBuf, - } - - impl TestFileLock { - fn acquire(name: &str) -> Self { - let path = std::env::temp_dir().join(name); - loop { - match std::fs::create_dir(&path) { - Ok(()) => return Self { path }, - Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { - std::thread::sleep(std::time::Duration::from_millis(10)); - } - Err(err) => panic!("failed to acquire cloud test lock {:?}: {}", path, err), - } - } - } - } - - impl Drop for TestFileLock { - fn drop(&mut self) { - let _ = std::fs::remove_dir(&self.path); - } - } - - impl Drop for GlobalConfigGuard { - fn drop(&mut self) { - let mut cache = crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = self.previous_config.take(); - restore_env_var("HOME", &self.previous_home); - #[cfg(target_os = "windows")] - { - restore_env_var("APPDATA", &self.previous_appdata); - restore_env_var("USERPROFILE", &self.previous_userprofile); - } - } - } - - fn lock_cloud_tests() -> MutexGuard<'static, ()> { - CLOUD_EXTRA_TEST_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - } - - fn set_config_root(path: &Path) { - #[cfg(target_os = "windows")] - { - std::env::set_var("APPDATA", path); - std::env::remove_var("USERPROFILE"); - } - #[cfg(not(target_os = "windows"))] - { - std::env::set_var("HOME", path); - } - } - - fn restore_env_var(key: &str, value: &Option) { - match value { - Some(previous) => std::env::set_var(key, previous), - None => std::env::remove_var(key), - } - } - - fn token_with_claims(claims: serde_json::Value) -> String { - let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(serde_json::to_vec(&claims).expect("serialize claims")); - format!("header.{}.signature", payload) - } - - struct PairingStateGuard { - previous: Option, - } - - impl PairingStateGuard { - fn isolated() -> Self { - let previous = { - let mut state = PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state.take() - }; - Self { previous } - } - - fn set_pending(&self, code: &str, device_secret: &str, server_url: &str) { - let mut state = PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *state = Some(PairingState { - code: code.to_string(), - device_secret: device_secret.to_string(), - server_url: server_url.to_string(), - }); - } - } - - impl Drop for PairingStateGuard { - fn drop(&mut self) { - let mut state = PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *state = self.previous.take(); - } - } - - // Local mock-server coverage is intentionally compiled out in this sandbox: - // binding 127.0.0.1:0 returns PermissionDenied, while the real cloud endpoint is external. - #[cfg(any())] - #[derive(Clone)] - struct MockResponse { - status: StatusCode, - body: &'static str, - } - - #[cfg(any())] - #[derive(Default)] - struct MockCloudCommandState { - me_responses: VecDeque, - status_responses: VecDeque, - approve_responses: VecDeque, - reject_responses: VecDeque, - authorizations: Vec, - request_paths: Vec, - request_bodies: Vec, - } - - #[cfg(any())] - fn header_string(headers: &HeaderMap, name: &str) -> String { - headers - .get(name) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string() - } - - #[cfg(any())] - async fn spawn_cloud_command_server(state: Arc>) -> String { - let app = Router::new() - .route( - "/api/me", - get( - |headers: HeaderMap, - State(state): State>>| async move { - let response = { - let mut state = state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state - .authorizations - .push(header_string(&headers, "authorization")); - state.me_responses.pop_front().expect("queued me response") - }; - Response::from((response.status, response.body).into_response()) - }, - ), - ) - .route( - "/api/device-codes/status", - post( - |State(state): State>>, - Json(body): Json| async move { - let response = { - let mut state = state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state.request_paths.push("/api/device-codes/status".to_string()); - state.request_bodies.push(body); - state - .status_responses - .pop_front() - .expect("queued status response") - }; - Response::from((response.status, response.body).into_response()) - }, - ), - ) - .route( - "/api/device-codes/{code}/approve", - post( - |AxumPath(code): AxumPath, - State(state): State>>, - Json(body): Json| async move { - let response = { - let mut state = state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state - .request_paths - .push(format!("/api/device-codes/{code}/approve")); - state.request_bodies.push(body); - state - .approve_responses - .pop_front() - .expect("queued approve response") - }; - Response::from((response.status, response.body).into_response()) - }, - ), - ) - .route( - "/api/device-codes/{code}/reject", - post( - |AxumPath(code): AxumPath, - State(state): State>>, - Json(body): Json| async move { - let response = { - let mut state = state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state - .request_paths - .push(format!("/api/device-codes/{code}/reject")); - state.request_bodies.push(body); - state - .reject_responses - .pop_front() - .expect("queued reject response") - }; - Response::from((response.status, response.body).into_response()) - }, - ), - ) - .with_state(state); - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("bind cloud command mock server"); - let addr = listener.local_addr().expect("cloud command mock addr"); - tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - format!("http://{}", addr) - } - - fn cloud_config( - server_url: Option<&str>, - access_token: Option<&str>, - refresh_token: Option<&str>, - ) -> crate::types::GlobalConfig { - let mut config = crate::types::GlobalConfig::default(); - config.cloud.server_url = server_url.map(ToOwned::to_owned); - config.cloud.access_token = access_token.map(ToOwned::to_owned); - config.cloud.refresh_token = refresh_token.map(ToOwned::to_owned); - config - } - - #[serial] - #[test] - fn decode_jwt_claims_handles_mod_one_payload_and_missing_sub() { - assert_eq!(decode_jwt_claims("header.a.signature"), None); - - let token = token_with_claims(json!({ "exp": 4_102_444_800u64 })); - - assert_eq!( - decode_jwt_claims(&token), - Some(("2100-01-01T00:00:00+00:00".to_string(), String::new())) - ); - } - - #[serial] - #[tokio::test] - async fn cloud_get_status_reports_disconnected_and_connected_token_states() { - let _lock = lock_cloud_tests(); - - let _config = - GlobalConfigGuard::with_config(cloud_config(Some("https://cloud.example"), None, None)); - let disconnected = cloud_get_status().await.expect("status without token"); - assert!(!disconnected.connected); - assert_eq!( - disconnected.server_url.as_deref(), - Some("https://cloud.example") - ); - assert_eq!(disconnected.token_expires_at, None); - drop(_config); - - let valid_token = token_with_claims(json!({ "exp": 4_102_444_800u64, "sub": "user-123" })); - let _config = GlobalConfigGuard::with_config(cloud_config(None, Some(&valid_token), None)); - let connected = cloud_get_status().await.expect("connected status"); - assert!(connected.connected); - assert_eq!( - connected.token_expires_at.as_deref(), - Some("2100-01-01T00:00:00+00:00") - ); - assert_eq!(connected.user_email, None); - drop(_config); - - let _config = GlobalConfigGuard::with_config(cloud_config(None, Some("not.jwt"), None)); - let malformed = cloud_get_status() - .await - .expect("connected malformed token status"); - assert!(malformed.connected); - assert_eq!(malformed.token_expires_at, None); - } - - #[serial] - #[test] - fn device_code_status_json_covers_terminal_states() { - for status in ["pending", "approved", "rejected", "expired"] { - let value = json!({ - "status": status, - "access_token": if status == "approved" { Some("access") } else { None }, - "refresh_token": if status == "approved" { Some("refresh") } else { None }, - "user_email": if status == "approved" { Some("user@example.com") } else { None }, - }); - - let decoded: DeviceCodeStatusResponse = - serde_json::from_value(value).expect("parse device-code status"); - - assert_eq!(decoded.status, status); - if status == "approved" { - assert_eq!(decoded.access_token.as_deref(), Some("access")); - assert_eq!(decoded.refresh_token.as_deref(), Some("refresh")); - assert_eq!(decoded.user_email.as_deref(), Some("user@example.com")); - } else { - assert_eq!(decoded.access_token, None); - assert_eq!(decoded.refresh_token, None); - assert_eq!(decoded.user_email, None); - } - } - } - - #[serial] - #[test] - fn jwt_claim_matrix_covers_padding_subject_and_expiry_variants() { - let valid_cases = [ - ( - json!({ "exp": 0u64, "sub": "epoch-user" }), - "1970-01-01T00:00:00+00:00", - "epoch-user", - ), - ( - json!({ "exp": 946_684_800u64, "sub": "y2k-user" }), - "2000-01-01T00:00:00+00:00", - "y2k-user", - ), - ( - json!({ "exp": 1_577_836_800u64, "sub": "twenty-twenty" }), - "2020-01-01T00:00:00+00:00", - "twenty-twenty", - ), - ( - json!({ "exp": 1_735_689_600u64, "sub": "twenty-twenty-five" }), - "2025-01-01T00:00:00+00:00", - "twenty-twenty-five", - ), - ( - json!({ "exp": 4_102_444_800u64, "sub": "future-user" }), - "2100-01-01T00:00:00+00:00", - "future-user", - ), - ( - json!({ "exp": 4_102_444_800u64 }), - "2100-01-01T00:00:00+00:00", - "", - ), - ( - json!({ "exp": 4_102_444_800u64, "sub": null }), - "2100-01-01T00:00:00+00:00", - "", - ), - ( - json!({ "exp": 4_102_444_800u64, "sub": 12345 }), - "2100-01-01T00:00:00+00:00", - "", - ), - ( - json!({ "exp": 4_102_444_800u64, "sub": true }), - "2100-01-01T00:00:00+00:00", - "", - ), - ]; - - for (claims, expected_exp, expected_sub) in valid_cases { - let token = token_with_claims(claims); - - let decoded = decode_jwt_claims(&token).expect("valid claim set decodes"); - - assert_eq!(decoded.0, expected_exp); - assert_eq!(decoded.1, expected_sub); - } - - let invalid_payloads = [ - "not-a-jwt", - "header.", - "header..signature", - "header.%%%%.signature", - "header.a.signature", - "header.W10.signature", - "header.e30.signature", - ]; - - for token in invalid_payloads { - assert_eq!(decode_jwt_claims(token), None, "{token}"); - } - - let invalid_claims = [ - json!({}), - json!({ "exp": null }), - json!({ "exp": -1 }), - json!({ "exp": 1.25 }), - json!({ "exp": "4102444800" }), - json!({ "exp": [], "sub": "array-exp" }), - json!({ "exp": {}, "sub": "object-exp" }), - ]; - - for claims in invalid_claims { - let token = token_with_claims(claims); - - assert_eq!(decode_jwt_claims(&token), None); - } - } - - #[serial] - #[tokio::test] - async fn pairing_commands_with_bad_urls_preserve_or_clear_state_as_expected() { - let _lock = lock_cloud_tests(); - let pairing = PairingStateGuard::isolated(); - let _config = GlobalConfigGuard::with_config(cloud_config( - Some("://bad-url"), - Some("old-access"), - Some("old-refresh"), - )); - - pairing.set_pending("PAIR-STATUS", "secret-status", "://bad-url"); - let status_error = cloud_check_pairing_status() - .await - .expect_err("bad status url fails before network"); - assert!(status_error.starts_with("请求失败:"), "{status_error}"); - assert!(PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_some()); - - pairing.set_pending("PAIR-APPROVE", "secret-approve", "://bad-url"); - let approve_error = cloud_approve_pairing() - .await - .expect_err("bad approve url fails before network"); - assert!(approve_error.starts_with("请求失败:"), "{approve_error}"); - assert!(PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_some()); - - pairing.set_pending("PAIR-REJECT", "secret-reject", "://bad-url"); - let reject_error = cloud_reject_pairing() - .await - .expect_err("bad reject url fails before network"); - assert!(reject_error.starts_with("请求失败:"), "{reject_error}"); - assert!(PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_some()); - - cloud_disconnect() - .await - .expect("disconnect clears local cloud state"); - let config = crate::config::load_global_config(); - assert_eq!(config.cloud.access_token, None); - assert_eq!(config.cloud.refresh_token, None); - assert!(PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_none()); - } - - #[serial] - #[test] - fn response_type_matrix_deserializes_optional_cloud_fields() { - let device_code_cases = [ - ( - json!({ - "code": "PAIR-001", - "device_secret": "secret-001" - }), - "PAIR-001", - "secret-001", - None, - None, - ), - ( - json!({ - "code": "PAIR-002", - "device_secret": "secret-002", - "expires_in": 60 - }), - "PAIR-002", - "secret-002", - Some(60), - None, - ), - ( - json!({ - "code": "PAIR-003", - "device_secret": "secret-003", - "poll_interval": 5 - }), - "PAIR-003", - "secret-003", - None, - Some(5), - ), - ( - json!({ - "code": "PAIR-004", - "device_secret": "secret-004", - "expires_in": 600, - "poll_interval": 3 - }), - "PAIR-004", - "secret-004", - Some(600), - Some(3), - ), - ]; - - for (value, code, secret, expires_in, poll_interval) in device_code_cases { - let decoded: DeviceCodeResponse = - serde_json::from_value(value).expect("device code response parses"); - - assert_eq!(decoded.code, code); - assert_eq!(decoded.device_secret, secret); - assert_eq!(decoded.expires_in, expires_in); - assert_eq!(decoded.poll_interval, poll_interval); - } - - let approve_cases = [ - (json!({}), None, None, None), - ( - json!({ "access_token": "access-only" }), - Some("access-only"), - None, - None, - ), - ( - json!({ "refresh_token": "refresh-only" }), - None, - Some("refresh-only"), - None, - ), - ( - json!({ "user_email": "user@example.com" }), - None, - None, - Some("user@example.com"), - ), - ( - json!({ - "access_token": "access-full", - "refresh_token": "refresh-full", - "user_email": "full@example.com" - }), - Some("access-full"), - Some("refresh-full"), - Some("full@example.com"), - ), - ]; - - for (value, access, refresh, email) in approve_cases { - let decoded: ApproveResponse = - serde_json::from_value(value).expect("approve response parses"); - - assert_eq!(decoded.access_token.as_deref(), access); - assert_eq!(decoded.refresh_token.as_deref(), refresh); - assert_eq!(decoded.user_email.as_deref(), email); - } - } - - #[cfg(any())] - #[serial] - #[tokio::test] - async fn cloud_get_status_fetches_user_info_and_tolerates_bad_me_responses() { - let _lock = lock_cloud_tests(); - let _pairing = PairingStateGuard::isolated(); - let token = token_with_claims(json!({ "exp": 4_102_444_800u64, "sub": "user-123" })); - let state = Arc::new(Mutex::new(MockCloudCommandState { - me_responses: VecDeque::from([MockResponse { - status: StatusCode::OK, - body: r#"{"email":"user@example.com","username":"tester"}"#, - }]), - ..MockCloudCommandState::default() - })); - let server_url = spawn_cloud_command_server(state.clone()).await; - let _config = - GlobalConfigGuard::with_config(cloud_config(Some(&server_url), Some(&token), None)); - - let status = cloud_get_status().await.expect("status with /api/me"); - - assert!(status.connected); - assert!(!status.pairing); - assert_eq!(status.server_url.as_deref(), Some(server_url.as_str())); - assert_eq!(status.user_email.as_deref(), Some("user@example.com")); - assert_eq!(status.username.as_deref(), Some("tester")); - assert_eq!( - status.token_expires_at.as_deref(), - Some("2100-01-01T00:00:00+00:00") - ); - assert_eq!( - state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .authorizations, - vec![format!("Bearer {token}")] - ); - drop(_config); - - let bad_state = Arc::new(Mutex::new(MockCloudCommandState { - me_responses: VecDeque::from([MockResponse { - status: StatusCode::OK, - body: "not-json", - }]), - ..MockCloudCommandState::default() - })); - let bad_server_url = spawn_cloud_command_server(bad_state).await; - let _config = - GlobalConfigGuard::with_config(cloud_config(Some(&bad_server_url), Some(&token), None)); - - let bad_status = cloud_get_status() - .await - .expect("status ignores malformed me"); - - assert!(bad_status.connected); - assert_eq!(bad_status.user_email, None); - assert_eq!(bad_status.username, None); - } - - #[cfg(any())] - #[serial] - #[tokio::test] - async fn cloud_check_pairing_status_approved_persists_tokens_and_clears_pairing() { - let _lock = lock_cloud_tests(); - let pairing = PairingStateGuard::isolated(); - let state = Arc::new(Mutex::new(MockCloudCommandState { - status_responses: VecDeque::from([MockResponse { - status: StatusCode::OK, - body: r#"{"status":"approved","access_token":"access-new","refresh_token":"refresh-new","user_email":"user@example.com"}"#, - }]), - ..MockCloudCommandState::default() - })); - let server_url = spawn_cloud_command_server(state.clone()).await; - let _config = GlobalConfigGuard::with_config(cloud_config(Some(&server_url), None, None)); - pairing.set_pending("PAIR-1", "secret-1", &server_url); - - let response = cloud_check_pairing_status() - .await - .expect("approved pairing status"); - - let config = crate::config::load_global_config(); - let state = state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - assert_eq!(response.status, "approved"); - assert_eq!(response.access_token.as_deref(), Some("access-new")); - assert_eq!(response.refresh_token.as_deref(), Some("refresh-new")); - assert_eq!(config.cloud.access_token.as_deref(), Some("access-new")); - assert_eq!(config.cloud.refresh_token.as_deref(), Some("refresh-new")); - assert_eq!(state.request_paths, vec!["/api/device-codes/status"]); - assert_eq!(state.request_bodies[0]["code"], "PAIR-1"); - assert_eq!(state.request_bodies[0]["device_secret"], "secret-1"); - assert!(PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_none()); - } - - #[cfg(any())] - #[serial] - #[tokio::test] - async fn cloud_check_pairing_status_reports_http_and_parse_errors_without_clearing_pairing() { - let _lock = lock_cloud_tests(); - let pairing = PairingStateGuard::isolated(); - let state = Arc::new(Mutex::new(MockCloudCommandState { - status_responses: VecDeque::from([MockResponse { - status: StatusCode::BAD_REQUEST, - body: "bad device code", - }]), - ..MockCloudCommandState::default() - })); - let server_url = spawn_cloud_command_server(state).await; - let _config = GlobalConfigGuard::with_config(cloud_config(Some(&server_url), None, None)); - pairing.set_pending("PAIR-2", "secret-2", &server_url); - - let http_err = cloud_check_pairing_status().await.unwrap_err(); - - assert!(http_err.contains("400 Bad Request")); - assert!(http_err.contains("bad device code")); - assert!(PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_some()); - drop(_config); - - let malformed_state = Arc::new(Mutex::new(MockCloudCommandState { - status_responses: VecDeque::from([MockResponse { - status: StatusCode::OK, - body: "not-json", - }]), - ..MockCloudCommandState::default() - })); - let malformed_server_url = spawn_cloud_command_server(malformed_state).await; - let _config = - GlobalConfigGuard::with_config(cloud_config(Some(&malformed_server_url), None, None)); - pairing.set_pending("PAIR-3", "secret-3", &malformed_server_url); - - let parse_err = cloud_check_pairing_status().await.unwrap_err(); - - assert!(parse_err.starts_with("解析响应失败:")); - assert!(PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_some()); - } - - #[cfg(any())] - #[serial] - #[tokio::test] - async fn cloud_approve_pairing_stores_tokens_and_cloud_reject_clears_pairing() { - let _lock = lock_cloud_tests(); - let pairing = PairingStateGuard::isolated(); - let state = Arc::new(Mutex::new(MockCloudCommandState { - approve_responses: VecDeque::from([MockResponse { - status: StatusCode::OK, - body: r#"{"access_token":"approved-access","refresh_token":"approved-refresh","user_email":"approver@example.com"}"#, - }]), - reject_responses: VecDeque::from([MockResponse { - status: StatusCode::NO_CONTENT, - body: "", - }]), - ..MockCloudCommandState::default() - })); - let server_url = spawn_cloud_command_server(state.clone()).await; - let _config = GlobalConfigGuard::with_config(cloud_config(Some(&server_url), None, None)); - pairing.set_pending("PAIR-4", "secret-4", &server_url); - - let approved = cloud_approve_pairing().await.expect("approve pairing"); - let config = crate::config::load_global_config(); - - assert_eq!(approved.access_token.as_deref(), Some("approved-access")); - assert_eq!(approved.refresh_token.as_deref(), Some("approved-refresh")); - assert_eq!(approved.user_email.as_deref(), Some("approver@example.com")); - assert_eq!( - config.cloud.access_token.as_deref(), - Some("approved-access") - ); - assert_eq!( - config.cloud.refresh_token.as_deref(), - Some("approved-refresh") - ); - assert!(PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_none()); - - pairing.set_pending("PAIR-5", "secret-5", &server_url); - cloud_reject_pairing().await.expect("reject pairing"); - - let state = state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - assert_eq!( - state.request_paths, - vec![ - "/api/device-codes/PAIR-4/approve".to_string(), - "/api/device-codes/PAIR-5/reject".to_string() - ] - ); - assert_eq!(state.request_bodies[0]["device_secret"], "secret-4"); - assert_eq!(state.request_bodies[1]["device_secret"], "secret-5"); - assert!(PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_none()); - } - - #[cfg(any())] - #[serial] - #[tokio::test] - async fn cloud_reject_pairing_reports_server_error_and_disconnect_clears_tokens_and_pairing() { - let _lock = lock_cloud_tests(); - let pairing = PairingStateGuard::isolated(); - let state = Arc::new(Mutex::new(MockCloudCommandState { - reject_responses: VecDeque::from([MockResponse { - status: StatusCode::INTERNAL_SERVER_ERROR, - body: "reject failed", - }]), - ..MockCloudCommandState::default() - })); - let server_url = spawn_cloud_command_server(state).await; - let _config = GlobalConfigGuard::with_config(cloud_config( - Some(&server_url), - Some("access-old"), - Some("refresh-old"), - )); - pairing.set_pending("PAIR-6", "secret-6", &server_url); - - let reject_err = cloud_reject_pairing().await.unwrap_err(); - - assert!(reject_err.contains("500 Internal Server Error")); - assert!(reject_err.contains("reject failed")); - assert!(PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_some()); - - cloud_disconnect().await.expect("disconnect clears tokens"); - let config = crate::config::load_global_config(); - - assert_eq!(config.cloud.access_token, None); - assert_eq!(config.cloud.refresh_token, None); - assert!(PAIRING_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_none()); - } -} diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs deleted file mode 100644 index d8edb36..0000000 --- a/src-tauri/src/commands/config.rs +++ /dev/null @@ -1,328 +0,0 @@ -use crate::config::{load_global_config, save_global_config_internal}; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Serialize, Deserialize)] -pub struct CommitPrefixConfig { - pub templates: Vec, - pub enabled: bool, - pub default_index: usize, -} - -#[tauri::command] -pub(crate) fn get_commit_prefix_config() -> Result { - let config = load_global_config(); - log::info!( - "[config] get_commit_prefix_config: default_index={}, templates={:?}", - config.default_prefix_index, - config.commit_prefix_templates - ); - Ok(CommitPrefixConfig { - templates: config.commit_prefix_templates, - enabled: config.commit_prefix_enabled, - default_index: config.default_prefix_index, - }) -} - -#[tauri::command] -pub(crate) fn set_commit_prefix_config( - templates: Vec, - enabled: bool, - default_index: usize, -) -> Result<(), String> { - let mut config = load_global_config(); - log::info!( - "[config] set_commit_prefix_config: old default={}, new default={}", - config.default_prefix_index, - default_index - ); - config.commit_prefix_templates = templates.into_iter().take(3).collect(); - config.commit_prefix_enabled = enabled; - config.default_prefix_index = default_index; - save_global_config_internal(&config)?; - log::info!("[config] set_commit_prefix_config: saved ok"); - Ok(()) -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct GitUserGlobalConfig { - pub name: Option, - pub email: Option, -} - -#[tauri::command] -pub(crate) fn get_git_user_global_config() -> Result { - let config = load_global_config(); - Ok(GitUserGlobalConfig { - name: config.git_user_name, - email: config.git_user_email, - }) -} - -#[tauri::command] -pub(crate) fn set_git_user_global_config( - name: Option, - email: Option, -) -> Result<(), String> { - let mut config = load_global_config(); - config.git_user_name = name; - config.git_user_email = email; - save_global_config_internal(&config) -} - -#[tauri::command] -pub(crate) fn get_skip_git_hooks() -> Result { - let config = load_global_config(); - Ok(config.skip_git_hooks) -} - -#[tauri::command] -pub(crate) fn set_skip_git_hooks(skip: bool) -> Result<(), String> { - let mut config = load_global_config(); - config.skip_git_hooks = skip; - save_global_config_internal(&config) -} - -#[tauri::command] -pub(crate) fn get_shell_integration_enabled() -> Result { - let config = load_global_config(); - Ok(config.shell_integration_enabled) -} - -#[tauri::command] -pub(crate) fn set_shell_integration_enabled(enabled: bool) -> Result<(), String> { - let mut config = load_global_config(); - config.shell_integration_enabled = enabled; - save_global_config_internal(&config) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::state::GLOBAL_CONFIG_CACHE; - use crate::types::GlobalConfig; - use serial_test::serial; - use std::path::{Path, PathBuf}; - use std::time::Duration; - - struct NamedTestLock { - path: PathBuf, - } - - impl NamedTestLock { - fn acquire() -> Self { - let path = std::env::temp_dir().join("worktree-manager-command-test-global-lock"); - loop { - match std::fs::create_dir(&path) { - Ok(()) => return Self { path }, - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { - std::thread::sleep(Duration::from_millis(10)); - } - Err(e) => panic!("failed to acquire test lock at {:?}: {}", path, e), - } - } - } - } - - impl Drop for NamedTestLock { - fn drop(&mut self) { - let _ = std::fs::remove_dir(&self.path); - } - } - - struct ConfigCommandStateGuard { - _lock: NamedTestLock, - previous_global: Option, - previous_home: Option, - #[cfg(target_os = "windows")] - previous_appdata: Option, - #[cfg(target_os = "windows")] - previous_userprofile: Option, - _temp_dir: tempfile::TempDir, - } - - impl ConfigCommandStateGuard { - fn with_temp_config(config: GlobalConfig) -> Self { - let lock = NamedTestLock::acquire(); - let temp_dir = tempfile::tempdir().expect("create temp config home"); - let previous_home = std::env::var("HOME").ok(); - #[cfg(target_os = "windows")] - let previous_appdata = std::env::var("APPDATA").ok(); - #[cfg(target_os = "windows")] - let previous_userprofile = std::env::var("USERPROFILE").ok(); - - set_config_root(temp_dir.path()); - let previous_global = replace_global_cache(Some(config)); - - Self { - _lock: lock, - previous_global, - previous_home, - #[cfg(target_os = "windows")] - previous_appdata, - #[cfg(target_os = "windows")] - previous_userprofile, - _temp_dir: temp_dir, - } - } - - fn with_unwritable_config_root(config: GlobalConfig) -> Self { - let lock = NamedTestLock::acquire(); - let temp_dir = tempfile::tempdir().expect("create temp config parent"); - let file_root = temp_dir.path().join("not-a-directory"); - std::fs::write(&file_root, "file blocks config dir").expect("write file root"); - - let previous_home = std::env::var("HOME").ok(); - #[cfg(target_os = "windows")] - let previous_appdata = std::env::var("APPDATA").ok(); - #[cfg(target_os = "windows")] - let previous_userprofile = std::env::var("USERPROFILE").ok(); - - set_config_root(&file_root); - let previous_global = replace_global_cache(Some(config)); - - Self { - _lock: lock, - previous_global, - previous_home, - #[cfg(target_os = "windows")] - previous_appdata, - #[cfg(target_os = "windows")] - previous_userprofile, - _temp_dir: temp_dir, - } - } - } - - impl Drop for ConfigCommandStateGuard { - fn drop(&mut self) { - restore_env_var("HOME", &self.previous_home); - #[cfg(target_os = "windows")] - { - restore_env_var("APPDATA", &self.previous_appdata); - restore_env_var("USERPROFILE", &self.previous_userprofile); - } - let _ = replace_global_cache(self.previous_global.take()); - } - } - - fn replace_global_cache(config: Option) -> Option { - let mut cache = GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::replace(&mut *cache, config) - } - - fn set_config_root(path: &Path) { - #[cfg(target_os = "windows")] - { - std::env::set_var("APPDATA", path); - std::env::remove_var("USERPROFILE"); - } - #[cfg(not(target_os = "windows"))] - { - std::env::set_var("HOME", path); - } - } - - fn restore_env_var(key: &str, value: &Option) { - match value { - Some(previous) => std::env::set_var(key, previous), - None => std::env::remove_var(key), - } - } - - #[serial] - #[test] - fn get_commit_prefix_config_reads_cached_global_values() { - let config = GlobalConfig { - commit_prefix_templates: vec!["feat:".to_string(), "fix:".to_string()], - commit_prefix_enabled: false, - default_prefix_index: 1, - ..GlobalConfig::default() - }; - let _guard = ConfigCommandStateGuard::with_temp_config(config); - - let prefix = get_commit_prefix_config().expect("read prefix config"); - - assert_eq!(prefix.templates, vec!["feat:", "fix:"]); - assert!(!prefix.enabled); - assert_eq!(prefix.default_index, 1); - } - - #[serial] - #[test] - fn set_commit_prefix_config_truncates_to_three_templates_and_persists() { - let _guard = ConfigCommandStateGuard::with_temp_config(GlobalConfig::default()); - let config_path = crate::config::get_global_config_path(); - - set_commit_prefix_config( - vec![ - "one".to_string(), - "two".to_string(), - "three".to_string(), - "four".to_string(), - ], - false, - 2, - ) - .expect("save prefix config"); - let saved: GlobalConfig = serde_json::from_str( - &std::fs::read_to_string(config_path).expect("read saved global config"), - ) - .expect("parse saved global config"); - - assert_eq!(saved.commit_prefix_templates, vec!["one", "two", "three"]); - assert!(!saved.commit_prefix_enabled); - assert_eq!(saved.default_prefix_index, 2); - } - - #[serial] - #[test] - fn git_user_config_round_trips_through_global_config_file() { - let _guard = ConfigCommandStateGuard::with_temp_config(GlobalConfig::default()); - let config_path = crate::config::get_global_config_path(); - - set_git_user_global_config( - Some("Test User".to_string()), - Some("test@example.com".to_string()), - ) - .expect("save git user"); - let saved: GlobalConfig = serde_json::from_str( - &std::fs::read_to_string(config_path).expect("read saved global config"), - ) - .expect("parse saved global config"); - - assert_eq!(saved.git_user_name.as_deref(), Some("Test User")); - assert_eq!(saved.git_user_email.as_deref(), Some("test@example.com")); - } - - #[serial] - #[test] - fn boolean_config_commands_toggle_and_persist_values() { - let _guard = ConfigCommandStateGuard::with_temp_config(GlobalConfig::default()); - let config_path = crate::config::get_global_config_path(); - - set_skip_git_hooks(true).expect("enable skip hooks"); - set_shell_integration_enabled(false).expect("disable shell integration"); - let saved: GlobalConfig = serde_json::from_str( - &std::fs::read_to_string(config_path).expect("read saved global config"), - ) - .expect("parse saved global config"); - - assert!(saved.skip_git_hooks); - assert!(!saved.shell_integration_enabled); - } - - #[serial] - #[test] - fn write_commands_return_error_when_config_directory_cannot_be_created() { - let _guard = ConfigCommandStateGuard::with_unwritable_config_root(GlobalConfig::default()); - - let err = set_skip_git_hooks(true).expect_err("save should fail"); - - assert!( - err.contains("Failed to create config directory"), - "unexpected error: {err}" - ); - } -} diff --git a/src-tauri/src/commands/git.rs b/src-tauri/src/commands/git.rs deleted file mode 100644 index 24933ae..0000000 --- a/src-tauri/src/commands/git.rs +++ /dev/null @@ -1,1792 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use crate::config::{get_window_workspace_config, save_workspace_config_internal}; -use crate::git_ops; -use crate::types::{CloneProjectRequest, ProjectConfig, SwitchBranchRequest}; -use crate::utils::{ - friendly_fs_error, git_command, mask_url_credentials, normalize_path, parse_repo_url, - validate_git_ref_name, -}; - -// ==================== Helper: spawn_blocking wrapper ==================== - -/// Run a blocking closure on tokio's blocking threadpool, converting JoinError to String. -async fn blocking(f: F) -> Result -where - F: FnOnce() -> Result + Send + 'static, - T: Send + 'static, -{ - tokio::task::spawn_blocking(f) - .await - .map_err(|e| format!("Task join error: {}", e))? -} - -fn log_git_command_error( - operation: &str, - path: &str, - result: Result, -) -> Result { - if let Err(error) = &result { - let error_for_log = mask_url_credentials(error); - log::error!( - "[git-command] operation='{}', path='{}', error='{}'", - operation, - path, - error_for_log - ); - } - result -} - -// ==================== Tauri 命令:Git 操作 ==================== - -fn switch_branch_sync(request: SwitchBranchRequest) -> Result<(), String> { - log::info!( - "[git] Switching branch: path='{}', target='{}'", - request.project_path, - request.branch - ); - let path = PathBuf::from(&request.project_path); - - if !path.exists() { - log::error!( - "[git] Project path does not exist: {}", - request.project_path - ); - return Err(format!( - "Project path does not exist: {}", - request.project_path - )); - } - - // Step 1: Fetch to ensure we have latest refs - log::info!("[git] Step 1/3: git fetch origin"); - let fetch_output = git_command() - .args(["fetch", "origin"]) - .current_dir(&path) - .output() - .map_err(|e| format!("Failed to fetch: {}", e))?; - - if !fetch_output.status.success() { - let stderr_for_log = mask_url_credentials(&String::from_utf8_lossy(&fetch_output.stderr)); - // Fetch failure is not critical, continue with checkout - log::warn!( - "[git] Step 1/3: git fetch failed (non-critical), continuing: {}", - stderr_for_log - ); - } else { - log::info!("[git] Step 1/3: git fetch origin succeeded"); - } - - // Step 2: Checkout the branch - log::info!("[git] Step 2/3: git checkout {}", request.branch); - let checkout_output = git_command() - .args(["checkout", &request.branch]) - .current_dir(&path) - .output() - .map_err(|e| format!("Failed to checkout: {}", e))?; - - if !checkout_output.status.success() { - let stderr = String::from_utf8_lossy(&checkout_output.stderr); - let stderr_for_log = mask_url_credentials(&stderr); - log::error!( - "[git] Step 2/3 FAILED: git checkout {}: {}", - request.branch, - stderr_for_log - ); - return Err(format!("Failed to checkout {}: {}", request.branch, stderr)); - } - log::info!("[git] Step 2/3: git checkout {} succeeded", request.branch); - - // Step 3: Pull latest changes - log::info!("[git] Step 3/3: git pull origin {}", request.branch); - let pull_output = git_command() - .args(["pull", "origin", &request.branch]) - .current_dir(&path) - .output() - .map_err(|e| format!("Failed to pull: {}", e))?; - - if !pull_output.status.success() { - let stderr = String::from_utf8_lossy(&pull_output.stderr); - let stderr_for_log = mask_url_credentials(&stderr); - log::warn!( - "[git] Step 3/3: git pull failed (non-critical): {}", - stderr_for_log - ); - } else { - log::info!( - "[git] Step 3/3: git pull origin {} succeeded", - request.branch - ); - } - - log::info!( - "[git] Successfully switched to branch '{}' at '{}'", - request.branch, - request.project_path - ); - Ok(()) -} - -#[tauri::command] -pub(crate) async fn switch_branch(request: SwitchBranchRequest) -> Result<(), String> { - validate_git_ref_name(&request.branch)?; - let path = request.project_path.clone(); - let result = blocking(move || switch_branch_sync(request)).await; - log_git_command_error("switch_branch", &path, result) -} - -pub fn clone_project_impl(window_label: &str, request: CloneProjectRequest) -> Result<(), String> { - validate_git_ref_name(&request.base_branch)?; - validate_git_ref_name(&request.test_branch)?; - - let (workspace_path, mut config) = - get_window_workspace_config(window_label).ok_or("No workspace selected")?; - - let projects_path = PathBuf::from(&workspace_path).join("projects"); - let target_path = projects_path.join(&request.name); - - // Sanitize URL for logging (may contain tokens) - let safe_url = mask_url_credentials(&request.repo_url); - - log::info!( - "[git] Cloning project: name='{}', url='{}', target='{}', base_branch='{}'", - request.name, - safe_url, - target_path.display(), - request.base_branch - ); - - // Check if project already exists - if target_path.exists() { - log::error!( - "[git] Project '{}' already exists at {}", - request.name, - target_path.display() - ); - return Err(format!("Project '{}' already exists", request.name)); - } - - // Parse repo URL and convert to git-compatible format - let git_url = parse_repo_url(&request.repo_url)?; - - // Step 1: Clone the repository - log::info!("[git] Step 1/3: git clone to {}", target_path.display()); - let clone_output = git_command() - .args(["clone", &git_url, target_path.to_string_lossy().as_ref()]) - .output() - .map_err(|e| format!("Failed to clone repository: {}", e))?; - - if !clone_output.status.success() { - let stderr = String::from_utf8_lossy(&clone_output.stderr); - let stderr_for_log = mask_url_credentials(&stderr); - log::error!("[git] Step 1/3 FAILED: git clone: {}", stderr_for_log); - return Err(format!("Git clone failed: {}", stderr)); - } - log::info!("[git] Step 1/3: git clone succeeded"); - - // Step 2: Checkout base branch if not already on it - log::info!("[git] Step 2/3: git checkout {}", request.base_branch); - let checkout_output = git_command() - .args(["checkout", &request.base_branch]) - .current_dir(&target_path) - .output() - .map_err(|e| format!("Failed to checkout base branch: {}", e))?; - - if !checkout_output.status.success() { - log::warn!( - "[git] Step 2/3: Could not checkout base branch '{}', using default branch", - request.base_branch - ); - } else { - log::info!( - "[git] Step 2/3: Checked out base branch '{}'", - request.base_branch - ); - } - - // Step 3: Add project to config - log::info!( - "[git] Step 3/3: Adding project '{}' to workspace config", - request.name - ); - config.projects.push(ProjectConfig { - name: request.name.clone(), - base_branch: request.base_branch, - test_branch: request.test_branch, - merge_strategy: request.merge_strategy, - linked_folders: request.linked_folders, - commit_prefix_index: None, - git_user_name: None, - git_user_email: None, - tags: vec![], - }); - - save_workspace_config_internal(&workspace_path, &config)?; - - log::info!("[git] Successfully cloned project '{}'", request.name); - Ok(()) -} - -#[tauri::command] -pub(crate) async fn clone_project( - window: tauri::Window, - request: CloneProjectRequest, -) -> Result<(), String> { - let label = window.label().to_string(); - blocking(move || clone_project_impl(&label, request)).await -} - -// ==================== 主工作区项目管理 ==================== - -pub fn scan_existing_projects_impl( - window_label: &str, -) -> Result, String> { - let (workspace_path, config) = - get_window_workspace_config(window_label).ok_or("No workspace selected")?; - - let projects_dir = PathBuf::from(&workspace_path).join("projects"); - if !projects_dir.exists() { - return Ok(vec![]); - } - - let registered: std::collections::HashSet = - config.projects.iter().map(|p| p.name.clone()).collect(); - - let mut result = vec![]; - let entries = std::fs::read_dir(&projects_dir) - .map_err(|e| friendly_fs_error("无法读取 projects 目录", &e))?; - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_string(); - - if name.starts_with('.') { - continue; - } - - let is_registered = registered.contains(&name); - - // Check if it's a git repo - if !path.join(".git").exists() { - continue; - } - - // Get current branch - let current_branch = git_command() - .args(["rev-parse", "--abbrev-ref", "HEAD"]) - .current_dir(&path) - .output() - .ok() - .and_then(|o| { - if o.status.success() { - Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) - } else { - None - } - }) - .unwrap_or_else(|| "unknown".to_string()); - - result.push(crate::types::ExistingProjectInfo { - name, - current_branch, - is_registered, - }); - } - - result.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(result) -} - -#[tauri::command] -pub(crate) async fn scan_existing_projects( - window: tauri::Window, -) -> Result, String> { - let label = window.label().to_string(); - blocking(move || scan_existing_projects_impl(&label)).await -} - -pub fn add_existing_project_impl( - window_label: &str, - name: String, - base_branch: String, - test_branch: String, - merge_strategy: String, -) -> Result<(), String> { - validate_git_ref_name(&base_branch)?; - validate_git_ref_name(&test_branch)?; - - let (workspace_path, mut config) = - get_window_workspace_config(window_label).ok_or("No workspace selected")?; - - let projects_dir = PathBuf::from(&workspace_path).join("projects"); - let project_path = projects_dir.join(&name); - - if !project_path.exists() || !project_path.join(".git").exists() { - return Err(format!( - "Project '{}' does not exist or is not a git repository", - name - )); - } - - // Check if already registered - if config.projects.iter().any(|p| p.name == name) { - return Err(format!("Project '{}' is already registered", name)); - } - - log::info!( - "[git] Adding existing project '{}' to config (base={}, test={})", - name, - base_branch, - test_branch - ); - - config.projects.push(ProjectConfig { - name: name.clone(), - base_branch, - test_branch, - merge_strategy, - linked_folders: vec![], - commit_prefix_index: None, - git_user_name: None, - git_user_email: None, - tags: vec![], - }); - - save_workspace_config_internal(&workspace_path, &config)?; - log::info!("[git] Successfully added existing project '{}'", name); - Ok(()) -} - -#[tauri::command] -pub(crate) async fn add_existing_project( - window: tauri::Window, - name: String, - base_branch: String, - test_branch: String, - merge_strategy: String, -) -> Result<(), String> { - let label = window.label().to_string(); - blocking(move || { - add_existing_project_impl(&label, name, base_branch, test_branch, merge_strategy) - }) - .await -} - -// ==================== 导入外部项目到 projects/ ==================== - -pub fn import_external_project_impl( - window_label: &str, - source_path: String, -) -> Result { - let (workspace_path, config) = - get_window_workspace_config(window_label).ok_or("No workspace selected")?; - - let source = PathBuf::from(&source_path); - if !source.exists() { - return Err(format!("Path does not exist: {}", source_path)); - } - if !source.join(".git").exists() { - return Err("Selected folder is not a git repository (no .git found)".to_string()); - } - - let folder_name = source - .file_name() - .and_then(|n| n.to_str()) - .ok_or("Cannot determine folder name from path")? - .to_string(); - - let projects_dir = PathBuf::from(&workspace_path).join("projects"); - let dest = projects_dir.join(&folder_name); - - // Check if already exists in projects/ - if dest.exists() { - let is_registered = config.projects.iter().any(|p| p.name == folder_name); - // Get current branch - let current_branch = git_command() - .args(["rev-parse", "--abbrev-ref", "HEAD"]) - .current_dir(&dest) - .output() - .ok() - .and_then(|o| { - if o.status.success() { - Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) - } else { - None - } - }) - .unwrap_or_else(|| "unknown".to_string()); - return Ok(crate::types::ExistingProjectInfo { - name: folder_name, - current_branch, - is_registered, - }); - } - - // Ensure projects/ dir exists - std::fs::create_dir_all(&projects_dir) - .map_err(|e| friendly_fs_error("无法创建 projects 目录", &e))?; - - // Copy the project directory - log::info!( - "[git] Importing external project from '{}' to '{}'", - source_path, - dest.display() - ); - copy_dir_recursive(&source, &dest).map_err(|e| friendly_fs_error("复制项目失败", &e))?; - - // Get current branch of the copied project - let current_branch = git_command() - .args(["rev-parse", "--abbrev-ref", "HEAD"]) - .current_dir(&dest) - .output() - .ok() - .and_then(|o| { - if o.status.success() { - Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) - } else { - None - } - }) - .unwrap_or_else(|| "unknown".to_string()); - - log::info!( - "[git] Successfully imported project '{}' (branch: {})", - folder_name, - current_branch - ); - - Ok(crate::types::ExistingProjectInfo { - name: folder_name, - current_branch, - is_registered: false, - }) -} - -fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { - std::fs::create_dir_all(dst)?; - for entry in std::fs::read_dir(src)? { - let entry = entry?; - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - if src_path.is_dir() { - copy_dir_recursive(&src_path, &dst_path)?; - } else { - std::fs::copy(&src_path, &dst_path)?; - } - } - Ok(()) -} - -#[tauri::command] -pub(crate) async fn import_external_project( - window: tauri::Window, - source_path: String, -) -> Result { - let label = window.label().to_string(); - blocking(move || import_external_project_impl(&label, source_path)).await -} - -pub fn remove_project_from_config_impl(window_label: &str, name: String) -> Result<(), String> { - let (workspace_path, mut config) = - get_window_workspace_config(window_label).ok_or("No workspace selected")?; - - // Check that the project exists in config - if !config.projects.iter().any(|p| p.name == name) { - return Err(format!("Project '{}' is not in the configuration", name)); - } - - // Check that no worktree references this project - let root = PathBuf::from(&workspace_path); - let worktrees_dir = root.join(&config.worktrees_dir); - let mut referencing_worktrees = vec![]; - - if worktrees_dir.exists() { - if let Ok(entries) = std::fs::read_dir(&worktrees_dir) { - for entry in entries.flatten() { - let wt_path = entry.path(); - if !wt_path.is_dir() { - continue; - } - let wt_name = wt_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_string(); - - // Skip archived worktrees (they end with .archive) - if wt_name.ends_with(".archive") { - continue; - } - - let proj_in_wt = wt_path.join("projects").join(&name); - if proj_in_wt.symlink_metadata().is_ok() { - referencing_worktrees.push(wt_name); - } - } - } - } - - if !referencing_worktrees.is_empty() { - return Err(format!( - "Cannot remove project '{}': it is referenced by worktree(s): {}", - name, - referencing_worktrees.join(", ") - )); - } - - log::info!( - "[git] Removing project '{}' from config (directory NOT deleted)", - name - ); - - config.projects.retain(|p| p.name != name); - save_workspace_config_internal(&workspace_path, &config)?; - - log::info!("[git] Successfully removed project '{}' from config", name); - Ok(()) -} - -#[tauri::command] -pub(crate) async fn remove_project_from_config( - window: tauri::Window, - name: String, -) -> Result<(), String> { - let label = window.label().to_string(); - blocking(move || remove_project_from_config_impl(&label, name)).await -} - -// ==================== Tauri 命令:Git 高级操作 ==================== - -#[tauri::command] -pub(crate) async fn sync_with_base_branch( - path: String, - base_branch: String, -) -> Result { - validate_git_ref_name(&base_branch)?; - let path_for_log = path.clone(); - let result = blocking(move || { - let normalized = normalize_path(&path); - git_ops::sync_with_base_branch(Path::new(&normalized), &base_branch) - }) - .await; - log_git_command_error("sync_with_base_branch", &path_for_log, result) -} - -#[tauri::command] -pub(crate) async fn push_to_remote(path: String) -> Result { - let path_for_log = path.clone(); - let result = blocking(move || { - let normalized = normalize_path(&path); - git_ops::push_to_remote(Path::new(&normalized)) - }) - .await; - log_git_command_error("push_to_remote", &path_for_log, result) -} - -#[tauri::command] -pub(crate) async fn pull_current_branch(path: String) -> Result { - let path_for_log = path.clone(); - let result = blocking(move || { - let normalized = normalize_path(&path); - git_ops::pull_current_branch(Path::new(&normalized)) - }) - .await; - log_git_command_error("pull_current_branch", &path_for_log, result) -} - -#[tauri::command] -pub(crate) async fn merge_to_test_branch( - path: String, - test_branch: String, -) -> Result { - validate_git_ref_name(&test_branch)?; - let path_for_log = path.clone(); - let result = blocking(move || { - let normalized = normalize_path(&path); - git_ops::merge_to_test_branch(Path::new(&normalized), &test_branch) - }) - .await; - log_git_command_error("merge_to_test_branch", &path_for_log, result) -} - -#[tauri::command] -pub(crate) async fn merge_to_base_branch( - path: String, - base_branch: String, -) -> Result { - validate_git_ref_name(&base_branch)?; - let path_for_log = path.clone(); - let result = blocking(move || { - let normalized = normalize_path(&path); - git_ops::merge_to_base_branch(Path::new(&normalized), &base_branch) - }) - .await; - log_git_command_error("merge_to_base_branch", &path_for_log, result) -} - -#[tauri::command] -pub(crate) async fn get_branch_diff_stats( - path: String, - base_branch: String, - test_branch: Option, -) -> Result { - validate_git_ref_name(&base_branch)?; - if let Some(branch) = &test_branch { - validate_git_ref_name(branch)?; - } - let path_for_log = path.clone(); - let result = tokio::task::spawn_blocking(move || { - let normalized = normalize_path(&path); - git_ops::get_branch_diff_stats(Path::new(&normalized), &base_branch, test_branch.as_deref()) - }) - .await - .map_err(|e| format!("Task join error: {}", e)); - log_git_command_error("get_branch_diff_stats", &path_for_log, result) -} - -#[tauri::command] -pub(crate) async fn create_pull_request( - path: String, - base_branch: String, - title: String, - body: String, -) -> Result { - validate_git_ref_name(&base_branch)?; - let path_for_log = path.clone(); - let result = blocking(move || { - let normalized = normalize_path(&path); - git_ops::create_pull_request(Path::new(&normalized), &base_branch, &title, &body) - }) - .await; - log_git_command_error("create_pull_request", &path_for_log, result) -} - -#[tauri::command] -pub(crate) async fn fetch_project_remote(path: String) -> Result<(), String> { - let path_for_log = path.clone(); - let result = blocking(move || { - let normalized = normalize_path(&path); - git_ops::fetch_remote(Path::new(&normalized)) - }) - .await; - log_git_command_error("fetch_project_remote", &path_for_log, result) -} - -#[tauri::command] -pub(crate) async fn check_remote_branch_exists( - path: String, - branch_name: String, -) -> Result { - validate_git_ref_name(&branch_name)?; - let path_for_log = path.clone(); - let result = blocking(move || { - let normalized = normalize_path(&path); - git_ops::check_remote_branch_exists(Path::new(&normalized), &branch_name) - }) - .await; - log_git_command_error("check_remote_branch_exists", &path_for_log, result) -} - -#[tauri::command] -pub(crate) async fn get_remote_branches(path: String) -> Result, String> { - let path_for_log = path.clone(); - let result = blocking(move || { - let normalized = normalize_path(&path); - git_ops::get_remote_branches(Path::new(&normalized)) - }) - .await; - log_git_command_error("get_remote_branches", &path_for_log, result) -} - -#[tauri::command] -pub(crate) async fn get_git_diff(path: String) -> Result { - let path_for_log = path.clone(); - let result = blocking(move || { - let normalized = normalize_path(&path); - git_ops::get_git_diff(Path::new(&normalized)) - }) - .await; - log_git_command_error("get_git_diff", &path_for_log, result) -} - -#[tauri::command] -pub(crate) async fn commit_all( - path: String, - message: String, - author_name: Option, - author_email: Option, - skip_hooks: Option, -) -> Result { - let path_for_log = path.clone(); - let result = blocking(move || { - let normalized = normalize_path(&path); - git_ops::commit_all( - Path::new(&normalized), - &message, - author_name.as_deref(), - author_email.as_deref(), - skip_hooks.unwrap_or(false), - ) - }) - .await; - log_git_command_error("commit_all", &path_for_log, result) -} - -#[tauri::command] -pub(crate) async fn get_git_user_config( - path: String, -) -> Result<(Option, Option), String> { - let path_for_log = path.clone(); - let result = blocking(move || { - let normalized = normalize_path(&path); - git_ops::get_git_user_config(Path::new(&normalized)) - }) - .await; - log_git_command_error("get_git_user_config", &path_for_log, result) -} - -#[tauri::command] -pub(crate) async fn set_git_user_config( - path: String, - name: Option, - email: Option, -) -> Result<(), String> { - let path_for_log = path.clone(); - let result = blocking(move || { - let normalized = normalize_path(&path); - git_ops::set_git_user_config(Path::new(&normalized), name.as_deref(), email.as_deref()) - }) - .await; - log_git_command_error("set_git_user_config", &path_for_log, result) -} - -#[tauri::command] -pub(crate) async fn get_changed_files(path: String) -> Result, String> { - let path_for_log = path.clone(); - let result = blocking(move || { - let normalized = normalize_path(&path); - git_ops::get_changed_files(Path::new(&normalized)) - }) - .await; - log_git_command_error("get_changed_files", &path_for_log, result) -} - -#[tauri::command] -pub(crate) async fn get_file_diff( - path: String, - file_path: String, -) -> Result { - let path_for_log = path.clone(); - let result = blocking(move || { - let normalized = normalize_path(&path); - git_ops::get_file_diff(Path::new(&normalized), &file_path) - }) - .await; - log_git_command_error("get_file_diff", &path_for_log, result) -} - -// ==================== HTTP Server 共享接口 ==================== - -pub fn switch_branch_internal(request: &SwitchBranchRequest) -> Result<(), String> { - validate_git_ref_name(&request.branch)?; - - log::info!( - "[git] switch_branch_internal: path='{}', target='{}'", - request.project_path, - request.branch - ); - let path = PathBuf::from(&request.project_path); - if !path.exists() { - log::error!( - "[git] Project path does not exist: {}", - request.project_path - ); - return Err(format!( - "Project path does not exist: {}", - request.project_path - )); - } - log::info!("[git] Step 1/3: git fetch origin"); - let _ = git_command() - .args(["fetch", "origin"]) - .current_dir(&path) - .output(); - log::info!("[git] Step 2/3: git checkout {}", request.branch); - let checkout_output = git_command() - .args(["checkout", &request.branch]) - .current_dir(&path) - .output() - .map_err(|e| format!("Failed to checkout: {}", e))?; - if !checkout_output.status.success() { - let stderr = String::from_utf8_lossy(&checkout_output.stderr); - let stderr_for_log = mask_url_credentials(&stderr); - log::error!( - "[git] Step 2/3 FAILED: git checkout {}: {}", - request.branch, - stderr_for_log - ); - return Err(format!("Failed to checkout {}: {}", request.branch, stderr)); - } - log::info!("[git] Step 3/3: git pull origin {}", request.branch); - let _ = git_command() - .args(["pull", "origin", &request.branch]) - .current_dir(&path) - .output(); - log::info!("[git] Successfully switched to branch '{}'", request.branch); - Ok(()) -} - -// ==================== Sync All Projects to BASE ==================== - -#[derive(Debug, serde::Serialize, Clone)] -pub struct SyncBaseResult { - pub path: String, - pub project_name: String, - pub status: String, // "success" | "skipped" | "failed" - pub message: String, -} - -struct SyncPermitRelease(std::sync::mpsc::SyncSender<()>); - -impl Drop for SyncPermitRelease { - fn drop(&mut self) { - let _ = self.0.send(()); - } -} - -fn sync_worker_join_result( - join_result: std::thread::Result, - path: String, - project_name: String, -) -> SyncBaseResult { - match join_result { - Ok(result) => result, - Err(_) => { - log::error!( - "[sync-base] Worker thread panicked for project '{}'", - project_name - ); - SyncBaseResult { - path, - project_name, - status: "failed".to_string(), - message: "内部错误:同步线程异常退出".to_string(), - } - } - } -} - -pub(crate) fn sync_all_projects_to_base_impl( - window_label: &str, - project_paths: Vec, -) -> Result, String> { - let (_, config) = get_window_workspace_config(window_label).ok_or("No workspace selected")?; - - let projects_config: Vec<(String, String)> = config - .projects - .iter() - .map(|p| (p.name.clone(), p.base_branch.clone())) - .collect(); - - let max_concurrent = project_paths.len().clamp(1, 8); - - log::info!( - "[sync-base] Syncing {} projects to their base branches (max concurrent: {})", - project_paths.len(), - max_concurrent - ); - - // Use a channel-based semaphore to avoid tokio block_on in thread::scope - let (sem_tx, sem_rx) = std::sync::mpsc::sync_channel::<()>(max_concurrent); - let sem_rx = Arc::new(std::sync::Mutex::new(sem_rx)); - - // Pre-fill permits - for _ in 0..max_concurrent { - let _ = sem_tx.send(()); - } - - let results: Vec = std::thread::scope(|s| { - let mut handles = Vec::new(); - - for project_path in &project_paths { - let path = PathBuf::from(project_path.clone()); - let project_name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(project_path.as_str()) - .to_string(); - let base_branch = projects_config - .iter() - .find(|(name, _)| name == &project_name) - .map(|(_, b)| b.clone()) - .unwrap_or_else(|| { - log::warn!( - "[sync-base] Project '{}' not found in config, defaulting to 'main'", - project_name - ); - "main".to_string() - }); - let result_path = path.to_string_lossy().to_string(); - let result_project_name = project_name.clone(); - - // Acquire permit: blocks if max concurrent reached - let rx = sem_rx.clone(); - rx.lock().unwrap().recv().expect("Semaphore channel closed"); - - let tx = sem_tx.clone(); - let handle = s.spawn(move || { - let _permit = SyncPermitRelease(tx); - sync_single_project_to_base(&path, &project_name, &base_branch) - }); - handles.push((result_path, result_project_name, handle)); - } - - let mut results = Vec::new(); - for (path, project_name, handle) in handles { - results.push(sync_worker_join_result(handle.join(), path, project_name)); - } - results - }); - - log::info!( - "[sync-base] Done. Results: {} success, {} skipped, {} failed", - results.iter().filter(|r| r.status == "success").count(), - results.iter().filter(|r| r.status == "skipped").count(), - results.iter().filter(|r| r.status == "failed").count(), - ); - - Ok(results) -} - -/// Push with exponential backoff retry (synchronous — safe for use in thread::scope) -fn retry_push(project_path: &str, max_retries: u32) -> Result<(), String> { - let mut delay_ms = 1000u64; - for attempt in 0..max_retries { - match git_ops::push_to_remote(Path::new(project_path)) { - Ok(_) => { - log::info!( - "[sync-base] Push succeeded after {} attempt(s)", - attempt + 1 - ); - return Ok(()); - } - Err(e) if attempt < max_retries - 1 => { - let error_for_log = mask_url_credentials(&e); - log::warn!( - "[sync-base] Push attempt {} failed: {}, retrying in {}ms...", - attempt + 1, - error_for_log, - delay_ms - ); - std::thread::sleep(std::time::Duration::from_millis(delay_ms)); - delay_ms *= 2; - } - Err(e) => { - let error_for_log = mask_url_credentials(&e); - log::error!( - "[sync-base] Push attempt {} failed: {}", - attempt + 1, - error_for_log - ); - return Err(e); - } - } - } - Ok(()) -} - -fn sync_single_project_to_base( - path: &Path, - project_name: &str, - base_branch: &str, -) -> SyncBaseResult { - let path_str = path.to_string_lossy().to_string(); - - if let Err(e) = validate_git_ref_name(base_branch) { - log::error!( - "[sync-base] {}: invalid base branch '{}': {}", - project_name, - base_branch, - e - ); - return SyncBaseResult { - path: path_str, - project_name: project_name.to_string(), - status: "failed".to_string(), - message: e, - }; - } - - if !path.exists() { - log::error!("[sync-base] Project path does not exist: {}", path_str); - return SyncBaseResult { - path: path_str, - project_name: project_name.to_string(), - status: "failed".to_string(), - message: "Project path does not exist".to_string(), - }; - } - - match git_ops::sync_with_base_branch(path, base_branch) { - Ok(msg) => { - log::info!("[sync-base] {}: sync succeeded - {}", project_name, msg); - - let push_result = retry_push(&path_str, 3); - - match push_result { - Ok(()) => { - log::info!( - "[sync-base] {}: success - {} (push succeeded)", - project_name, - msg - ); - SyncBaseResult { - path: path_str, - project_name: project_name.to_string(), - status: "success".to_string(), - message: format!("{} (push succeeded)", msg), - } - } - Err(e) => { - let error_for_log = mask_url_credentials(&e); - log::error!( - "[sync-base] {}: push failed - {}", - project_name, - error_for_log - ); - SyncBaseResult { - path: path_str, - project_name: project_name.to_string(), - status: "failed".to_string(), - message: format!("{} (push failed: {})", msg, e), - } - } - } - } - Err(e) => { - let error_for_log = mask_url_credentials(&e); - log::error!("[sync-base] {}: failed - {}", project_name, error_for_log); - SyncBaseResult { - path: path_str, - project_name: project_name.to_string(), - status: "failed".to_string(), - message: e, - } - } - } -} - -#[tauri::command] -pub(crate) async fn sync_all_projects_to_base( - window: tauri::Window, - project_paths: Vec, -) -> Result, String> { - let label = window.label().to_string(); - blocking(move || sync_all_projects_to_base_impl(&label, project_paths)).await -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::load_workspace_config; - use crate::state::WINDOW_WORKSPACES; - use crate::types::WorkspaceConfig; - use serial_test::serial; - use std::path::Path; - use std::process::Command; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::{Mutex, Once}; - use tempfile::TempDir; - - struct TestLogger; - - static TEST_LOGGER: TestLogger = TestLogger; - static LOGGER_INIT: Once = Once::new(); - static LOG_MESSAGES: Mutex> = Mutex::new(Vec::new()); - static NEXT_WINDOW_ID: AtomicUsize = AtomicUsize::new(0); - - impl log::Log for TestLogger { - fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { - metadata.level() <= log::Level::Error - } - - fn log(&self, record: &log::Record<'_>) { - if self.enabled(record.metadata()) { - LOG_MESSAGES - .lock() - .expect("lock log messages") - .push(format!("{}", record.args())); - } - } - - fn flush(&self) {} - } - - fn init_test_logger() { - LOGGER_INIT.call_once(|| { - log::set_logger(&TEST_LOGGER).expect("set test logger"); - log::set_max_level(log::LevelFilter::Error); - }); - LOG_MESSAGES.lock().expect("clear log messages").clear(); - } - - fn run_git(repo: &Path, args: &[&str]) { - let output = Command::new("git") - .args(args) - .current_dir(repo) - .output() - .expect("run git command"); - assert!( - output.status.success(), - "git {:?} failed\nstdout:\n{}\nstderr:\n{}", - args, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } - - fn git_output(repo: &Path, args: &[&str]) -> String { - let output = Command::new("git") - .args(args) - .current_dir(repo) - .output() - .expect("run git command"); - assert!( - output.status.success(), - "git {:?} failed\nstdout:\n{}\nstderr:\n{}", - args, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8_lossy(&output.stdout).trim().to_string() - } - - fn make_test_repo() -> TempDir { - let temp = tempfile::tempdir().expect("create temp repo"); - let repo = temp.path(); - - run_git(repo, &["init"]); - run_git(repo, &["checkout", "-b", "main"]); - run_git(repo, &["config", "user.email", "test@example.com"]); - run_git(repo, &["config", "user.name", "Test User"]); - std::fs::write(repo.join("README.md"), "initial\n").expect("write initial file"); - run_git(repo, &["add", "README.md"]); - run_git(repo, &["commit", "-m", "initial commit"]); - run_git(repo, &["branch", "feature/local"]); - - temp - } - - fn workspace_config(projects: Vec) -> WorkspaceConfig { - WorkspaceConfig { - name: "Git Command Workspace".to_string(), - worktrees_dir: "worktrees".to_string(), - projects, - ..WorkspaceConfig::default() - } - } - - fn project_config(name: &str, base_branch: &str) -> ProjectConfig { - ProjectConfig { - name: name.to_string(), - base_branch: base_branch.to_string(), - test_branch: "test".to_string(), - merge_strategy: "merge".to_string(), - linked_folders: vec![], - commit_prefix_index: None, - git_user_name: None, - git_user_email: None, - tags: vec![], - } - } - - fn bind_workspace(workspace: &Path, config: &WorkspaceConfig) -> String { - let label = format!( - "git-test-window-{}", - NEXT_WINDOW_ID.fetch_add(1, Ordering::SeqCst) - ); - let workspace_path = workspace.to_string_lossy().to_string(); - save_workspace_config_internal(&workspace_path, config).expect("save workspace config"); - WINDOW_WORKSPACES - .lock() - .expect("lock window workspaces") - .insert(label.clone(), workspace_path); - label - } - - fn init_named_repo(parent: &Path, name: &str) -> PathBuf { - let repo = parent.join(name); - std::fs::create_dir_all(&repo).expect("create named repo"); - run_git(&repo, &["init"]); - run_git(&repo, &["checkout", "-b", "main"]); - run_git(&repo, &["config", "user.email", "test@example.com"]); - run_git(&repo, &["config", "user.name", "Test User"]); - std::fs::write(repo.join("README.md"), format!("{name}\n")).expect("write readme"); - run_git(&repo, &["add", "README.md"]); - run_git(&repo, &["commit", "-m", "initial commit"]); - repo - } - - fn init_bare_repo(origin_path: &Path) { - let output = Command::new("git") - .args(["init", "--bare"]) - .arg(origin_path) - .output() - .expect("init bare origin"); - assert!( - output.status.success(), - "git init --bare {} failed\nstdout:\n{}\nstderr:\n{}", - origin_path.display(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } - - fn make_bare_origin() -> TempDir { - let origin = tempfile::tempdir().expect("create bare origin dir"); - init_bare_repo(origin.path()); - let seed = make_test_repo(); - run_git(seed.path(), &["branch", "test"]); - run_git( - seed.path(), - &["remote", "add", "origin", origin.path().to_str().unwrap()], - ); - run_git(seed.path(), &["push", "origin", "main"]); - run_git(seed.path(), &["push", "origin", "test"]); - run_git(seed.path(), &["push", "origin", "feature/local"]); - run_git(origin.path(), &["symbolic-ref", "HEAD", "refs/heads/main"]); - origin - } - - fn clone_repo(origin_path: &Path, clone_path: &Path) { - let output = Command::new("git") - .arg("clone") - .arg(origin_path) - .arg(clone_path) - .output() - .expect("clone repo"); - assert!( - output.status.success(), - "git clone {} {} failed\nstdout:\n{}\nstderr:\n{}", - origin_path.display(), - clone_path.display(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } - - fn make_origin_backed_repo() -> (TempDir, TempDir) { - let origin = make_bare_origin(); - let clone = tempfile::tempdir().expect("create clone dir"); - clone_repo(origin.path(), clone.path()); - run_git(clone.path(), &["config", "user.email", "test@example.com"]); - run_git(clone.path(), &["config", "user.name", "Test User"]); - run_git(clone.path(), &["checkout", "feature/local"]); - (origin, clone) - } - - #[serial] - #[test] - fn sync_worker_join_result_converts_panic_to_failed_result() { - let join_result = std::thread::spawn(|| -> SyncBaseResult { - panic!("worker panic"); - }) - .join(); - - let result = sync_worker_join_result( - join_result, - "/tmp/workspace/projects/demo".to_string(), - "demo".to_string(), - ); - - assert_eq!(result.path, "/tmp/workspace/projects/demo"); - assert_eq!(result.project_name, "demo"); - assert_eq!(result.status, "failed"); - assert_eq!(result.message, "内部错误:同步线程异常退出"); - } - - #[serial] - #[tokio::test] - async fn switch_branch_switches_to_existing_branch_even_when_fetch_and_pull_fail() { - let repo = make_test_repo(); - let request = SwitchBranchRequest { - project_path: repo.path().to_string_lossy().to_string(), - branch: "feature/local".to_string(), - }; - - switch_branch(request).await.expect("switch branch"); - - assert_eq!( - git_output(repo.path(), &["branch", "--show-current"]), - "feature/local" - ); - } - - #[serial] - #[test] - fn switch_branch_internal_switches_existing_branch() { - let repo = make_test_repo(); - let request = SwitchBranchRequest { - project_path: repo.path().to_string_lossy().to_string(), - branch: "feature/local".to_string(), - }; - - switch_branch_internal(&request).expect("switch branch internally"); - - assert_eq!( - git_output(repo.path(), &["branch", "--show-current"]), - "feature/local" - ); - } - - #[serial] - #[tokio::test] - async fn switch_branch_returns_checkout_error_for_missing_branch() { - let repo = make_test_repo(); - let request = SwitchBranchRequest { - project_path: repo.path().to_string_lossy().to_string(), - branch: "missing-branch".to_string(), - }; - - let err = switch_branch(request).await.unwrap_err(); - - assert!(err.contains("Failed to checkout missing-branch"), "{err}"); - assert_eq!( - git_output(repo.path(), &["branch", "--show-current"]), - "main" - ); - } - - #[serial] - #[tokio::test] - async fn switch_branch_reports_missing_project_path() { - let missing = tempfile::tempdir() - .expect("create temp dir") - .path() - .join("missing-project"); - let request = SwitchBranchRequest { - project_path: missing.to_string_lossy().to_string(), - branch: "main".to_string(), - }; - - let err = switch_branch(request).await.unwrap_err(); - - assert_eq!( - err, - format!("Project path does not exist: {}", missing.display()) - ); - } - - #[serial] - #[tokio::test] - async fn switch_branch_rejects_invalid_ref_before_path_lookup() { - let request = SwitchBranchRequest { - project_path: "/path/that/does/not/exist".to_string(), - branch: "-upload-pack=sh".to_string(), - }; - - let err = switch_branch(request).await.unwrap_err(); - - assert_eq!(err, "无效的分支名"); - } - - #[serial] - #[test] - fn log_git_command_error_masks_credentials_in_logs_and_returns_original_error() { - init_test_logger(); - let raw_error = - "fatal: Authentication failed for https://user:secret-token@example.com/repo.git"; - - let result = log_git_command_error::<()>( - "clone_project_test_unique", - "/tmp/redaction-test", - Err(raw_error.to_string()), - ); - - assert_eq!(result.unwrap_err(), raw_error); - let joined = LOG_MESSAGES.lock().expect("read log messages").join("\n"); - assert!(joined.contains("clone_project_test_unique"), "{joined}"); - assert!( - joined.contains("https://***@example.com/repo.git"), - "{joined}" - ); - assert!(!joined.contains("secret-token"), "{joined}"); - } - - #[serial] - #[tokio::test] - async fn get_changed_files_command_returns_parsed_entries() { - let repo = make_test_repo(); - std::fs::write(repo.path().join("README.md"), "changed\n").expect("modify readme"); - std::fs::write(repo.path().join("new.txt"), "new\n").expect("write new file"); - - let files = get_changed_files(repo.path().to_string_lossy().to_string()) - .await - .expect("get changed files"); - - let readme = files - .iter() - .find(|file| file.path == "README.md") - .expect("README.md changed file"); - assert_eq!(readme.status, "M"); - assert!(!readme.staged); - - let new_file = files - .iter() - .find(|file| file.path == "new.txt") - .expect("new.txt changed file"); - assert_eq!(new_file.status, "?"); - assert!(!new_file.staged); - } - - #[serial] - #[tokio::test] - async fn get_git_diff_command_propagates_clean_repo_error() { - let repo = make_test_repo(); - - let err = get_git_diff(repo.path().to_string_lossy().to_string()) - .await - .unwrap_err(); - - assert_eq!(err, "No changes to commit"); - } - - #[serial] - #[tokio::test] - async fn set_and_get_git_user_config_commands_round_trip_local_config() { - let repo = make_test_repo(); - let path = repo.path().to_string_lossy().to_string(); - - set_git_user_config( - path.clone(), - Some("Command User".to_string()), - Some("command@example.com".to_string()), - ) - .await - .expect("set git user config"); - - let (name, email) = get_git_user_config(path) - .await - .expect("get git user config"); - assert_eq!(name.as_deref(), Some("Command User")); - assert_eq!(email.as_deref(), Some("command@example.com")); - } - - #[serial] - #[tokio::test] - async fn commit_all_command_commits_local_changes_and_returns_message() { - let repo = make_test_repo(); - let path = repo.path().to_string_lossy().to_string(); - std::fs::write(repo.path().join("README.md"), "committed\n").expect("modify readme"); - - let result = commit_all(path, "command commit".to_string(), None, None, Some(true)) - .await - .expect("commit all"); - - assert_eq!(result, "Committed: command commit"); - assert_eq!( - git_output(repo.path(), &["log", "-1", "--pretty=%s"]), - "command commit" - ); - } - - #[serial] - #[test] - fn clone_project_reports_existing_target_and_rejects_file_url_local_remote() { - let workspace = tempfile::tempdir().expect("create workspace"); - std::fs::create_dir_all(workspace.path().join("projects")).expect("create projects dir"); - let label = bind_workspace(workspace.path(), &workspace_config(vec![])); - let origin = make_bare_origin(); - let file_url = format!("file://{}", origin.path().display()); - - let err = clone_project_impl( - &label, - CloneProjectRequest { - name: "demo".to_string(), - repo_url: file_url.clone(), - base_branch: "main".to_string(), - test_branch: "test".to_string(), - merge_strategy: "merge".to_string(), - linked_folders: vec!["node_modules".to_string()], - }, - ) - .unwrap_err(); - assert_eq!(err, format!("Invalid repository URL format: {file_url}")); - assert!(!workspace.path().join("projects").join("demo").exists()); - - std::fs::create_dir_all(workspace.path().join("projects").join("existing")) - .expect("create existing target"); - let existing = clone_project_impl( - &label, - CloneProjectRequest { - name: "existing".to_string(), - repo_url: file_url, - base_branch: "main".to_string(), - test_branch: "test".to_string(), - merge_strategy: "merge".to_string(), - linked_folders: vec![], - }, - ) - .unwrap_err(); - assert_eq!(existing, "Project 'existing' already exists"); - } - - #[serial] - #[test] - fn scan_import_add_and_remove_existing_projects_update_workspace_config() { - let workspace = tempfile::tempdir().expect("create workspace"); - std::fs::create_dir_all(workspace.path().join("projects")).expect("create projects dir"); - let external = tempfile::tempdir().expect("create external parent"); - let source = init_named_repo(external.path(), "alpha"); - let label = bind_workspace(workspace.path(), &workspace_config(vec![])); - - let imported = import_external_project_impl(&label, source.to_string_lossy().to_string()) - .expect("import external project"); - assert_eq!(imported.name, "alpha"); - assert_eq!(imported.current_branch, "main"); - assert!(!imported.is_registered); - - let scanned = scan_existing_projects_impl(&label).expect("scan imported project"); - assert_eq!(scanned.len(), 1); - assert_eq!(scanned[0].name, "alpha"); - assert_eq!(scanned[0].current_branch, "main"); - assert!(!scanned[0].is_registered); - - add_existing_project_impl( - &label, - "alpha".to_string(), - "main".to_string(), - "test".to_string(), - "merge".to_string(), - ) - .expect("add existing project"); - let scanned = scan_existing_projects_impl(&label).expect("scan registered project"); - assert!(scanned[0].is_registered); - - let duplicate = add_existing_project_impl( - &label, - "alpha".to_string(), - "main".to_string(), - "test".to_string(), - "merge".to_string(), - ) - .unwrap_err(); - assert_eq!(duplicate, "Project 'alpha' is already registered"); - - let reference = workspace - .path() - .join("worktrees") - .join("feature_a") - .join("projects") - .join("alpha"); - std::fs::create_dir_all(&reference).expect("create worktree project reference"); - let blocked = remove_project_from_config_impl(&label, "alpha".to_string()).unwrap_err(); - assert!( - blocked.contains("referenced by worktree(s): feature_a"), - "{blocked}" - ); - - std::fs::remove_dir_all(workspace.path().join("worktrees")).expect("remove reference"); - remove_project_from_config_impl(&label, "alpha".to_string()) - .expect("remove project from config"); - let saved = load_workspace_config(&workspace.path().to_string_lossy()); - assert!(saved.projects.is_empty()); - - let missing = remove_project_from_config_impl(&label, "alpha".to_string()).unwrap_err(); - assert_eq!(missing, "Project 'alpha' is not in the configuration"); - } - - #[serial] - #[tokio::test] - async fn advanced_git_command_wrappers_use_local_bare_origin_success_paths() { - let (_origin, repo) = make_origin_backed_repo(); - let path = repo.path().to_string_lossy().to_string(); - - switch_branch(SwitchBranchRequest { - project_path: path.clone(), - branch: "main".to_string(), - }) - .await - .expect("switch to main with fetch and pull"); - assert_eq!( - git_output(repo.path(), &["branch", "--show-current"]), - "main" - ); - - switch_branch(SwitchBranchRequest { - project_path: path.clone(), - branch: "feature/local".to_string(), - }) - .await - .expect("switch back to feature"); - assert_eq!( - git_output(repo.path(), &["branch", "--show-current"]), - "feature/local" - ); - - assert_eq!( - sync_with_base_branch(path.clone(), "main".to_string()) - .await - .expect("sync with base"), - "Successfully synced with main" - ); - assert_eq!( - push_to_remote(path.clone()) - .await - .expect("push feature branch"), - "Successfully pushed feature/local to origin" - ); - assert_eq!( - pull_current_branch(path.clone()) - .await - .expect("pull feature branch"), - "Successfully pulled feature/local from origin" - ); - fetch_project_remote(path.clone()) - .await - .expect("fetch remote"); - assert!( - check_remote_branch_exists(path.clone(), "feature/local".to_string()) - .await - .expect("check feature remote branch") - ); - assert!( - !check_remote_branch_exists(path.clone(), "missing".to_string()) - .await - .expect("check missing remote branch") - ); - let branches = get_remote_branches(path.clone()) - .await - .expect("get remote branches"); - assert!(branches.contains(&"main".to_string()), "{branches:?}"); - assert!(branches.contains(&"test".to_string()), "{branches:?}"); - assert!( - branches.contains(&"feature/local".to_string()), - "{branches:?}" - ); - - let test_merge = merge_to_test_branch(path.clone(), "test".to_string()) - .await - .expect("merge to test"); - assert!(test_merge.contains("feature/local"), "{test_merge}"); - let base_merge = merge_to_base_branch(path.clone(), "main".to_string()) - .await - .expect("merge to base"); - assert!(base_merge.contains("feature/local"), "{base_merge}"); - - std::fs::write(repo.path().join("README.md"), "wrapper change\n").expect("modify readme"); - let stats = - get_branch_diff_stats(path.clone(), "main".to_string(), Some("test".to_string())) - .await - .expect("get diff stats"); - assert_eq!(stats.changed_files, 1); - let diff = get_file_diff(path, "README.md".to_string()) - .await - .expect("get file diff"); - assert_eq!(diff.old_content, "initial\n"); - assert_eq!(diff.new_content, "wrapper change\n"); - assert!(!diff.is_binary); - } - - #[serial] - #[tokio::test] - async fn advanced_git_command_wrappers_propagate_validation_and_git_errors() { - let non_git = tempfile::tempdir().expect("create non git dir"); - let path = non_git.path().to_string_lossy().to_string(); - - let invalid_sync = sync_with_base_branch(path.clone(), "bad..branch".to_string()) - .await - .unwrap_err(); - assert_eq!(invalid_sync, "无效的分支名"); - - let fetch_err = fetch_project_remote(path.clone()).await.unwrap_err(); - assert!(fetch_err.contains("Git fetch failed"), "{fetch_err}"); - - let remote_check_err = check_remote_branch_exists(path.clone(), "main".to_string()) - .await - .unwrap_err(); - assert!( - remote_check_err.contains("Git branch check failed"), - "{remote_check_err}" - ); - - let invalid_remote_branch = - check_remote_branch_exists(path.clone(), "bad..branch".to_string()) - .await - .unwrap_err(); - assert_eq!(invalid_remote_branch, "无效的分支名"); - - let remote_branches_err = get_remote_branches(path.clone()).await.unwrap_err(); - assert!( - remote_branches_err.contains("Git fetch failed"), - "{remote_branches_err}" - ); - - let pr_validation = create_pull_request( - path, - "bad..branch".to_string(), - "title".to_string(), - "body".to_string(), - ) - .await - .unwrap_err(); - assert_eq!(pr_validation, "无效的分支名"); - } - - #[serial] - #[test] - fn sync_all_projects_to_base_reports_success_missing_path_and_invalid_base() { - let (_origin, repo) = make_origin_backed_repo(); - let workspace = tempfile::tempdir().expect("create workspace"); - let project_name = repo - .path() - .file_name() - .and_then(|name| name.to_str()) - .expect("repo dir name") - .to_string(); - let label = bind_workspace( - workspace.path(), - &workspace_config(vec![project_config(&project_name, "main")]), - ); - let missing_path = workspace.path().join("projects").join("missing"); - - let results = sync_all_projects_to_base_impl( - &label, - vec![ - repo.path().to_string_lossy().to_string(), - missing_path.to_string_lossy().to_string(), - ], - ) - .expect("sync all projects"); - - assert_eq!(results.len(), 2); - let success = results - .iter() - .find(|result| result.path == repo.path().to_string_lossy()) - .expect("success result"); - assert_eq!(success.project_name, project_name); - assert_eq!(success.status, "success"); - assert!(success.message.contains("push succeeded"), "{success:?}"); - - let missing = results - .iter() - .find(|result| result.path == missing_path.to_string_lossy()) - .expect("missing result"); - assert_eq!(missing.project_name, "missing"); - assert_eq!(missing.status, "failed"); - assert_eq!(missing.message, "Project path does not exist"); - - let invalid = sync_single_project_to_base(repo.path(), "demo", "bad..branch"); - assert_eq!(invalid.status, "failed"); - assert_eq!(invalid.message, "无效的分支名"); - } -} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs deleted file mode 100644 index 8248b4b..0000000 --- a/src-tauri/src/commands/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -pub(crate) mod cloud; -pub(crate) mod config; -pub(crate) mod git; -pub(crate) mod pty; -pub(crate) mod sharing; -pub(crate) mod system; -pub(crate) mod vault; -pub(crate) mod voice; -pub(crate) mod window; -pub(crate) mod workspace; -pub(crate) mod worktree; diff --git a/src-tauri/src/commands/pty.rs b/src-tauri/src/commands/pty.rs deleted file mode 100644 index f73bdfe..0000000 --- a/src-tauri/src/commands/pty.rs +++ /dev/null @@ -1,375 +0,0 @@ -use crate::pty_manager::requested_shell_path; -use crate::state::PTY_MANAGER; -use crate::utils::normalize_path; - -#[tauri::command] -pub(crate) fn pty_create( - session_id: String, - cwd: String, - cols: u16, - rows: u16, - shell: Option, -) -> Result<(), String> { - let cwd = normalize_path(&cwd); - // Hold the lock for the entire check-close-create sequence to avoid - // TOCTOU races with concurrent IPC or HTTP requests on the same session. - let requested_shell = requested_shell_path(shell.as_deref()); - let mut manager = PTY_MANAGER - .lock() - .map_err(|e| format!("Lock error: {}", e))?; - - if let Some(existing_shell) = manager.session_shell_path(&session_id) { - if existing_shell == requested_shell { - log::info!( - "[pty] Session already exists, skipping create: id={}, requested cols={}, rows={}, shell={}", - session_id, - cols, - rows, - requested_shell - ); - return Ok(()); - } - - log::info!( - "[pty] Session exists with different shell, recreating: id={}, existing_shell={}, requested_shell={}", - session_id, - existing_shell, - requested_shell - ); - manager.close_session(&session_id, "pty_create: shell changed")?; - } - - log::info!( - "[pty] Creating session: id={}, cwd={}, cols={}, rows={}, shell={:?}", - session_id, - cwd, - cols, - rows, - shell - ); - let result = manager.create_session(&session_id, &cwd, cols, rows, shell.as_deref()); - match &result { - Ok(()) => log::info!("[pty] Session created: {}", session_id), - Err(e) => log::error!("[pty] Failed to create session {}: {}", session_id, e), - } - result -} - -#[tauri::command] -pub(crate) fn pty_write(session_id: String, data: String) -> Result<(), String> { - let manager = PTY_MANAGER - .lock() - .map_err(|e| format!("Lock error: {}", e))?; - manager.write_to_session(&session_id, &data) -} - -#[tauri::command] -pub(crate) fn pty_read(session_id: String, client_id: Option) -> Result { - let manager = PTY_MANAGER - .lock() - .map_err(|e| format!("Lock error: {}", e))?; - manager.read_from_session(&session_id, client_id.as_deref()) -} - -#[tauri::command] -pub(crate) fn pty_resize( - session_id: String, - cols: u16, - rows: u16, - _client_id: Option, -) -> Result<(), String> { - log::info!( - "[pty] RESIZE: session={} size={}x{}", - session_id, - cols, - rows - ); - let manager = PTY_MANAGER - .lock() - .map_err(|e| format!("Lock error: {}", e))?; - manager.resize_session(&session_id, cols, rows) -} - -#[tauri::command] -pub(crate) fn pty_close(session_id: String) -> Result<(), String> { - log::info!("[pty] Closing session: {}", session_id); - let mut manager = PTY_MANAGER - .lock() - .map_err(|e| format!("Lock error: {}", e))?; - let result = manager.close_session(&session_id, "pty_close: frontend request"); - match &result { - Ok(()) => log::info!("[pty] Closed session: {}", session_id), - Err(e) => log::error!("[pty] Failed to close session {}: {}", session_id, e), - } - result -} - -#[tauri::command] -pub(crate) fn pty_exists(session_id: String) -> Result { - let manager = PTY_MANAGER - .lock() - .map_err(|e| format!("Lock error: {}", e))?; - Ok(manager.has_session(&session_id)) -} - -/// Close all PTY sessions whose working directory starts with the given path prefix. -/// Used internally when archiving/deleting worktrees (see archive_worktree, delete_archived_worktree) -/// and exposed via the HTTP server for remote access mode. -#[tauri::command] -pub(crate) fn pty_close_by_path(path_prefix: String) -> Result, String> { - let path_prefix = normalize_path(&path_prefix); - log::info!("[pty] Closing sessions by path prefix: {}", path_prefix); - let mut manager = PTY_MANAGER - .lock() - .map_err(|e| format!("Lock error: {}", e))?; - let closed = - manager.close_sessions_by_path_prefix(&path_prefix, "pty_close_by_path: frontend request"); - log::info!( - "[pty] Closed {} sessions matching path prefix: {}", - closed.len(), - path_prefix - ); - Ok(closed) -} - -#[cfg(test)] -mod tests { - use super::*; - use once_cell::sync::Lazy; - use serial_test::serial; - use std::sync::{Mutex, MutexGuard}; - use std::time::{Duration, Instant}; - - static PTY_COMMAND_TEST_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); - - fn lock_pty_command_tests() -> MutexGuard<'static, ()> { - PTY_COMMAND_TEST_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - } - - fn unique_session_id(name: &str) -> String { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - format!("pty-command-test-{}-{}-{nanos}", std::process::id(), name) - } - - #[cfg(not(target_os = "windows"))] - fn wait_for_pty_output(session_id: &str, reader_id: &str, needle: &str) -> String { - let deadline = Instant::now() + Duration::from_secs(3); - let mut collected = String::new(); - while Instant::now() < deadline { - let chunk = pty_read(session_id.to_string(), Some(reader_id.to_string())) - .expect("read pty output"); - collected.push_str(&chunk); - if collected.contains(needle) { - return collected; - } - std::thread::sleep(Duration::from_millis(25)); - } - panic!("timed out waiting for {needle:?}; collected {collected:?}"); - } - - #[serial] - #[test] - fn pty_exists_returns_false_for_missing_session() { - let _serial = lock_pty_command_tests(); - let session_id = unique_session_id("exists"); - - let exists = pty_exists(session_id).expect("query missing session"); - - assert!(!exists); - } - - #[serial] - #[test] - fn missing_session_read_write_and_resize_return_session_not_found() { - let _serial = lock_pty_command_tests(); - let session_id = unique_session_id("missing-ops"); - - let write_err = pty_write(session_id.clone(), "input".to_string()) - .expect_err("write to missing session"); - let read_err = - pty_read(session_id.clone(), Some("reader".to_string())).expect_err("read missing"); - let resize_err = pty_resize(session_id, 120, 40, Some("reader".to_string())) - .expect_err("resize missing"); - - assert_eq!(write_err, "Session not found"); - assert_eq!(read_err, "Session not found"); - assert_eq!(resize_err, "Session not found"); - } - - #[serial] - #[test] - fn pty_close_is_idempotent_for_missing_session() { - let _serial = lock_pty_command_tests(); - let session_id = unique_session_id("close"); - - let first = pty_close(session_id.clone()); - let second = pty_close(session_id.clone()); - let exists = pty_exists(session_id).expect("query closed session"); - - assert!(first.is_ok()); - assert!(second.is_ok()); - assert!(!exists); - } - - #[serial] - #[test] - fn pty_close_by_path_returns_empty_for_unmatched_prefix_without_spawning() { - let _serial = lock_pty_command_tests(); - let prefix = tempfile::tempdir() - .expect("create prefix") - .path() - .join("worktree"); - - let closed = pty_close_by_path(prefix.to_string_lossy().to_string()) - .expect("close unmatched prefix"); - - assert!(closed.is_empty()); - } - - #[cfg(not(target_os = "windows"))] - #[serial] - #[test] - fn pty_commands_create_write_read_resize_and_close_short_session() { - let _serial = lock_pty_command_tests(); - if !std::path::Path::new("/bin/sh").exists() { - // Unix CI images should have /bin/sh; skip only on unusual local systems. - return; - } - let cwd = tempfile::tempdir().expect("pty cwd"); - let session_id = unique_session_id("lifecycle"); - - pty_create( - session_id.clone(), - cwd.path().to_string_lossy().to_string(), - 80, - 24, - Some("/bin/sh".to_string()), - ) - .expect("create pty"); - assert!(pty_exists(session_id.clone()).unwrap()); - pty_resize(session_id.clone(), 100, 30, Some("reader".to_string())).unwrap(); - pty_write( - session_id.clone(), - "printf PTY_COMMAND_OK; exit\n".to_string(), - ) - .unwrap(); - - let output = wait_for_pty_output(&session_id, "reader", "PTY_COMMAND_OK"); - assert!(output.contains("PTY_COMMAND_OK")); - - pty_close(session_id.clone()).unwrap(); - assert!(!pty_exists(session_id).unwrap()); - } - - #[cfg(not(target_os = "windows"))] - #[serial] - #[test] - fn pty_create_is_idempotent_when_existing_shell_matches() { - let _serial = lock_pty_command_tests(); - if !std::path::Path::new("/bin/sh").exists() { - // Unix CI images should have /bin/sh; skip only on unusual local systems. - return; - } - let cwd = tempfile::tempdir().expect("pty cwd"); - let session_id = unique_session_id("idempotent"); - - pty_create( - session_id.clone(), - cwd.path().to_string_lossy().to_string(), - 80, - 24, - Some("/bin/sh".to_string()), - ) - .expect("create pty"); - pty_create( - session_id.clone(), - cwd.path().to_string_lossy().to_string(), - 120, - 40, - Some("/bin/sh".to_string()), - ) - .expect("idempotent create"); - - assert!(pty_exists(session_id.clone()).unwrap()); - pty_close(session_id).unwrap(); - } - - #[cfg(not(target_os = "windows"))] - #[serial] - #[test] - fn pty_create_recreates_existing_session_when_shell_changes() { - let _serial = lock_pty_command_tests(); - if !std::path::Path::new("/bin/sh").exists() || !std::path::Path::new("/bin/bash").exists() - { - // This branch needs two distinct local shells; skip on minimal Unix images. - return; - } - let cwd = tempfile::tempdir().expect("pty cwd"); - let session_id = unique_session_id("recreate"); - - pty_create( - session_id.clone(), - cwd.path().to_string_lossy().to_string(), - 80, - 24, - Some("/bin/sh".to_string()), - ) - .expect("create sh session"); - pty_create( - session_id.clone(), - cwd.path().to_string_lossy().to_string(), - 80, - 24, - Some("/bin/bash".to_string()), - ) - .expect("recreate bash session"); - - assert!(pty_exists(session_id.clone()).unwrap()); - pty_close(session_id).unwrap(); - } - - #[cfg(not(target_os = "windows"))] - #[serial] - #[test] - fn pty_close_by_path_closes_matching_normalized_session_ids() { - let _serial = lock_pty_command_tests(); - if !std::path::Path::new("/bin/sh").exists() { - // Unix CI images should have /bin/sh; skip only on unusual local systems. - return; - } - let cwd = tempfile::tempdir().expect("pty cwd"); - let normalized_prefix = cwd.path().to_string_lossy().replace(['/', '\\', '#'], "-"); - let exact_id = format!("pty-{}", normalized_prefix); - let child_id = format!("pty-{}-tab-2", normalized_prefix); - - pty_create( - exact_id.clone(), - cwd.path().to_string_lossy().to_string(), - 80, - 24, - Some("/bin/sh".to_string()), - ) - .expect("create exact session"); - pty_create( - child_id.clone(), - cwd.path().to_string_lossy().to_string(), - 80, - 24, - Some("/bin/sh".to_string()), - ) - .expect("create child session"); - - let mut closed = - pty_close_by_path(cwd.path().to_string_lossy().to_string()).expect("close by path"); - closed.sort(); - - assert_eq!(closed, vec![exact_id.clone(), child_id.clone()]); - assert!(!pty_exists(exact_id).unwrap()); - assert!(!pty_exists(child_id).unwrap()); - } -} diff --git a/src-tauri/src/commands/sharing.rs b/src-tauri/src/commands/sharing.rs deleted file mode 100644 index c2fe7d5..0000000 --- a/src-tauri/src/commands/sharing.rs +++ /dev/null @@ -1,1267 +0,0 @@ -use ngrok::config::ForwarderBuilder; // trait import: provides listen_and_forward() -use ngrok::forwarder::Forwarder; -use ngrok::tunnel::{EndpointInfo, HttpTunnel}; // EndpointInfo trait import: provides url() - -use crate::config::{get_window_workspace_path, load_global_config, save_global_config_internal}; -use crate::http_server; - -use crate::state::{ - AUTHENTICATED_SESSIONS, CLIENT_NOTIFICATION_BROADCAST, CONNECTED_CLIENTS, SHARE_STATE, TOKIO_RT, -}; -use crate::tls; -use crate::types::{ConnectedClient, ShareStateInfo}; - -// ==================== 分享功能命令 ==================== - -#[tauri::command] -pub(crate) async fn get_ngrok_token() -> Result, String> { - let config = load_global_config(); - Ok(config.ngrok_token) -} - -#[tauri::command] -pub(crate) async fn set_ngrok_token(token: String) -> Result<(), String> { - let mut config = load_global_config(); - config.ngrok_token = if token.is_empty() { None } else { Some(token) }; - save_global_config_internal(&config)?; - Ok(()) -} - -#[tauri::command] -pub(crate) async fn get_last_share_port() -> Result, String> { - let config = load_global_config(); - Ok(config.last_share_port) -} - -#[tauri::command] -pub(crate) async fn get_last_share_password() -> Result, String> { - let config = load_global_config(); - Ok(config.share_password) -} - -fn save_last_share_credentials(port: Option, password: &str) -> Result<(), String> { - let mut config = load_global_config(); - if let Some(port) = port { - config.last_share_port = Some(port); - } - config.share_password = Some(password.to_string()); - save_global_config_internal(&config) -} - -/// Internal function to start LAN sharing. -pub async fn start_sharing_internal( - workspace_path: String, - port: u16, - password: String, -) -> Result { - log::info!( - "[sharing] Starting LAN sharing: workspace={}, port={}, password_len={}", - workspace_path, - port, - password.len() - ); - - // SECURITY: Validate password strength (required for remote access security) - if password.trim().chars().count() < 8 { - log::warn!("[sharing] Rejected: password shorter than 8 characters"); - return Err("分享密码至少需要 8 位".to_string()); - } - - // Validate port range (recommended dynamic/private ports: 49152-65535) - // Allow common development ports (3000-9999) for convenience - if port < 3000 { - log::warn!("[sharing] Rejected: port {} too low (minimum 3000)", port); - return Err(format!( - "端口 {} 过小。推荐使用 49152-65535 范围内的端口,或 3000-9999 开发端口", - port - )); - } - - // Check if already sharing - { - let state = SHARE_STATE - .lock() - .map_err(|_| "Internal state error".to_string())?; - if state.active { - log::warn!("[sharing] Rejected: already sharing on port {}", state.port); - return Err("Already sharing. Stop current sharing first.".to_string()); - } - } - - // Check if port is available - // Bind to 0.0.0.0 to allow LAN access (security handled by password auth) - let bind_addr = format!("0.0.0.0:{}", port); - if let Err(e) = tokio::net::TcpListener::bind(&bind_addr).await { - log::error!("[sharing] Port {} unavailable: {}", port, e); - return Err(format!("端口 {} 已被占用: {}", port, e)); - } - - // Collect all LAN IPs for multi-address display - // Include all non-loopback IPv4: private, link-local, CGNAT (Tailscale 100.x), etc. - let mut lan_ips: Vec = local_ip_address::list_afinet_netifas() - .unwrap_or_default() - .into_iter() - .filter_map(|(_name, ip)| match ip { - std::net::IpAddr::V4(v4) - if !v4.is_loopback() && !v4.is_unspecified() && !v4.is_multicast() => - { - Some(ip) - } - _ => None, - }) - .collect(); - lan_ips.sort(); - lan_ips.dedup(); - log::info!( - "[sharing] Detected {} LAN IPs: {:?}", - lan_ips.len(), - lan_ips - ); - - // Generate self-signed TLS certificate for HTTPS (includes all LAN IPs in SAN) - let tls_certs = tls::generate_self_signed(&lan_ips)?; - log::info!( - "[sharing] TLS certificate generated for {} LAN IPs", - lan_ips.len() - ); - - let share_urls: Vec = lan_ips - .iter() - .map(|ip| format!("https://{}:{}", ip, port)) - .collect(); - - let share_url = share_urls - .first() - .cloned() - .unwrap_or_else(|| format!("https://0.0.0.0:{}", port)); - - // Create shutdown channel - let (tx, rx) = tokio::sync::watch::channel(false); - - // Generate salt and derive key using PBKDF2 - use ring::pbkdf2; - use ring::rand::{SecureRandom, SystemRandom}; - - let rng = SystemRandom::new(); - let mut salt = vec![0u8; 16]; - rng.fill(&mut salt).map_err(|_| "Failed to generate salt")?; - - let mut auth_key = vec![0u8; 32]; - pbkdf2::derive( - pbkdf2::PBKDF2_HMAC_SHA256, - std::num::NonZeroU32::new(100_000).unwrap(), - &salt, - password.as_bytes(), - &mut auth_key, - ); - log::info!("[sharing] PBKDF2 key derived"); - - save_last_share_credentials(Some(port), &password)?; - log::info!( - "[sharing] Port {} and password saved to global config", - port - ); - - // Update share state - { - let mut state = SHARE_STATE - .lock() - .map_err(|_| "Internal state error".to_string())?; - state.active = true; - state.workspace_path = Some(workspace_path.clone()); - state.port = port; - state.auth_key = Some(auth_key); - state.auth_salt = Some(salt); - state.shutdown_tx = Some(tx); - } - - // Clear any previous authenticated sessions - if let Ok(mut sessions) = AUTHENTICATED_SESSIONS.lock() { - sessions.clear(); - } - log::info!("[sharing] Previous authenticated sessions cleared"); - - // Spawn HTTP (port) + HTTPS (port+1) servers on the shared tokio runtime - TOKIO_RT.spawn(http_server::start_server(port, rx, Some(tls_certs))); - log::info!( - "[sharing] HTTP/HTTPS server spawned on port {} for workspace {}", - port, - workspace_path - ); - - Ok(share_url) -} - -#[tauri::command] -pub(crate) async fn start_sharing( - window: tauri::Window, - port: u16, - password: String, -) -> Result { - let workspace_path = - get_window_workspace_path(window.label()).ok_or("No workspace selected")?; - start_sharing_internal(workspace_path, port, password).await -} - -pub async fn start_ngrok_tunnel_internal() -> Result { - log::info!("[ngrok] Starting ngrok tunnel"); - let port = { - let state = SHARE_STATE - .lock() - .map_err(|_| "Internal state error".to_string())?; - if !state.active { - log::warn!("[ngrok] Rejected: LAN sharing not active"); - return Err("请先开启分享".to_string()); - } - if state.ngrok_url.is_some() { - log::warn!("[ngrok] Rejected: ngrok tunnel already running"); - return Err("ngrok 隧道已在运行".to_string()); - } - state.port - }; - - let config = load_global_config(); - let ngrok_token = config - .ngrok_token - .ok_or("未配置 ngrok token,请先在设置中配置".to_string())?; - log::info!("[ngrok] Token configured, forwarding to port {}", port); - - let (url_tx, url_rx) = std::sync::mpsc::channel::>(); - - let ngrok_handle = TOKIO_RT.spawn(async move { - let result = async { - log::info!("[ngrok] Connecting to ngrok service..."); - let session = ngrok::Session::builder() - .authtoken(ngrok_token) - .connect() - .await - .map_err(|e| format!("ngrok 连接失败: {}", e))?; - log::info!( - "[ngrok] Session established, creating HTTP tunnel to localhost:{}", - port - ); - - let forwarder = session - .http_endpoint() - .listen_and_forward( - url::Url::parse(&format!("http://localhost:{}", port)) - .map_err(|e| format!("URL 解析失败: {}", e))?, - ) - .await - .map_err(|e| format!("ngrok 隧道创建失败: {}", e))?; - - let ngrok_url = forwarder.url().to_string(); - log::info!("[ngrok] Tunnel created, URL: {}", ngrok_url); - Ok::<(String, Forwarder), String>((ngrok_url, forwarder)) - } - .await; - - match result { - Ok((url, mut forwarder)) => { - let _ = url_tx.send(Ok(url)); - // join() keeps the forwarder actively forwarding traffic - let _ = forwarder.join().await; - log::info!("[ngrok] Forwarder join() returned, tunnel closed"); - } - Err(e) => { - log::error!("[ngrok] Tunnel creation failed: {}", e); - let _ = url_tx.send(Err(e)); - } - } - }); - - // Wait for the ngrok URL (with timeout) - match url_rx.recv_timeout(std::time::Duration::from_secs(30)) { - Ok(Ok(ngrok_url)) => { - let mut state = SHARE_STATE - .lock() - .map_err(|_| "Internal state error".to_string())?; - state.ngrok_url = Some(ngrok_url.clone()); - state.ngrok_task = Some(ngrok_handle); - log::info!("[ngrok] Tunnel started successfully: {}", ngrok_url); - Ok(ngrok_url) - } - Ok(Err(e)) => { - log::error!("[ngrok] Tunnel startup error: {}", e); - ngrok_handle.abort(); - Err(e) - } - Err(_) => { - log::error!("[ngrok] Tunnel startup timed out after 30s"); - ngrok_handle.abort(); - Err("ngrok 隧道启动超时".to_string()) - } - } -} - -#[tauri::command] -pub(crate) async fn start_ngrok_tunnel() -> Result { - start_ngrok_tunnel_internal().await -} - -#[tauri::command] -pub(crate) async fn stop_ngrok_tunnel() -> Result<(), String> { - log::info!("[ngrok] Stopping ngrok tunnel"); - let mut state = SHARE_STATE - .lock() - .map_err(|_| "Internal state error".to_string())?; - if let Some(handle) = state.ngrok_task.take() { - // abort() is intentional: the ngrok crate's Forwarder does not expose a graceful - // shutdown API. Aborting the task triggers its Drop impl, which handles cleanup. - handle.abort(); - log::info!("[ngrok] Tunnel task aborted"); - } else { - log::info!("[ngrok] No active tunnel task to stop"); - } - state.ngrok_url = None; - log::info!("[ngrok] Tunnel stopped"); - Ok(()) -} - -/// Internal function to stop LAN sharing. -pub fn stop_sharing_internal() -> Result<(), String> { - log::info!("[sharing] Stopping LAN sharing"); - - // Single lock scope: check active, stop ngrok, extract shutdown_tx, and reset state - let shutdown_tx = { - let mut state = SHARE_STATE - .lock() - .map_err(|_| "Internal state error".to_string())?; - if !state.active { - log::warn!("[sharing] Stop rejected: not currently sharing"); - return Err("Not currently sharing".to_string()); - } - - // Stop ngrok tunnel if active - // NOTE: abort() is intentional here -- the ngrok crate's Forwarder does not expose - // a graceful shutdown API; aborting the task triggers its Drop impl for cleanup. - if let Some(handle) = state.ngrok_task.take() { - handle.abort(); - log::info!("[sharing] Stopped ngrok tunnel"); - } - state.ngrok_url = None; - - // Extract shutdown_tx and reset all state atomically - let tx = state.shutdown_tx.take(); - state.active = false; - state.workspace_path = None; - state.port = 0; - state.auth_key = None; - state.auth_salt = None; - tx - }; - - // Stop HTTP server (outside SHARE_STATE lock to avoid holding it during send) - if let Some(tx) = shutdown_tx { - let _ = tx.send(true); - log::info!("[sharing] HTTP server shutdown signal sent"); - } - - // Clear authenticated sessions and connected clients - if let Ok(mut sessions) = AUTHENTICATED_SESSIONS.lock() { - let count = sessions.len(); - sessions.clear(); - log::info!("[sharing] Cleared {} authenticated sessions", count); - } - if let Ok(mut clients) = CONNECTED_CLIENTS.lock() { - let count = clients.len(); - clients.clear(); - log::info!("[sharing] Cleared {} connected clients", count); - } - - log::info!("[sharing] LAN sharing stopped"); - Ok(()) -} - -#[tauri::command] -pub(crate) async fn stop_sharing() -> Result<(), String> { - stop_sharing_internal() -} - -#[tauri::command] -pub(crate) async fn get_share_state() -> Result { - let state = SHARE_STATE - .lock() - .map_err(|_| "Internal state error".to_string())?; - let urls = if state.active { - let mut ips: Vec = local_ip_address::list_afinet_netifas() - .unwrap_or_default() - .into_iter() - .filter_map(|(_name, ip)| match ip { - std::net::IpAddr::V4(v4) - if !v4.is_loopback() && !v4.is_unspecified() && !v4.is_multicast() => - { - Some(ip) - } - _ => None, - }) - .collect(); - ips.sort(); - ips.dedup(); - ips.iter() - .map(|ip| format!("https://{}:{}", ip, state.port)) - .collect() - } else { - vec![] - }; - - let current_workspace_name = state - .workspace_path - .as_ref() - .map(|path| crate::config::load_workspace_config(path).name); - - Ok(ShareStateInfo { - active: state.active, - urls, - ngrok_url: state.ngrok_url.clone(), - workspace_path: state - .workspace_path - .as_ref() - .map(|p| crate::normalize_path(p)), - current_workspace_name, - }) -} - -#[tauri::command] -pub(crate) async fn update_share_password(password: String) -> Result<(), String> { - log::info!( - "[sharing] Updating share password (new password_len={})", - password.len() - ); - - // SECURITY: Validate password strength - if password.trim().chars().count() < 8 { - log::warn!("[sharing] Password update rejected: shorter than 8 characters"); - return Err("分享密码至少需要 8 位".to_string()); - } - - // Generate new salt and derive new key - use ring::pbkdf2; - use ring::rand::{SecureRandom, SystemRandom}; - - let rng = SystemRandom::new(); - let mut salt = vec![0u8; 16]; - rng.fill(&mut salt).map_err(|_| "Failed to generate salt")?; - - let mut auth_key = vec![0u8; 32]; - pbkdf2::derive( - pbkdf2::PBKDF2_HMAC_SHA256, - std::num::NonZeroU32::new(100_000).unwrap(), - &salt, - password.as_bytes(), - &mut auth_key, - ); - log::info!("[sharing] New PBKDF2 key derived"); - - let mut state = SHARE_STATE - .lock() - .map_err(|_| "Internal state error".to_string())?; - if !state.active { - log::warn!("[sharing] Password update rejected: not currently sharing"); - return Err("Not currently sharing".to_string()); - } - state.auth_key = Some(auth_key); - state.auth_salt = Some(salt); - drop(state); - - save_last_share_credentials(None, &password)?; - - // Clear authenticated sessions and connected clients so everyone must re-auth with the new password - if let Ok(mut sessions) = AUTHENTICATED_SESSIONS.lock() { - let count = sessions.len(); - sessions.clear(); - log::info!( - "[sharing] Cleared {} authenticated sessions after password change", - count - ); - } - if let Ok(mut clients) = CONNECTED_CLIENTS.lock() { - let count = clients.len(); - clients.clear(); - log::info!( - "[sharing] Cleared {} connected clients after password change", - count - ); - } - - log::info!("[sharing] Share password updated successfully"); - Ok(()) -} - -// ==================== Connected Clients ==================== - -#[tauri::command] -pub(crate) fn get_connected_clients() -> Vec { - let Ok(clients) = CONNECTED_CLIENTS.lock() else { - return vec![]; - }; - clients.values().cloned().collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::state::{ - AUTHENTICATED_SESSIONS, CONNECTED_CLIENTS, GLOBAL_CONFIG_CACHE, SHARE_STATE, - }; - use once_cell::sync::Lazy; - use serde_json::Value; - use serial_test::serial; - use std::sync::{Mutex, MutexGuard}; - - static TEST_MUTEX: Lazy> = Lazy::new(|| Mutex::new(())); - - fn lock_test_mutex() -> MutexGuard<'static, ()> { - TEST_MUTEX.lock().unwrap_or_else(|err| err.into_inner()) - } - - struct GlobalConfigCacheGuard { - previous: Option, - } - - impl GlobalConfigCacheGuard { - fn with_share_password(password: &str) -> Self { - let mut cache = GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let previous = std::mem::replace( - &mut *cache, - Some(crate::GlobalConfig { - share_password: Some(password.to_string()), - ..crate::GlobalConfig::default() - }), - ); - Self { previous } - } - } - - impl Drop for GlobalConfigCacheGuard { - fn drop(&mut self) { - let mut cache = GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = self.previous.take(); - } - } - - struct TempHomeGuard { - previous_home: Option, - _temp_dir: tempfile::TempDir, - } - - impl TempHomeGuard { - fn new() -> Self { - let temp_dir = tempfile::tempdir().expect("create temp home"); - let previous_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", temp_dir.path()); - clear_global_config_cache(); - Self { - previous_home, - _temp_dir: temp_dir, - } - } - } - - impl Drop for TempHomeGuard { - fn drop(&mut self) { - match &self.previous_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } - clear_global_config_cache(); - } - } - - struct ShareStateGuard { - previous: crate::ShareState, - } - - impl ShareStateGuard { - fn inactive() -> Self { - let mut state = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let previous = std::mem::take(&mut *state); - state.active = false; - Self { previous } - } - - fn active(workspace_path: String, port: u16) -> Self { - let mut state = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let previous = std::mem::take(&mut *state); - let (tx, _rx) = tokio::sync::watch::channel(false); - state.active = true; - state.workspace_path = Some(workspace_path); - state.port = port; - state.auth_key = Some(vec![7; 32]); - state.auth_salt = Some(vec![8; 16]); - state.shutdown_tx = Some(tx); - Self { previous } - } - } - - impl Drop for ShareStateGuard { - fn drop(&mut self) { - let mut state = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *state = std::mem::take(&mut self.previous); - } - } - - struct ClientStateGuard { - previous_sessions: std::collections::HashSet, - previous_clients: std::collections::HashMap, - } - - impl ClientStateGuard { - fn empty() -> Self { - let previous_sessions = { - let mut sessions = AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *sessions) - }; - let previous_clients = { - let mut clients = CONNECTED_CLIENTS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *clients) - }; - Self { - previous_sessions, - previous_clients, - } - } - } - - impl Drop for ClientStateGuard { - fn drop(&mut self) { - let mut sessions = AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *sessions = std::mem::take(&mut self.previous_sessions); - - let mut clients = CONNECTED_CLIENTS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *clients = std::mem::take(&mut self.previous_clients); - } - } - - fn clear_global_config_cache() { - let mut cache = GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = None; - } - - fn free_port_at_least_3000() -> u16 { - for _ in 0..100 { - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind free port"); - let port = listener.local_addr().unwrap().port(); - if port >= 3000 { - return port; - } - } - panic!("could not allocate a test port >= 3000"); - } - - async fn loopback_bind_allowed() -> bool { - tokio::net::TcpListener::bind("127.0.0.1:0").await.is_ok() - } - - #[serial] - #[tokio::test] - async fn get_last_share_password_reads_global_config() { - let _serial = lock_test_mutex(); - let _guard = GlobalConfigCacheGuard::with_share_password("persisted-secret"); - - let password = get_last_share_password().await.expect("read password"); - - assert_eq!(password, Some("persisted-secret".to_string())); - } - - #[serial] - #[tokio::test] - async fn start_sharing_rejects_trimmed_password_shorter_than_eight_before_port_validation() { - let _serial = lock_test_mutex(); - - let result = - start_sharing_internal("/tmp/workspace".to_string(), 1, " 1234567 ".to_string()).await; - - assert_eq!(result, Err("分享密码至少需要 8 位".to_string())); - } - - #[serial] - #[tokio::test] - async fn start_sharing_accepts_exactly_eight_trimmed_password_characters() { - let _serial = lock_test_mutex(); - - let result = - start_sharing_internal("/tmp/workspace".to_string(), 1, " 12345678 ".to_string()).await; - - assert_eq!( - result, - Err( - "端口 1 过小。推荐使用 49152-65535 范围内的端口,或 3000-9999 开发端口".to_string() - ) - ); - } - - #[serial] - #[tokio::test] - async fn start_sharing_accepts_passwords_longer_than_eight_characters() { - let _serial = lock_test_mutex(); - - let result = - start_sharing_internal("/tmp/workspace".to_string(), 2, "long-password".to_string()) - .await; - - assert_eq!( - result, - Err( - "端口 2 过小。推荐使用 49152-65535 范围内的端口,或 3000-9999 开发端口".to_string() - ) - ); - } - - #[serial] - #[tokio::test] - async fn start_sharing_rejects_already_active_before_binding_port() { - let _serial = lock_test_mutex(); - let _state = ShareStateGuard::active("/tmp/workspace".to_string(), 45678); - - let result = start_sharing_internal( - "/tmp/other-workspace".to_string(), - 45679, - "long-password".to_string(), - ) - .await; - - assert_eq!( - result, - Err("Already sharing. Stop current sharing first.".to_string()) - ); - } - - #[serial] - #[tokio::test] - async fn start_sharing_rejects_occupied_port_and_preserves_inactive_state() { - let _serial = lock_test_mutex(); - if !loopback_bind_allowed().await { - // The managed sandbox can deny loopback binds; this branch needs local sockets only. - return; - } - let _state = ShareStateGuard::inactive(); - let listener = std::net::TcpListener::bind("0.0.0.0:0").expect("bind occupied port"); - let port = listener.local_addr().unwrap().port(); - if port < 3000 { - // Rare ephemeral allocation below the command's validation threshold. - return; - } - - let result = start_sharing_internal( - "/tmp/workspace".to_string(), - port, - "long-password".to_string(), - ) - .await; - - assert!(result - .unwrap_err() - .starts_with(&format!("端口 {} 已被占用", port))); - let state = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - assert!(!state.active); - assert_eq!(state.port, 0); - } - - #[serial] - #[tokio::test] - async fn start_sharing_success_sets_state_persists_credentials_and_stop_resets() { - let _serial = lock_test_mutex(); - if !loopback_bind_allowed().await { - // The managed sandbox can deny loopback binds; this branch needs local sockets only. - return; - } - let _home = TempHomeGuard::new(); - let _state = ShareStateGuard::inactive(); - let _clients = ClientStateGuard::empty(); - let workspace = tempfile::tempdir().expect("workspace"); - let port = free_port_at_least_3000(); - - let url = start_sharing_internal( - workspace.path().to_string_lossy().to_string(), - port, - "strong-password".to_string(), - ) - .await - .expect("start sharing"); - - assert!(url.starts_with("https://")); - { - let state = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - assert!(state.active); - assert_eq!(state.workspace_path.as_deref(), workspace.path().to_str()); - assert_eq!(state.port, port); - assert_eq!(state.auth_key.as_ref().unwrap().len(), 32); - assert_eq!(state.auth_salt.as_ref().unwrap().len(), 16); - assert!(state.shutdown_tx.is_some()); - } - let config = load_global_config(); - assert_eq!(config.last_share_port, Some(port)); - assert_eq!(config.share_password.as_deref(), Some("strong-password")); - - stop_sharing_internal().expect("stop sharing"); - let state = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - assert!(!state.active); - assert_eq!(state.workspace_path, None); - assert_eq!(state.port, 0); - assert!(state.auth_key.is_none()); - assert!(state.auth_salt.is_none()); - assert!(state.shutdown_tx.is_none()); - } - - #[serial] - #[tokio::test] - async fn update_share_password_rejects_trimmed_password_shorter_than_eight() { - let _serial = lock_test_mutex(); - let _guard = ShareStateGuard::inactive(); - - let result = update_share_password(" 1234567 ".to_string()).await; - - assert_eq!(result, Err("分享密码至少需要 8 位".to_string())); - } - - #[serial] - #[tokio::test] - async fn update_share_password_accepts_exactly_eight_chars_before_state_check() { - let _serial = lock_test_mutex(); - let _guard = ShareStateGuard::inactive(); - - let result = update_share_password("12345678".to_string()).await; - - assert_eq!(result, Err("Not currently sharing".to_string())); - } - - #[serial] - #[tokio::test] - async fn update_share_password_accepts_long_trimmed_password_before_state_check() { - let _serial = lock_test_mutex(); - let _guard = ShareStateGuard::inactive(); - - let result = update_share_password(" longer-password ".to_string()).await; - - assert_eq!(result, Err("Not currently sharing".to_string())); - } - - #[serial] - #[tokio::test] - async fn update_share_password_rotates_key_persists_password_and_clears_clients() { - let _serial = lock_test_mutex(); - let _home = TempHomeGuard::new(); - let _state = ShareStateGuard::active("/tmp/workspace".to_string(), 50123); - let _clients = ClientStateGuard::empty(); - AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert("session-1".to_string()); - CONNECTED_CLIENTS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert( - "session-1".to_string(), - ConnectedClient { - session_id: "session-1".to_string(), - ip: "198.51.100.10".to_string(), - user_agent: "unit-test".to_string(), - authenticated_at: "2026-06-11T00:00:00Z".to_string(), - last_active: "2026-06-11T00:01:00Z".to_string(), - ws_connected: true, - }, - ); - let (old_key, old_salt) = { - let state = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - ( - state.auth_key.clone().unwrap(), - state.auth_salt.clone().unwrap(), - ) - }; - - update_share_password("new-strong-password".to_string()) - .await - .expect("update password"); - - let state = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let new_key = state.auth_key.as_ref().unwrap(); - let new_salt = state.auth_salt.as_ref().unwrap(); - assert_eq!(new_key.len(), 32); - assert_eq!(new_salt.len(), 16); - assert_ne!(new_key, &old_key); - assert_ne!(new_salt, &old_salt); - drop(state); - assert!(AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_empty()); - assert!(CONNECTED_CLIENTS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_empty()); - assert_eq!( - load_global_config().share_password.as_deref(), - Some("new-strong-password") - ); - } - - #[serial] - #[test] - fn save_last_share_credentials_persists_port_and_password() { - let _serial = lock_test_mutex(); - let _home = TempHomeGuard::new(); - - save_last_share_credentials(Some(45678), "persisted-secret") - .expect("persist share credentials"); - - let config_path = crate::config::get_global_config_path(); - let content = std::fs::read_to_string(config_path).expect("read global config"); - let value: Value = serde_json::from_str(&content).expect("parse global config"); - - assert_eq!(value["last_share_port"], Value::from(45678)); - assert_eq!(value["share_password"], Value::from("persisted-secret")); - assert_eq!( - load_global_config().share_password, - Some("persisted-secret".to_string()) - ); - } - - #[serial] - #[tokio::test] - async fn saved_share_credentials_reload_through_public_getters() { - let _serial = lock_test_mutex(); - let _home = TempHomeGuard::new(); - - save_last_share_credentials(Some(45679), "reload-secret").expect("persist credentials"); - clear_global_config_cache(); - - assert_eq!(get_last_share_port().await.unwrap(), Some(45679)); - assert_eq!( - get_last_share_password().await.unwrap(), - Some("reload-secret".to_string()) - ); - } - - #[serial] - #[tokio::test] - async fn ngrok_token_getter_and_setter_persist_empty_as_none() { - let _serial = lock_test_mutex(); - let _home = TempHomeGuard::new(); - - assert_eq!(get_ngrok_token().await.unwrap(), None); - set_ngrok_token("token-123".to_string()).await.unwrap(); - assert_eq!( - get_ngrok_token().await.unwrap(), - Some("token-123".to_string()) - ); - set_ngrok_token(String::new()).await.unwrap(); - assert_eq!(get_ngrok_token().await.unwrap(), None); - } - - #[serial] - #[tokio::test] - async fn get_share_state_reports_inactive_and_active_snapshots() { - let _serial = lock_test_mutex(); - let workspace = tempfile::tempdir().expect("workspace"); - { - let _state = ShareStateGuard::inactive(); - let inactive = get_share_state().await.unwrap(); - assert!(!inactive.active); - assert!(inactive.urls.is_empty()); - assert_eq!(inactive.ngrok_url, None); - assert_eq!(inactive.workspace_path, None); - } - let _state = ShareStateGuard::active(workspace.path().to_string_lossy().to_string(), 51234); - { - let mut state = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state.ngrok_url = Some("https://unit.ngrok.test".to_string()); - } - - let active = get_share_state().await.unwrap(); - - assert!(active.active); - assert_eq!(active.ngrok_url.as_deref(), Some("https://unit.ngrok.test")); - assert_eq!( - active.workspace_path.as_deref(), - Some(workspace.path().to_str().unwrap()) - ); - assert!( - active.urls.iter().all(|url| url.ends_with(":51234")), - "urls were {:?}", - active.urls - ); - } - - #[serial] - #[tokio::test] - async fn stop_ngrok_tunnel_aborts_task_and_clears_url() { - let _serial = lock_test_mutex(); - let _state = ShareStateGuard::active("/tmp/workspace".to_string(), 51235); - let handle = TOKIO_RT.spawn(async { - tokio::time::sleep(std::time::Duration::from_secs(60)).await; - }); - { - let mut state = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state.ngrok_url = Some("https://unit.ngrok.test".to_string()); - state.ngrok_task = Some(handle); - } - - stop_ngrok_tunnel().await.expect("stop ngrok"); - - let state = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - assert!(state.ngrok_url.is_none()); - assert!(state.ngrok_task.is_none()); - } - - #[serial] - #[test] - fn stop_sharing_rejects_inactive_state() { - let _serial = lock_test_mutex(); - let _state = ShareStateGuard::inactive(); - - assert_eq!( - stop_sharing_internal(), - Err("Not currently sharing".to_string()) - ); - } - - #[serial] - #[tokio::test] - async fn stop_sharing_command_wrapper_returns_same_inactive_error() { - let _serial = lock_test_mutex(); - let _state = ShareStateGuard::inactive(); - - assert_eq!( - stop_sharing().await, - Err("Not currently sharing".to_string()) - ); - } - - #[serial] - #[test] - fn stop_sharing_resets_state_and_clears_sessions_and_clients() { - let _serial = lock_test_mutex(); - let _state = ShareStateGuard::active("/tmp/workspace".to_string(), 51236); - let _clients = ClientStateGuard::empty(); - AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert("session-1".to_string()); - CONNECTED_CLIENTS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert( - "session-1".to_string(), - ConnectedClient { - session_id: "session-1".to_string(), - ip: "203.0.113.10".to_string(), - user_agent: "unit-test".to_string(), - authenticated_at: "2026-06-11T00:00:00Z".to_string(), - last_active: "2026-06-11T00:01:00Z".to_string(), - ws_connected: false, - }, - ); - - stop_sharing_internal().expect("stop sharing"); - - let state = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - assert!(!state.active); - assert_eq!(state.workspace_path, None); - assert_eq!(state.port, 0); - assert!(state.auth_key.is_none()); - assert!(state.auth_salt.is_none()); - drop(state); - assert!(AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_empty()); - assert!(CONNECTED_CLIENTS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_empty()); - } - - #[serial] - #[test] - fn save_last_share_credentials_without_port_preserves_existing_port() { - let _serial = lock_test_mutex(); - let _home = TempHomeGuard::new(); - let initial = crate::GlobalConfig { - last_share_port: Some(50123), - share_password: Some("old-secret".to_string()), - ..crate::GlobalConfig::default() - }; - save_global_config_internal(&initial).expect("write initial config"); - - save_last_share_credentials(None, "new-secret").expect("persist password only"); - clear_global_config_cache(); - let config = load_global_config(); - - assert_eq!(config.last_share_port, Some(50123)); - assert_eq!(config.share_password, Some("new-secret".to_string())); - } - - #[serial] - #[test] - fn get_connected_clients_returns_current_client_snapshot() { - let _serial = lock_test_mutex(); - let _clients = ClientStateGuard::empty(); - let client = ConnectedClient { - session_id: "session-1".to_string(), - ip: "192.0.2.10".to_string(), - user_agent: "unit-test".to_string(), - authenticated_at: "2026-06-11T00:00:00Z".to_string(), - last_active: "2026-06-11T00:01:00Z".to_string(), - ws_connected: true, - }; - CONNECTED_CLIENTS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert(client.session_id.clone(), client.clone()); - - let clients = get_connected_clients(); - - assert_eq!(clients.len(), 1); - assert_eq!(clients[0].session_id, client.session_id); - assert_eq!(clients[0].ip, client.ip); - assert!(clients[0].ws_connected); - } - - #[serial] - #[test] - fn kick_client_internal_removes_session_and_client_records() { - let _serial = lock_test_mutex(); - let _clients = ClientStateGuard::empty(); - let mut notifications = CLIENT_NOTIFICATION_BROADCAST.subscribe(); - AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert("session-1".to_string()); - CONNECTED_CLIENTS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert( - "session-1".to_string(), - ConnectedClient { - session_id: "session-1".to_string(), - ip: "192.0.2.10".to_string(), - user_agent: "unit-test".to_string(), - authenticated_at: "2026-06-11T00:00:00Z".to_string(), - last_active: "2026-06-11T00:01:00Z".to_string(), - ws_connected: true, - }, - ); - - kick_client_internal("session-1").expect("kick client"); - - let notification = notifications.try_recv().expect("kick notification"); - let notification: Value = serde_json::from_str(¬ification).unwrap(); - assert_eq!(notification["session_id"], "session-1"); - assert_eq!(notification["type"], "kicked"); - assert_eq!(notification["reason"], "您已被管理员踢出"); - assert!(!AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .contains("session-1")); - assert!(!CONNECTED_CLIENTS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .contains_key("session-1")); - } - - #[serial] - #[test] - fn kick_client_command_wrapper_succeeds_for_missing_client_and_broadcasts() { - let _serial = lock_test_mutex(); - let _clients = ClientStateGuard::empty(); - let mut notifications = CLIENT_NOTIFICATION_BROADCAST.subscribe(); - - kick_client("missing-session".to_string()).expect("kick missing client"); - - let notification = notifications.try_recv().expect("kick notification"); - let notification: Value = serde_json::from_str(¬ification).unwrap(); - assert_eq!(notification["session_id"], "missing-session"); - assert!(AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_empty()); - assert!(CONNECTED_CLIENTS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_empty()); - } -} - -/// Kick a client by session ID: send WebSocket notification, then disconnect and remove session. -pub fn kick_client_internal(session_id: &str) -> Result<(), String> { - log::info!("[sharing] Kicking client: session_id={}", session_id); - - // Send kick notification via WebSocket broadcast before removing session - let notification = serde_json::json!({ - "session_id": session_id, - "type": "kicked", - "reason": "您已被管理员踢出" - }) - .to_string(); - let _ = CLIENT_NOTIFICATION_BROADCAST.send(notification); - log::info!( - "[sharing] Kick notification broadcast sent for session {}", - session_id - ); - - // Remove from authenticated sessions - if let Ok(mut sessions) = AUTHENTICATED_SESSIONS.lock() { - let removed = sessions.remove(session_id); - log::info!( - "[sharing] Session {} {} from authenticated sessions", - session_id, - if removed { "removed" } else { "not found" } - ); - } - - // Remove from connected clients - if let Ok(mut clients) = CONNECTED_CLIENTS.lock() { - let removed = clients.remove(session_id).is_some(); - log::info!( - "[sharing] Session {} {} from connected clients", - session_id, - if removed { "removed" } else { "not found" } - ); - } - - Ok(()) -} - -#[tauri::command] -pub(crate) fn kick_client(session_id: String) -> Result<(), String> { - kick_client_internal(&session_id) -} diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs deleted file mode 100644 index 758f536..0000000 --- a/src-tauri/src/commands/system.rs +++ /dev/null @@ -1,2937 +0,0 @@ -use std::path::PathBuf; -use std::process::{Command, Output}; -use std::time::Duration; - -use serde::Serialize; -use wait_timeout::ChildExt; - -use crate::types::OpenEditorRequest; -use crate::utils::{friendly_io_error, normalize_path}; -use crate::{pending_crash_report, CrashReport}; - -const TOOL_DETECTION_TIMEOUT_SECS: u64 = 10; - -// ==================== Tauri 命令:工具 ==================== - -#[tauri::command] -pub(crate) fn get_crash_report() -> Option { - match pending_crash_report().lock() { - Ok(mut pending) => pending.take(), - Err(e) => { - log::error!("[crash] failed to lock pending crash report: {}", e); - None - } - } -} - -fn command_for_log(cmd: &Command) -> String { - let mut parts = Vec::new(); - parts.push(cmd.get_program().to_string_lossy().to_string()); - parts.extend(cmd.get_args().map(|arg| arg.to_string_lossy().to_string())); - parts.join(" ") -} - -#[cfg(target_os = "windows")] -fn hide_command_window(cmd: &mut Command) { - use std::os::windows::process::CommandExt; - - const CREATE_NO_WINDOW: u32 = 0x08000000; - cmd.creation_flags(CREATE_NO_WINDOW); -} - -fn output_with_timeout(cmd: &mut Command, timeout_secs: u64) -> Option { - use std::io::Read; - - let command = command_for_log(cmd); - let mut child = match cmd.spawn() { - Ok(child) => child, - Err(e) => { - log::warn!("[system] Failed to spawn '{}': {}", command, e); - return None; - } - }; - - match child.wait_timeout(Duration::from_secs(timeout_secs)) { - Ok(Some(status)) => { - let mut stdout = Vec::new(); - if let Some(mut pipe) = child.stdout.take() { - if let Err(e) = pipe.read_to_end(&mut stdout) { - log::warn!("[system] Failed to read stdout from '{}': {}", command, e); - return None; - } - } - - let mut stderr = Vec::new(); - if let Some(mut pipe) = child.stderr.take() { - if let Err(e) = pipe.read_to_end(&mut stderr) { - log::warn!("[system] Failed to read stderr from '{}': {}", command, e); - return None; - } - } - - Some(Output { - status, - stdout, - stderr, - }) - } - Ok(None) => { - log::warn!( - "[system] Command timed out after {}s: {}", - timeout_secs, - command - ); - let _ = child.kill(); - let _ = child.wait(); - None - } - Err(e) => { - log::warn!("[system] Failed while waiting for '{}': {}", command, e); - let _ = child.kill(); - let _ = child.wait(); - None - } - } -} - -#[cfg(any(target_os = "windows", test))] -#[derive(Debug, PartialEq, Eq)] -struct WindowsTerminalLaunch { - program: String, - args: Vec, - current_dir: Option, -} - -#[cfg(any(target_os = "windows", test))] -fn path_like_executable(value: &str) -> bool { - std::path::Path::new(value).is_absolute() || value.contains('\\') || value.contains('/') -} - -#[cfg(any(target_os = "windows", test))] -fn git_bash_shell_path() -> String { - let candidates = [ - r"C:\Program Files\Git\bin\bash.exe", - r"C:\Program Files (x86)\Git\bin\bash.exe", - ]; - for path in &candidates { - if std::path::Path::new(path).exists() { - return path.to_string(); - } - } - if let Ok(local) = std::env::var("LOCALAPPDATA") { - let path = format!(r"{}\Programs\Git\bin\bash.exe", local); - if std::path::Path::new(&path).exists() { - return path; - } - } - "bash.exe".to_string() -} - -#[cfg(any(target_os = "windows", test))] -fn windows_shell_command(shell: Option<&str>) -> Vec { - let shell = shell - .map(str::trim) - .filter(|s| !s.is_empty() && *s != "auto"); - match shell { - Some("cmd") => vec!["cmd.exe".to_string()], - Some("powershell") => vec!["powershell.exe".to_string()], - Some("pwsh") => vec!["pwsh.exe".to_string()], - Some("gitbash") | Some("bash") => { - vec![ - git_bash_shell_path(), - "--login".to_string(), - "-i".to_string(), - ] - } - Some("nu") => vec!["nu.exe".to_string()], - Some(other) if path_like_executable(other) => vec![other.to_string()], - _ => Vec::new(), - } -} - -#[cfg(any(target_os = "windows", test))] -fn build_windows_terminal_launch( - normalized_path: &str, - terminal: Option<&str>, - shell: Option<&str>, -) -> WindowsTerminalLaunch { - let term = terminal - .map(str::trim) - .filter(|s| !s.is_empty()) - .unwrap_or("auto"); - - match term { - "cmd" => WindowsTerminalLaunch { - program: "cmd".to_string(), - args: vec![ - "/c".to_string(), - "start".to_string(), - "cmd".to_string(), - "/k".to_string(), - format!("cd /d {}", normalized_path), - ], - current_dir: None, - }, - "powershell" => WindowsTerminalLaunch { - program: "cmd".to_string(), - args: vec![ - "/c".to_string(), - "start".to_string(), - "powershell".to_string(), - "-NoExit".to_string(), - "-Command".to_string(), - format!("Set-Location '{}'", normalized_path), - ], - current_dir: None, - }, - "windowsterminal" | "auto" => { - let mut args = vec!["-d".to_string(), normalized_path.to_string()]; - args.extend(windows_shell_command(shell)); - WindowsTerminalLaunch { - program: "wt".to_string(), - args, - current_dir: None, - } - } - "gitbash" => { - let candidates = [ - r"C:\Program Files\Git\git-bash.exe", - r"C:\Program Files (x86)\Git\git-bash.exe", - ]; - let mut git_bash_path: Option = None; - for path in &candidates { - if std::path::Path::new(path).exists() { - git_bash_path = Some(path.to_string()); - break; - } - } - if git_bash_path.is_none() { - if let Ok(local) = std::env::var("LOCALAPPDATA") { - let path = format!(r"{}\Programs\Git\git-bash.exe", local); - if std::path::Path::new(&path).exists() { - git_bash_path = Some(path); - } - } - } - WindowsTerminalLaunch { - program: git_bash_path.unwrap_or_else(|| "git-bash.exe".to_string()), - args: vec![format!("--cd={}", normalized_path)], - current_dir: None, - } - } - other => WindowsTerminalLaunch { - program: other.to_string(), - args: Vec::new(), - current_dir: Some(PathBuf::from(normalized_path)), - }, - } -} - -#[cfg(target_os = "windows")] -fn spawn_windows_terminal_launch( - launch: &WindowsTerminalLaunch, - create_no_window: u32, -) -> std::io::Result { - use std::os::windows::process::CommandExt; - - let mut command = Command::new(&launch.program); - command.args(&launch.args).creation_flags(create_no_window); - if let Some(dir) = &launch.current_dir { - command.current_dir(dir); - } - command.spawn() -} - -#[tauri::command] -pub(crate) fn open_in_terminal( - path: String, - terminal: Option, - shell: Option, -) -> Result<(), String> { - let normalized = normalize_path(&path); - let term = terminal.as_deref().unwrap_or("auto"); - log::info!( - "[system] Opening terminal at: {} (type: {}, shell: {:?})", - normalized, - term, - shell - ); - - #[cfg(target_os = "macos")] - { - let app_name = match term { - "iterm2" => "iTerm", - "warp" => "Warp", - "alacritty" => "Alacritty", - "kitty" => "kitty", - "ghostty" => "Ghostty", - _ => "Terminal", // "terminal", "auto", or unknown - }; - match Command::new("open") - .args(["-a", app_name, &normalized]) - .spawn() - { - Ok(_) => log::info!("[system] Spawned {} for: {}", app_name, normalized), - Err(e) => { - log::error!("[system] Failed to spawn {}: {}", app_name, e); - return Err(format!( - "无法打开终端 {}:{}", - app_name, - friendly_io_error(&e) - )); - } - } - } - - #[cfg(target_os = "windows")] - { - const CREATE_NO_WINDOW: u32 = 0x08000000; - - let launch = - build_windows_terminal_launch(&normalized, terminal.as_deref(), shell.as_deref()); - log::info!("[system] Windows terminal launch plan: {:?}", launch); - let mut result = spawn_windows_terminal_launch(&launch, CREATE_NO_WINDOW); - if result.is_err() && term == "auto" { - let fallback = build_windows_terminal_launch(&normalized, Some("cmd"), None); - log::warn!( - "[system] Auto terminal launch failed; falling back to: {:?}", - fallback - ); - result = spawn_windows_terminal_launch(&fallback, CREATE_NO_WINDOW); - } - - match result { - Ok(_) => log::info!("[system] Spawned terminal '{}' for: {}", term, normalized), - Err(e) => { - log::error!("[system] Failed to spawn terminal '{}': {}", term, e); - return Err(format!("无法打开终端:{}", friendly_io_error(&e))); - } - } - } - - #[cfg(target_os = "linux")] - { - let terminals = ["x-terminal-emulator", "gnome-terminal", "konsole", "xterm"]; - let mut opened = false; - for t in &terminals { - let result = if *t == "gnome-terminal" { - Command::new(t) - .args(["--working-directory", &normalized]) - .spawn() - } else { - Command::new(t).current_dir(&normalized).spawn() - }; - if result.is_ok() { - log::info!("[system] Spawned {} for: {}", t, normalized); - opened = true; - break; - } - } - if !opened { - log::error!("[system] No terminal emulator found on Linux"); - return Err("No terminal emulator found".to_string()); - } - } - - Ok(()) -} - -fn editor_cli_command(editor: &str) -> &'static str { - match editor { - "vscode" => "code", - "cursor" => "cursor", - "antigravity" => "antigravity", - "idea" => "idea", - "codex" => "codex", - _ => "code", - } -} - -#[cfg(target_os = "macos")] -fn editor_app_name(editor: &str) -> &'static str { - match editor { - "vscode" => "Visual Studio Code", - "cursor" => "Cursor", - "antigravity" => "Antigravity", - "idea" => "IntelliJ IDEA", - "codex" => "Codex", - _ => "Visual Studio Code", - } -} - -pub(crate) fn open_editor_at_path( - request: &OpenEditorRequest, - custom_path: Option<&str>, -) -> Result<(), String> { - let path = &request.path; - log::info!( - "[system] Opening editor: type={}, path={}, custom={:?}", - request.editor, - path, - custom_path - ); - - // If custom path is provided, use it directly - if let Some(exe) = custom_path { - if !exe.is_empty() { - // On macOS, .app bundles need to be opened via `open -a` - #[cfg(target_os = "macos")] - if exe.ends_with(".app") { - match Command::new("open").args(["-a", exe, path]).spawn() { - Ok(_) => { - log::info!( - "[system] Spawned custom editor via open -a '{}' for: {}", - exe, - path - ); - return Ok(()); - } - Err(e) => { - log::error!("[system] Failed to open editor app '{}': {}", exe, e); - return Err(format!("无法打开编辑器 {}:{}", exe, friendly_io_error(&e))); - } - } - } - // Codex uses subcommand: `codex app ` - // First invocation launches the app; after a delay, second invocation opens the path. - let spawn_result = if request.editor == "codex" { - Command::new(exe).args(["app", path]).spawn() - } else { - Command::new(exe).arg(path).spawn() - }; - match spawn_result { - Ok(_) => { - log::info!("[system] Spawned custom editor '{}' for: {}", exe, path); - if request.editor == "codex" { - let exe_owned = exe.to_string(); - let path_owned = path.to_string(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(3)); - let _ = Command::new(&exe_owned).args(["app", &path_owned]).spawn(); - }); - } - return Ok(()); - } - Err(e) => { - log::error!("[system] Failed to spawn custom editor '{}': {}", exe, e); - return Err(format!("无法打开编辑器 {}:{}", exe, friendly_io_error(&e))); - } - } - } - } - - #[cfg(target_os = "macos")] - { - // Codex uses subcommand: `codex app ` - // First invocation launches the app; after a delay, second invocation opens the path. - if request.editor == "codex" { - let cmd = editor_cli_command(&request.editor); - match Command::new(cmd).args(["app", path]).spawn() { - Ok(_) => { - log::info!("[system] Spawned {} app (1st, launch) for: {}", cmd, path); - // Spawn background thread to send the command again after the app starts - let path_owned = path.to_string(); - let cmd_owned = cmd.to_string(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(3)); - match Command::new(&cmd_owned).args(["app", &path_owned]).spawn() { - Ok(_) => log::info!("[system] Spawned {} app (2nd, open path) for: {}", cmd_owned, path_owned), - Err(e) => log::warn!("[system] Codex 2nd invocation failed (app may already have the path): {}", e), - } - }); - return Ok(()); - } - Err(e) => { - log::error!("[system] Failed to spawn codex: {}", e); - return Err(format!( - "无法打开 Codex,请确认已安装该编辑器:{}", - friendly_io_error(&e) - )); - } - } - } - let app_name = editor_app_name(&request.editor); - if Command::new("open") - .args(["-a", app_name, path]) - .spawn() - .is_ok() - { - log::info!("[system] Spawned {} via open -a for: {}", app_name, path); - return Ok(()); - } - let cmd = editor_cli_command(&request.editor); - match Command::new(cmd).arg(path).spawn() { - Ok(_) => { - log::info!("[system] Spawned {} CLI for: {}", cmd, path); - } - Err(e) => { - log::error!("[system] Failed to spawn editor process: {}", e); - return Err(format!("无法打开 {},请确认已安装该编辑器", app_name)); - } - } - } - - #[cfg(target_os = "windows")] - { - let cmd = editor_cli_command(&request.editor); - if request.editor == "codex" { - // Windows: Codex is a UWP app, launch via shell:AppsFolder - let aumid = r"OpenAI.Codex_2p2nqsd0c76g0!App"; - match Command::new("explorer") - .arg(format!(r"shell:AppsFolder\{}", aumid)) - .spawn() - { - Ok(_) => log::info!("[system] Launched Codex UWP app"), - Err(e) => { - log::error!("[system] Failed to launch Codex UWP: {}", e); - return Err(format!("无法打开 Codex:{}", friendly_io_error(&e))); - } - } - } else { - match Command::new(cmd).arg(path).spawn() { - Ok(_) => { - log::info!("[system] Spawned {} for: {}", cmd, path); - } - Err(e) => { - log::error!("[system] Failed to spawn editor process: {}", e); - return Err(format!("无法打开编辑器 {}:{}", cmd, friendly_io_error(&e))); - } - } - } - } - - #[cfg(target_os = "linux")] - { - let cmd = editor_cli_command(&request.editor); - // Codex uses subcommand: `codex app ` - let spawn_result = if request.editor == "codex" { - Command::new(cmd).args(["app", path]).spawn() - } else { - Command::new(cmd).arg(path).spawn() - }; - match spawn_result { - Ok(_) => { - log::info!("[system] Spawned {} for: {}", cmd, path); - if request.editor == "codex" { - let path_owned = path.to_string(); - let cmd_owned = cmd.to_string(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(3)); - let _ = Command::new(&cmd_owned).args(["app", &path_owned]).spawn(); - }); - } - } - Err(e) => { - log::error!("[system] Failed to spawn editor process: {}", e); - return Err(format!("无法打开编辑器 {}:{}", cmd, friendly_io_error(&e))); - } - } - } - - Ok(()) -} - -#[tauri::command] -pub(crate) fn open_in_editor( - request: OpenEditorRequest, - custom_path: Option, -) -> Result<(), String> { - open_editor_at_path(&request, custom_path.as_deref()) -} - -#[tauri::command] -pub(crate) fn reveal_in_finder(path: String) -> Result<(), String> { - let normalized = normalize_path(&path); - log::info!("[system] Revealing in file manager: {}", normalized); - - #[cfg(target_os = "macos")] - { - match Command::new("open").arg(&normalized).spawn() { - Ok(_) => log::info!("[system] Spawned Finder for: {}", normalized), - Err(e) => { - log::error!("[system] Failed to spawn Finder: {}", e); - return Err(format!("无法打开文件夹:{}", friendly_io_error(&e))); - } - } - } - - #[cfg(target_os = "windows")] - { - match Command::new("explorer").arg(&normalized).spawn() { - Ok(_) => log::info!("[system] Spawned Explorer for: {}", normalized), - Err(e) => { - log::error!("[system] Failed to spawn Explorer: {}", e); - return Err(format!("无法打开文件夹:{}", friendly_io_error(&e))); - } - } - } - - #[cfg(target_os = "linux")] - { - match Command::new("xdg-open").arg(&normalized).spawn() { - Ok(_) => log::info!("[system] Spawned xdg-open for: {}", normalized), - Err(e) => { - log::error!("[system] Failed to spawn xdg-open: {}", e); - return Err(format!("无法打开文件夹:{}", friendly_io_error(&e))); - } - } - } - - Ok(()) -} - -#[tauri::command] -pub(crate) fn open_log_dir() -> Result<(), String> { - let log_dir = get_platform_log_dir()?; - log::info!("[system] Opening log directory: {:?}", log_dir); - - if !log_dir.exists() { - log::info!( - "[system] Log directory does not exist, creating: {:?}", - log_dir - ); - std::fs::create_dir_all(&log_dir) - .map_err(|e| format!("无法创建日志目录:{}", friendly_io_error(&e)))?; - } - - let dir_str = log_dir.to_str().unwrap_or(""); - - #[cfg(target_os = "macos")] - { - match Command::new("open").arg(dir_str).spawn() { - Ok(_) => log::info!("[system] Spawned Finder for log directory"), - Err(e) => { - log::error!("[system] Failed to open log directory: {}", e); - return Err(format!("无法打开日志目录:{}", friendly_io_error(&e))); - } - } - } - - #[cfg(target_os = "windows")] - { - match Command::new("explorer").arg(dir_str).spawn() { - Ok(_) => log::info!("[system] Spawned Explorer for log directory"), - Err(e) => { - log::error!("[system] Failed to open log directory: {}", e); - return Err(format!("无法打开日志目录:{}", friendly_io_error(&e))); - } - } - } - - #[cfg(target_os = "linux")] - { - match Command::new("xdg-open").arg(dir_str).spawn() { - Ok(_) => log::info!("[system] Spawned xdg-open for log directory"), - Err(e) => { - log::error!("[system] Failed to open log directory: {}", e); - return Err(format!("无法打开日志目录:{}", friendly_io_error(&e))); - } - } - } - - Ok(()) -} - -/// Extract the icon of an app/exe as a base64 data URL. -#[tauri::command] -#[allow(unused_variables)] -pub(crate) fn get_app_icon(path: String) -> Option { - #[cfg(target_os = "macos")] - { - return extract_macos_app_icon(&path); - } - #[cfg(target_os = "windows")] - { - let icon_map = extract_windows_exe_icons_batch(std::slice::from_ref(&path)); - return icon_map - .get(&path) - .filter(|s| !s.is_empty()) - .map(|b64| format!("data:image/png;base64,{}", b64)); - } - #[allow(unreachable_code)] - None -} - -/// Get the platform-appropriate log directory. -fn get_platform_log_dir() -> Result { - #[cfg(target_os = "macos")] - { - let home = std::env::var("HOME").map_err(|_| "无法获取用户目录".to_string())?; - Ok(PathBuf::from(home).join("Library/Logs/com.guo.worktree-manager")) - } - #[cfg(target_os = "windows")] - { - // Tauri on Windows logs to %LOCALAPPDATA%/{identifier}/logs - let appdata = std::env::var("LOCALAPPDATA") - .or_else(|_| std::env::var("APPDATA")) - .map_err(|_| "无法获取 LOCALAPPDATA 目录".to_string())?; - Ok(PathBuf::from(appdata) - .join("com.guo.worktree-manager") - .join("logs")) - } - #[cfg(target_os = "linux")] - { - // Tauri on Linux logs to $XDG_DATA_HOME/{identifier}/logs or ~/.local/share/... - let data_home = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| { - let home = std::env::var("HOME").unwrap_or_default(); - format!("{}/.local/share", home) - }); - Ok(PathBuf::from(data_home) - .join("com.guo.worktree-manager") - .join("logs")) - } -} - -// ==================== App Icon Extraction ==================== - -/// Extract the app icon from a macOS .app bundle and return as base64 data URL. -/// Reads Info.plist → CFBundleIconFile → converts .icns to 32x32 PNG → base64. -#[cfg(target_os = "macos")] -fn extract_macos_app_icon(app_path: &str) -> Option { - use std::process::Command; - - let app = std::path::Path::new(app_path); - if !app.exists() { - return None; - } - - // Step 1: Read CFBundleIconFile from Info.plist - let plist_output = Command::new("/usr/bin/defaults") - .arg("read") - .arg( - app.join("Contents/Info.plist") - .to_string_lossy() - .to_string(), - ) - .arg("CFBundleIconFile") - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) - .output() - .ok()?; - - if !plist_output.status.success() { - return None; - } - - let mut icon_file = String::from_utf8_lossy(&plist_output.stdout) - .trim() - .to_string(); - if icon_file.is_empty() { - return None; - } - - // Ensure .icns extension - if !icon_file.ends_with(".icns") { - icon_file.push_str(".icns"); - } - - let icns_path = app.join("Contents/Resources").join(&icon_file); - if !icns_path.exists() { - return None; - } - - // Step 2: Convert .icns to 32x32 PNG using sips - let tmp_png = format!( - "/tmp/wm_icon_{}.png", - app.file_name()?.to_string_lossy().replace(' ', "_") - ); - let sips_output = Command::new("/usr/bin/sips") - .args(["-s", "format", "png"]) - .arg(icns_path.to_string_lossy().to_string()) - .args(["--out", &tmp_png]) - .args(["--resampleWidth", "256"]) - .args(["-z", "256", "256"]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .output() - .ok()?; - - if !sips_output.status.success() { - return None; - } - - // Step 3: Read PNG and base64 encode - let png_data = std::fs::read(&tmp_png).ok()?; - // Clean up temp file - let _ = std::fs::remove_file(&tmp_png); - - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(&png_data); - Some(format!("data:image/png;base64,{}", b64)) -} - -/// Batch-extract icons from multiple Windows .exe files. -/// Returns a map of exe_path → base64 PNG data. -#[cfg(target_os = "windows")] -fn extract_windows_exe_icons_batch(paths: &[String]) -> std::collections::HashMap { - let mut result = std::collections::HashMap::new(); - for path in paths { - match windows_icons::get_icon_base64_by_path(path) { - Ok(b64) if !b64.is_empty() => { - result.insert(path.clone(), b64); - } - Ok(_) => { - log::warn!("[system] Icon extraction returned empty for: {}", path); - } - Err(e) => { - log::warn!("[system] Icon extraction failed for {}: {}", path, e); - } - } - } - log::debug!( - "[system] Icon extraction succeeded for {}/{} paths", - result.len(), - paths.len() - ); - result -} -// ==================== Tool Detection ==================== - -#[derive(Debug, Clone, Serialize, Default)] -pub struct DetectedTool { - pub id: String, - pub name: String, - pub path: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub icon: Option, -} - -#[derive(Debug, Clone, Serialize, Default)] -pub struct DetectedTools { - pub git: Vec, - pub terminals: Vec, - pub editors: Vec, - pub shells: Vec, -} - -fn check_executable(name: &str) -> Option { - #[cfg(target_os = "windows")] - { - let mut command = Command::new("where"); - command - .arg(name) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()); - hide_command_window(&mut command); - let output = output_with_timeout(&mut command, TOOL_DETECTION_TIMEOUT_SECS)?; - if output.status.success() { - let s = String::from_utf8_lossy(&output.stdout); - return s.lines().next().map(|l| l.trim().to_string()); - } - None - } - #[cfg(not(target_os = "windows"))] - { - let mut command = Command::new("/usr/bin/which"); - command - .arg(name) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()); - let output = output_with_timeout(&mut command, TOOL_DETECTION_TIMEOUT_SECS)?; - if output.status.success() { - let s = String::from_utf8_lossy(&output.stdout); - return s.lines().next().map(|l| l.trim().to_string()); - } - None - } -} - -fn detect_git() -> Vec { - let mut results = Vec::new(); - - if let Some(path) = check_executable("git") { - results.push(DetectedTool { - id: "git".into(), - name: "Git".into(), - path, - icon: None, - }); - } - - #[cfg(target_os = "windows")] - { - let candidates = [ - (r"C:\Program Files\Git\cmd\git.exe", "Git (Program Files)"), - (r"C:\Program Files (x86)\Git\cmd\git.exe", "Git (x86)"), - ]; - for (p, name) in &candidates { - if std::path::Path::new(p).exists() && !results.iter().any(|r| r.path == *p) { - results.push(DetectedTool { - id: "git".into(), - name: name.to_string(), - path: p.to_string(), - icon: None, - }); - } - } - if let Ok(local) = std::env::var("LOCALAPPDATA") { - let p = format!(r"{}\Programs\Git\cmd\git.exe", local); - if std::path::Path::new(&p).exists() && !results.iter().any(|r| r.path == p) { - results.push(DetectedTool { - id: "git".into(), - name: "Git (User)".into(), - path: p, - icon: None, - }); - } - } - } - - results -} - -fn detect_terminals() -> Vec { - let mut results = Vec::new(); - - #[cfg(target_os = "macos")] - { - results.push(DetectedTool { - id: "terminal".into(), - name: "Terminal.app".into(), - path: "/System/Applications/Utilities/Terminal.app".into(), - icon: None, - }); - if std::path::Path::new("/Applications/iTerm.app").exists() { - results.push(DetectedTool { - id: "iterm2".into(), - name: "iTerm2".into(), - path: "/Applications/iTerm.app".into(), - icon: None, - }); - } - if std::path::Path::new("/Applications/Warp.app").exists() { - results.push(DetectedTool { - id: "warp".into(), - name: "Warp".into(), - path: "/Applications/Warp.app".into(), - icon: None, - }); - } - if std::path::Path::new("/Applications/Alacritty.app").exists() { - results.push(DetectedTool { - id: "alacritty".into(), - name: "Alacritty".into(), - path: "/Applications/Alacritty.app".into(), - icon: None, - }); - } - if std::path::Path::new("/Applications/kitty.app").exists() { - results.push(DetectedTool { - id: "kitty".into(), - name: "kitty".into(), - path: "/Applications/kitty.app".into(), - icon: None, - }); - } - if std::path::Path::new("/Applications/Ghostty.app").exists() { - results.push(DetectedTool { - id: "ghostty".into(), - name: "Ghostty".into(), - path: "/Applications/Ghostty.app".into(), - icon: None, - }); - } - } - - #[cfg(target_os = "windows")] - { - results.push(DetectedTool { - id: "cmd".into(), - name: "CMD".into(), - path: "cmd.exe".into(), - icon: None, - }); - results.push(DetectedTool { - id: "powershell".into(), - name: "PowerShell".into(), - path: "powershell.exe".into(), - icon: None, - }); - if check_executable("wt").is_some() { - results.push(DetectedTool { - id: "windowsterminal".into(), - name: "Windows Terminal".into(), - path: "wt.exe".into(), - icon: None, - }); - } - // Git Bash terminal — keep in sync with build_windows_terminal_launch() in this file. - let mut gitbash_terminal_found = false; - let git_bash_terminal_candidates = [ - r"C:\Program Files\Git\git-bash.exe", - r"C:\Program Files (x86)\Git\git-bash.exe", - ]; - for p in &git_bash_terminal_candidates { - if std::path::Path::new(p).exists() { - results.push(DetectedTool { - id: "gitbash".into(), - name: "Git Bash".into(), - path: p.to_string(), - icon: None, - }); - gitbash_terminal_found = true; - break; - } - } - if !gitbash_terminal_found { - if let Ok(local) = std::env::var("LOCALAPPDATA") { - let p = format!(r"{}\Programs\Git\git-bash.exe", local); - if std::path::Path::new(&p).exists() { - results.push(DetectedTool { - id: "gitbash".into(), - name: "Git Bash".into(), - path: p, - icon: None, - }); - } - } - } - } - - #[cfg(target_os = "linux")] - { - let terminals = [ - ("gnome-terminal", "GNOME Terminal"), - ("konsole", "Konsole"), - ("xfce4-terminal", "XFCE Terminal"), - ("xterm", "XTerm"), - ("alacritty", "Alacritty"), - ("kitty", "kitty"), - ("ghostty", "Ghostty"), - ("wezterm", "WezTerm"), - ("tilix", "Tilix"), - ]; - for (cmd, name) in &terminals { - if let Some(path) = check_executable(cmd) { - results.push(DetectedTool { - id: cmd.to_string(), - name: name.to_string(), - path, - icon: None, - }); - } - } - } - - // Extract icons — same pattern as detect_editors() - #[cfg(target_os = "macos")] - for tool in results.iter_mut() { - if tool.icon.is_none() && tool.path.ends_with(".app") { - tool.icon = extract_macos_app_icon(&tool.path); - } - } - - #[cfg(target_os = "windows")] - { - let exe_paths: Vec = results - .iter() - .filter(|t| t.icon.is_none() && t.path.to_ascii_lowercase().ends_with(".exe")) - .map(|t| t.path.clone()) - .collect(); - if !exe_paths.is_empty() { - let icon_map = extract_windows_exe_icons_batch(&exe_paths); - for tool in results.iter_mut() { - if tool.icon.is_none() { - if let Some(b64) = icon_map.get(&tool.path) { - if !b64.is_empty() { - tool.icon = Some(format!("data:image/png;base64,{}", b64)); - } - } - } - } - } - } - - results -} - -/// Query Windows registry (HKLM + HKCU uninstall keys) for installed editors. -/// Returns actual .exe paths, enabling correct icon extraction. -#[cfg(target_os = "windows")] -fn detect_editors_via_registry() -> Vec { - // Each entry: (display_name_substring, id, friendly_name, exe_relative_to_InstallLocation) - // Paths use Windows backslash. Pattern matching is case-insensitive (-like). - let ps_script = r#" -$editors = @( - [pscustomobject]@{P='Microsoft Visual Studio Code';Id='vscode';N='VS Code';E='Code.exe'}, - [pscustomobject]@{P='Visual Studio Code - Insiders';Id='vscode-insiders';N='VS Code Insiders';E='Code - Insiders.exe'}, - [pscustomobject]@{P='VSCodium';Id='vscodium';N='VSCodium';E='VSCodium.exe'}, - [pscustomobject]@{P='Cursor';Id='cursor';N='Cursor';E='Cursor.exe'}, - [pscustomobject]@{P='Windsurf';Id='windsurf';N='Windsurf';E='Windsurf.exe'}, - [pscustomobject]@{P='Trae';Id='trae';N='Trae';E='Trae.exe'}, - [pscustomobject]@{P='Antigravity';Id='antigravity';N='Antigravity';E='Antigravity.exe'}, - [pscustomobject]@{P='IntelliJ IDEA';Id='idea';N='IntelliJ IDEA';E='bin\idea64.exe'}, - [pscustomobject]@{P='WebStorm';Id='webstorm';N='WebStorm';E='bin\webstorm64.exe'}, - [pscustomobject]@{P='PyCharm';Id='pycharm';N='PyCharm';E='bin\pycharm64.exe'}, - [pscustomobject]@{P='GoLand';Id='goland';N='GoLand';E='bin\goland64.exe'}, - [pscustomobject]@{P='Rider';Id='rider';N='Rider';E='bin\rider64.exe'}, - [pscustomobject]@{P='CLion';Id='clion';N='CLion';E='bin\clion64.exe'}, - [pscustomobject]@{P='RustRover';Id='rustrover';N='RustRover';E='bin\rustrover64.exe'}, - [pscustomobject]@{P='Fleet';Id='fleet';N='Fleet';E='bin\Fleet.exe'}, - [pscustomobject]@{P='DataGrip';Id='datagrip';N='DataGrip';E='bin\datagrip64.exe'}, - [pscustomobject]@{P='PhpStorm';Id='phpstorm';N='PhpStorm';E='bin\phpstorm64.exe'}, - [pscustomobject]@{P='Android Studio';Id='android-studio';N='Android Studio';E='bin\studio64.exe'}, - [pscustomobject]@{P='Sublime Text';Id='sublime';N='Sublime Text';E='sublime_text.exe'}, - [pscustomobject]@{P='Zed';Id='zed';N='Zed';E='zed.exe'} -) -$found = @{} -$regPaths = @( - 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', - 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', - 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' -) -foreach ($rp in $regPaths) { - if (-not (Test-Path $rp)) { continue } - Get-ChildItem $rp -ErrorAction SilentlyContinue | ForEach-Object { - $app = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue - if (-not $app -or -not $app.DisplayName -or -not $app.InstallLocation) { return } - foreach ($ed in $editors) { - if ($found.ContainsKey($ed.Id)) { continue } - if ($app.DisplayName -like "*$($ed.P)*") { - $exePath = Join-Path $app.InstallLocation $ed.E - if (Test-Path $exePath) { - $found[$ed.Id] = [pscustomobject]@{id=$ed.Id;name=$ed.N;path=$exePath} - } - } - } - } -} -$result = @($found.Values) -if ($result.Count -gt 0) { ConvertTo-Json -InputObject $result -Compress } else { Write-Output '[]' } -"#; - - let mut command = Command::new("powershell"); - command - .args(["-NoProfile", "-NonInteractive", "-Command", ps_script]) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()); - hide_command_window(&mut command); - let output = match output_with_timeout(&mut command, TOOL_DETECTION_TIMEOUT_SECS) { - Some(output) => output, - None => { - log::warn!("[system] Registry editor scan failed or timed out"); - return Vec::new(); - } - }; - - let stdout = String::from_utf8_lossy(&output.stdout); - let trimmed = stdout.trim(); - if trimmed.is_empty() || trimmed == "null" || trimmed == "[]" { - return Vec::new(); - } - - #[derive(serde::Deserialize)] - struct RegEntry { - id: String, - name: String, - path: String, - } - - // ConvertTo-Json may emit an object (not array) when count == 1; handle both - let entries: Vec = serde_json::from_str(trimmed) - .or_else(|_| serde_json::from_str::(trimmed).map(|r| vec![r])) - .unwrap_or_default(); - - entries - .into_iter() - .map(|r| DetectedTool { - id: r.id, - name: r.name, - path: r.path, - icon: None, - }) - .collect() -} - -fn detect_editors() -> Vec { - let mut results = Vec::new(); - - #[cfg(not(target_os = "windows"))] - let editors: &[(&str, &str, &str)] = &[ - ("code", "vscode", "Visual Studio Code"), - ("cursor", "cursor", "Cursor"), - ("antigravity", "antigravity", "Antigravity"), - ("idea", "idea", "IntelliJ IDEA"), - ("codex", "codex", "Codex"), - ("zed", "zed", "Zed"), - ("sublime_text", "sublime", "Sublime Text"), - ]; - - #[cfg(not(target_os = "windows"))] - for (cmd, id, name) in editors { - if let Some(path) = check_executable(cmd) { - results.push(DetectedTool { - id: id.to_string(), - name: name.to_string(), - path, - icon: None, - }); - } - } - - #[cfg(target_os = "macos")] - { - // Comprehensive scan of /Applications for all known IDEs/editors - let mac_apps: &[(&str, &str, &str)] = &[ - // VS Code family - ("/Applications/Visual Studio Code.app", "vscode", "VS Code"), - ( - "/Applications/Visual Studio Code - Insiders.app", - "vscode-insiders", - "VS Code Insiders", - ), - ("/Applications/VSCodium.app", "vscodium", "VSCodium"), - // AI-powered editors - ("/Applications/Cursor.app", "cursor", "Cursor"), - ( - "/Applications/Antigravity.app", - "antigravity", - "Antigravity", - ), - ("/Applications/Windsurf.app", "windsurf", "Windsurf"), - ("/Applications/Trae.app", "trae", "Trae"), - // JetBrains family - ("/Applications/IntelliJ IDEA.app", "idea", "IntelliJ IDEA"), - ( - "/Applications/IntelliJ IDEA CE.app", - "idea-ce", - "IntelliJ IDEA CE", - ), - ("/Applications/WebStorm.app", "webstorm", "WebStorm"), - ("/Applications/PyCharm.app", "pycharm", "PyCharm"), - ("/Applications/PyCharm CE.app", "pycharm-ce", "PyCharm CE"), - ("/Applications/GoLand.app", "goland", "GoLand"), - ("/Applications/Rider.app", "rider", "Rider"), - ("/Applications/CLion.app", "clion", "CLion"), - ("/Applications/RustRover.app", "rustrover", "RustRover"), - ("/Applications/Fleet.app", "fleet", "Fleet"), - ("/Applications/DataGrip.app", "datagrip", "DataGrip"), - ("/Applications/PhpStorm.app", "phpstorm", "PhpStorm"), - ("/Applications/Aqua.app", "aqua", "Aqua"), - // Apple - ("/Applications/Xcode.app", "xcode", "Xcode"), - // Google - ( - "/Applications/Android Studio.app", - "android-studio", - "Android Studio", - ), - // Other editors - ("/Applications/Zed.app", "zed", "Zed"), - ("/Applications/Sublime Text.app", "sublime", "Sublime Text"), - ("/Applications/Nova.app", "nova", "Nova"), - ("/Applications/BBEdit.app", "bbedit", "BBEdit"), - ("/Applications/TextMate.app", "textmate", "TextMate"), - ("/Applications/CotEditor.app", "coteditor", "CotEditor"), - ("/Applications/Codex.app", "codex", "Codex"), - ]; - for (app_path, id, name) in mac_apps { - if std::path::Path::new(app_path).exists() && !results.iter().any(|r| r.id == *id) { - let icon = extract_macos_app_icon(app_path); - results.push(DetectedTool { - id: id.to_string(), - name: name.to_string(), - path: app_path.to_string(), - icon, - }); - } - } - // Backfill icons for CLI-detected editors using known .app paths - let app_lookup: std::collections::HashMap<&str, &str> = - mac_apps.iter().map(|(path, id, _)| (*id, *path)).collect(); - for tool in results.iter_mut() { - if tool.icon.is_none() { - if let Some(app_path) = app_lookup.get(tool.id.as_str()) { - if std::path::Path::new(app_path).exists() { - tool.icon = extract_macos_app_icon(app_path); - } - } - } - } - } - - #[cfg(target_os = "windows")] - { - // Primary: registry-based detection — handles all install locations (Programs, Toolbox, etc.) - // and provides actual .exe paths for correct icon extraction. - for tool in detect_editors_via_registry() { - if !results.iter().any(|r: &DetectedTool| r.id == tool.id) { - results.push(tool); - } - } - - // Secondary: CLI detection for tools in PATH but absent from registry - // (e.g., installed via scoop / winget without standard registry entries) - let cli_fallback: &[(&str, &str, &str)] = &[ - ("code.cmd", "vscode", "Visual Studio Code"), - ("cursor.cmd", "cursor", "Cursor"), - ("antigravity.cmd", "antigravity", "Antigravity"), - ("idea.cmd", "idea", "IntelliJ IDEA"), - ("codex.cmd", "codex", "Codex"), - ("zed.cmd", "zed", "Zed"), - ("subl.exe", "sublime", "Sublime Text"), - ]; - for (cmd, id, name) in cli_fallback { - if !results.iter().any(|r| r.id == *id) { - if let Some(path) = check_executable(cmd) { - results.push(DetectedTool { - id: id.to_string(), - name: name.to_string(), - path, - icon: None, - }); - } - } - } - - // Codex UWP (Windows Store app — not in the standard uninstall registry) - if !results.iter().any(|r| r.id == "codex") { - let mut command = Command::new("powershell"); - command - .args(["-NoProfile", "-Command", "Get-AppxPackage -Name 'OpenAI.Codex' | Select-Object -ExpandProperty InstallLocation"]) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()); - hide_command_window(&mut command); - if let Some(output) = output_with_timeout(&mut command, TOOL_DETECTION_TIMEOUT_SECS) { - if output.status.success() { - let location = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !location.is_empty() { - results.push(DetectedTool { - id: "codex".into(), - name: "Codex (UWP)".into(), - path: location, - icon: None, - }); - } - } - } - } - - // Batch icon extraction: single PowerShell process for all .exe paths - let exe_paths: Vec = results - .iter() - .filter(|t| t.icon.is_none() && t.path.to_ascii_lowercase().ends_with(".exe")) - .map(|t| t.path.clone()) - .collect(); - if !exe_paths.is_empty() { - let icon_map = extract_windows_exe_icons_batch(&exe_paths); - for tool in results.iter_mut() { - if tool.icon.is_none() { - if let Some(b64) = icon_map.get(&tool.path) { - if !b64.is_empty() { - tool.icon = Some(format!("data:image/png;base64,{}", b64)); - } - } - } - } - } - } - - results -} - -fn detect_shells() -> Vec { - let mut results = Vec::new(); - - #[cfg(not(target_os = "windows"))] - { - let shells = [ - ("zsh", "Zsh"), - ("bash", "Bash"), - ("fish", "Fish"), - ("nu", "Nushell"), - ]; - for (cmd, name) in &shells { - if let Some(path) = check_executable(cmd) { - results.push(DetectedTool { - id: cmd.to_string(), - name: name.to_string(), - path, - icon: None, - }); - } - } - } - - #[cfg(target_os = "windows")] - { - // PowerShell 7+ (pwsh) - if let Some(path) = check_executable("pwsh") { - results.push(DetectedTool { - id: "pwsh".into(), - name: "PowerShell 7".into(), - path, - icon: None, - }); - } - // Windows PowerShell 5.x - let ps5 = r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"; - if std::path::Path::new(ps5).exists() { - results.push(DetectedTool { - id: "powershell".into(), - name: "Windows PowerShell".into(), - path: ps5.to_string(), - icon: None, - }); - } - // CMD - results.push(DetectedTool { - id: "cmd".into(), - name: "CMD".into(), - path: "cmd.exe".into(), - icon: None, - }); - // Git Bash — keep in sync with git_bash_shell_path() in this file. - let mut git_bash_found = false; - let git_bash_system_candidates = [ - r"C:\Program Files\Git\bin\bash.exe", - r"C:\Program Files (x86)\Git\bin\bash.exe", - ]; - for p in &git_bash_system_candidates { - if std::path::Path::new(p).exists() { - results.push(DetectedTool { - id: "bash".into(), - name: "Git Bash".into(), - path: p.to_string(), - icon: None, - }); - git_bash_found = true; - break; - } - } - if !git_bash_found { - if let Ok(local) = std::env::var("LOCALAPPDATA") { - let p = format!(r"{}\Programs\Git\bin\bash.exe", local); - if std::path::Path::new(&p).exists() { - results.push(DetectedTool { - id: "bash".into(), - name: "Git Bash".into(), - path: p, - icon: None, - }); - } - } - } - // Nushell - if let Some(path) = check_executable("nu") { - results.push(DetectedTool { - id: "nu".into(), - name: "Nushell".into(), - path, - icon: None, - }); - } - } - - results -} - -fn detect_tools_blocking() -> DetectedTools { - log::info!("[system] Detecting available tools..."); - let tools = DetectedTools { - git: detect_git(), - terminals: detect_terminals(), - editors: detect_editors(), - shells: detect_shells(), - }; - let icons_count = tools.editors.iter().filter(|e| e.icon.is_some()).count(); - log::info!( - "[system] Detected: {} git, {} terminals, {} editors ({} with icons)", - tools.git.len(), - tools.terminals.len(), - tools.editors.len(), - icons_count - ); - tools -} - -#[tauri::command] -pub(crate) async fn detect_tools() -> DetectedTools { - detect_tools_internal().await -} - -pub async fn detect_tools_internal() -> DetectedTools { - tokio::task::spawn_blocking(detect_tools_blocking) - .await - .unwrap_or_else(|e| { - log::warn!("[system] Tool detection task failed: {}", e); - DetectedTools::default() - }) -} - -#[tauri::command] -pub(crate) fn set_git_path(path: String) { - crate::utils::set_custom_git_path(&path); -} - -pub fn set_git_path_internal(path: &str) { - crate::utils::set_custom_git_path(path); -} - -pub fn terminate_process_impl(pid: u32) -> Result<(), String> { - if pid == 0 { - return Err("Invalid process id".to_string()); - } - if pid == std::process::id() { - return Err("Refusing to terminate the current app process".to_string()); - } - - // /T terminates the full process tree. This is intentional: child processes - // (e.g. language servers, file watchers) spawned by the target process may - // be the actual holders of file handles, so killing only the parent would - // leave those handles open and the archive would still be blocked. - #[cfg(target_os = "windows")] - let output = { - let mut command = Command::new("taskkill"); - command.args(["/PID", &pid.to_string(), "/T", "/F"]); - hide_command_window(&mut command); - command - .output() - .map_err(|e| format!("Failed to start taskkill: {}", e))? - }; - - #[cfg(not(target_os = "windows"))] - let output = Command::new("kill") - .args(["-TERM", &pid.to_string()]) - .output() - .map_err(|e| format!("Failed to start kill: {}", e))?; - - if output.status.success() { - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!( - "Failed to terminate process {}: {}", - pid, - stderr.trim() - )) - } -} - -#[tauri::command] -pub(crate) fn get_app_version() -> String { - env!("CARGO_PKG_VERSION").to_string() -} - -// ==================== 更新镜像检测 ==================== - -/// 通过 gh-proxy.org 镜像检测最新版本(仅检测,不下载) -/// 返回 JSON: { "version": "...", "pub_date": "...", "notes": "..." } -#[tauri::command] -pub(crate) async fn check_mirror_update(mirror_url: String) -> Result { - log::info!("[system] Checking mirror for updates via {}...", mirror_url); - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(15)) - .build() - .map_err(|e| format!("HTTP client error: {}", e))?; - - let github_url = - "https://github.com/guoyongchang/worktree-manager/releases/latest/download/latest.json"; - let endpoint = format!("{}{}", mirror_url, github_url); - let resp = client - .get(&endpoint) - .send() - .await - .map_err(|e| format!("Failed to fetch mirror manifest: {}", e))?; - - if !resp.status().is_success() { - return Err(format!("Mirror returned HTTP {}", resp.status())); - } - - let manifest: serde_json::Value = resp - .json() - .await - .map_err(|e| format!("Failed to parse mirror manifest: {}", e))?; - - let version = manifest - .get("version") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let pub_date = manifest - .get("pub_date") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let notes = manifest - .get("notes") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - log::info!( - "[system] Mirror latest version: {} (pub_date: {})", - version, - pub_date - ); - - Ok(serde_json::json!({ - "version": version, - "pub_date": pub_date, - "notes": notes, - "current_version": env!("CARGO_PKG_VERSION"), - })) -} - -// ==================== 更新镜像下载 ==================== - -/// 通过镜像下载更新的内部实现(单个镜像源) -async fn download_with_mirror(app: &tauri::AppHandle, mirror_url: &str) -> Result<(), String> { - use tauri::Emitter; - use tauri_plugin_updater::UpdaterExt; - - // 1. Fetch latest.json from GitHub (via mirror) - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|e| format!("HTTP client error: {}", e))?; - - let github_manifest = - "https://github.com/guoyongchang/worktree-manager/releases/latest/download/latest.json"; - let endpoint = format!("{}{}", mirror_url, github_manifest); - let resp = client - .get(&endpoint) - .send() - .await - .map_err(|e| format!("Failed to fetch update manifest: {}", e))?; - let mut manifest: serde_json::Value = resp - .json() - .await - .map_err(|e| format!("Failed to parse update manifest: {}", e))?; - - // 2. Modify all platform download URLs to use the mirror - if let Some(platforms) = manifest.get_mut("platforms") { - if let Some(obj) = platforms.as_object_mut() { - for (platform, info) in obj.iter_mut() { - if let Some(url_val) = info.get_mut("url") { - if let Some(url_str) = url_val.as_str() { - let proxied = format!("{}{}", mirror_url, url_str); - log::info!("[system] Proxied URL for {}: {}", platform, proxied); - *url_val = serde_json::Value::String(proxied); - } - } - } - } - } - - let manifest_body = serde_json::to_string(&manifest).map_err(|e| e.to_string())?; - - // 3. Start a temporary local HTTP server to serve the modified manifest - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .map_err(|e| format!("Failed to bind local server: {}", e))?; - let port = listener.local_addr().map_err(|e| e.to_string())?.port(); - - let router = axum::Router::new().route( - "/latest.json", - axum::routing::get(move || { - let body = manifest_body.clone(); - async move { body } - }), - ); - - struct AbortServerOnDrop(Option>); - - impl Drop for AbortServerOnDrop { - fn drop(&mut self) { - if let Some(handle) = self.0.take() { - handle.abort(); - } - } - } - - let server_handle = tokio::spawn(async move { - axum::serve(listener, router).await.ok(); - }); - let mut server_guard = AbortServerOnDrop(Some(server_handle)); - - // 4. Create a new updater instance pointing to the local endpoint - let local_endpoint: url::Url = format!("http://127.0.0.1:{}/latest.json", port) - .parse() - .map_err(|e: url::ParseError| e.to_string())?; - - log::info!("[system] Local manifest server at: {}", local_endpoint); - - let updater = app - .updater_builder() - .endpoints(vec![local_endpoint]) - .map_err(|e| format!("Failed to set endpoints: {}", e))? - .build() - .map_err(|e| format!("Failed to build updater: {}", e))?; - - // 5. Check for update (reads from local server → gets proxied download URLs) - let update: Option = updater - .check() - .await - .map_err(|e| format!("Mirror update check failed: {}", e))?; - - let update = update.ok_or_else(|| "No update available".to_string())?; - log::info!("[system] Mirror update found: v{}", update.version); - - // 6. Download and install with progress events emitted to the frontend - let app_for_chunk = app.clone(); - let app_for_finish = app.clone(); - let mut first_chunk = true; - - update - .download_and_install( - move |chunk_len: usize, content_length: Option| { - if first_chunk { - first_chunk = false; - let _ = app_for_chunk.emit( - "mirror-update-progress", - serde_json::json!({ - "event": "Started", - "data": { "contentLength": content_length.unwrap_or(0) } - }), - ); - } - let _ = app_for_chunk.emit( - "mirror-update-progress", - serde_json::json!({ - "event": "Progress", - "data": { "chunkLength": chunk_len } - }), - ); - }, - move || { - let _ = app_for_finish.emit( - "mirror-update-progress", - serde_json::json!({ - "event": "Finished", - "data": {} - }), - ); - }, - ) - .await - .map_err(|e| format!("Mirror download failed: {}", e))?; - - // 7. Clean up local server - if let Some(handle) = server_guard.0.take() { - handle.abort(); - } - log::info!( - "[system] Mirror update download complete via {}", - mirror_url - ); - - Ok(()) -} - -/// 通过镜像下载更新,支持自动 fallback 到其他可用镜像源 -#[tauri::command] -pub(crate) async fn download_update_via_mirror( - app: tauri::AppHandle, - mirror_url: String, -) -> Result<(), String> { - use tauri::Emitter; - - log::info!( - "[system] Starting mirror update download via {}...", - mirror_url - ); - - // Build fallback list: primary mirror + cached available mirrors (up to 3 total) - let mut fallback_list = vec![mirror_url.clone()]; - let cached = crate::mirror::get_cached_results(); - for r in &cached { - if r.available && r.url != mirror_url && fallback_list.len() < 3 { - fallback_list.push(r.url.clone()); - } - } - - log::info!( - "[system] Fallback mirror list ({} entries): {:?}", - fallback_list.len(), - fallback_list - ); - - let mut last_error = String::new(); - - for (attempt, url) in fallback_list.iter().enumerate() { - if attempt > 0 { - log::info!( - "[system] Fallback attempt #{}: trying {}...", - attempt + 1, - url - ); - let _ = app.emit( - "mirror-update-progress", - serde_json::json!({ - "event": "Fallback", - "data": { "mirror": url, "attempt": attempt + 1 } - }), - ); - } - - match download_with_mirror(&app, url).await { - Ok(()) => return Ok(()), - Err(e) => { - log::warn!( - "[system] Mirror download failed via {} (attempt {}): {}", - url, - attempt + 1, - e - ); - last_error = e; - } - } - } - - Err(format!( - "All {} mirror(s) failed. Last error: {}", - fallback_list.len(), - last_error - )) -} - -// ==================== 镜像源管理 ==================== - -/// 并发 PING 所有镜像源,返回可用性结果(不做吞吐量测速) -#[tauri::command] -pub(crate) async fn test_mirror_speed() -> Result, String> { - log::info!("[system] Starting mirror PING test..."); - let results = crate::mirror::ping_all_mirrors().await; - log::info!( - "[system] Mirror PING test complete, {} results", - results.len() - ); - Ok(results) -} - -/// 对单个镜像源进行吞吐量测速(10秒) -#[tauri::command] -pub(crate) async fn speed_test_single_mirror( - mirror_url: String, -) -> Result { - log::info!("[system] Speed testing single mirror: {}", mirror_url); - crate::mirror::speed_test_single(&mirror_url) - .await - .ok_or_else(|| format!("Mirror not found: {}", mirror_url)) -} - -/// 返回所有镜像源(内置 + 自定义) -#[tauri::command] -pub(crate) fn get_mirror_sources() -> Vec { - crate::mirror::get_all_mirrors() -} - -/// 保存用户自定义镜像源到 global.json -#[tauri::command] -pub(crate) fn save_custom_mirrors(mirrors: Vec) -> Result<(), String> { - let mut config = crate::config::load_global_config(); - config.custom_mirrors = mirrors; - crate::config::save_global_config_internal(&config)?; - crate::mirror::clear_mirror_cache(); - Ok(()) -} - -// ==================== HTTP Server 共享接口 ==================== - -pub fn open_in_terminal_internal( - path: &str, - terminal: Option<&str>, - shell: Option<&str>, -) -> Result<(), String> { - open_in_terminal( - path.to_string(), - terminal.map(|s| s.to_string()), - shell.map(|s| s.to_string()), - ) -} - -pub fn open_in_editor_internal( - request: &OpenEditorRequest, - custom_path: Option<&str>, -) -> Result<(), String> { - open_editor_at_path(request, custom_path) -} - -pub fn reveal_in_finder_internal(path: &str) -> Result<(), String> { - reveal_in_finder(path.to_string()) -} - -pub fn open_log_dir_internal() -> Result<(), String> { - open_log_dir() -} - -pub fn get_app_icon_internal(path: &str) -> Option { - get_app_icon(path.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{pending_crash_report, CrashReport}; - use axum::{http::StatusCode, response::IntoResponse, routing::get, Json, Router}; - use once_cell::sync::Lazy; - use serde_json::json; - use serial_test::serial; - use std::ffi::OsString; - use std::path::{Path, PathBuf}; - use std::process::{Command, Stdio}; - use std::sync::{Mutex, MutexGuard}; - use std::time::{Duration, Instant}; - use tokio::net::TcpListener; - - static SYSTEM_TEST_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); - - fn lock_system_test() -> MutexGuard<'static, ()> { - SYSTEM_TEST_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - } - - struct EnvVarGuard { - key: &'static str, - previous: Option, - } - - impl EnvVarGuard { - fn set(key: &'static str, value: impl AsRef) -> Self { - let previous = std::env::var_os(key); - std::env::set_var(key, value); - Self { key, previous } - } - - fn remove(key: &'static str) -> Self { - let previous = std::env::var_os(key); - std::env::remove_var(key); - Self { key, previous } - } - - fn prepend_path(dir: &Path) -> Self { - let previous = std::env::var_os("PATH"); - let mut paths = vec![dir.to_path_buf()]; - if let Some(old_path) = previous.as_ref() { - paths.extend(std::env::split_paths(old_path)); - } - let joined = std::env::join_paths(paths).expect("join PATH entries"); - std::env::set_var("PATH", joined); - Self { - key: "PATH", - previous, - } - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - match &self.previous { - Some(value) => std::env::set_var(self.key, value), - None => std::env::remove_var(self.key), - } - } - } - - struct GlobalConfigCacheGuard { - previous: Option, - } - - impl GlobalConfigCacheGuard { - fn clear() -> Self { - let previous = { - let mut cache = crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *cache) - }; - Self { previous } - } - } - - impl Drop for GlobalConfigCacheGuard { - fn drop(&mut self) { - let mut cache = crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = self.previous.take(); - } - } - - fn shell_quote(path: &Path) -> String { - format!("'{}'", path.to_string_lossy().replace('\'', "'\\''")) - } - - #[cfg(unix)] - fn make_fake_command(dir: &Path, name: &str, script_body: &str) -> PathBuf { - use std::os::unix::fs::PermissionsExt; - - let path = dir.join(name); - std::fs::write(&path, format!("#!/bin/sh\n{}\n", script_body)).expect("write fake command"); - let mut permissions = std::fs::metadata(&path).unwrap().permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&path, permissions).expect("chmod fake command"); - path - } - - #[cfg(windows)] - fn make_fake_command(dir: &Path, name: &str, script_body: &str) -> PathBuf { - let path = dir.join(format!("{}.cmd", name)); - std::fs::write(&path, script_body).expect("write fake command"); - path - } - - fn make_recording_command(dir: &Path, name: &str, record: &Path) -> PathBuf { - #[cfg(windows)] - { - let body = format!( - "@echo off\r\n(for %%A in (%*) do @echo %%~A) > \"{}\"\r\n", - record.display() - ); - make_fake_command(dir, name, &body) - } - #[cfg(not(windows))] - { - make_fake_command( - dir, - name, - &format!("printf '%s\\n' \"$@\" > {}", shell_quote(record)), - ) - } - } - - fn read_recorded_args(record: &Path) -> Vec { - let deadline = Instant::now() + Duration::from_secs(2); - while Instant::now() < deadline { - if let Ok(content) = std::fs::read_to_string(record) { - if !content.is_empty() { - return content.lines().map(str::to_string).collect(); - } - } - std::thread::sleep(Duration::from_millis(10)); - } - panic!("recorded command args were not written to {:?}", record); - } - - async fn spawn_manifest_server( - status: StatusCode, - body: serde_json::Value, - ) -> Result<(String, tokio::task::JoinHandle<()>), String> { - let app = Router::new().route( - "/{*path}", - get(move || { - let body = body.clone(); - async move { - if status.is_success() { - Json(body).into_response() - } else { - (status, body.to_string()).into_response() - } - } - }), - ); - let listener = match TcpListener::bind("127.0.0.1:0").await { - Ok(listener) => listener, - Err(err) => return Err(format!("local bind unavailable: {}", err)), - }; - let addr = listener.local_addr().expect("manifest server addr"); - let handle = tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - Ok((format!("http://{}/", addr), handle)) - } - - async fn spawn_text_server( - status: StatusCode, - body: &'static str, - ) -> Result<(String, tokio::task::JoinHandle<()>), String> { - let app = Router::new().route( - "/{*path}", - get(move || async move { (status, body).into_response() }), - ); - let listener = match TcpListener::bind("127.0.0.1:0").await { - Ok(listener) => listener, - Err(err) => return Err(format!("local bind unavailable: {}", err)), - }; - let addr = listener.local_addr().expect("text server addr"); - let handle = tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - Ok((format!("http://{}/", addr), handle)) - } - - fn command_for_script(script: &str) -> Command { - #[cfg(target_os = "windows")] - { - let mut command = Command::new("cmd"); - command.args(["/C", script]); - command - } - #[cfg(not(target_os = "windows"))] - { - let mut command = Command::new("sh"); - command.args(["-c", script]); - command - } - } - - #[serial] - #[test] - fn windows_terminal_uses_requested_shell_instead_of_default_profile() { - let launch = - build_windows_terminal_launch(r"C:\repo", Some("windowsterminal"), Some("cmd")); - - assert_eq!( - launch, - WindowsTerminalLaunch { - program: "wt".to_string(), - args: vec![ - "-d".to_string(), - r"C:\repo".to_string(), - "cmd.exe".to_string(), - ], - current_dir: None, - } - ); - } - - #[serial] - #[test] - fn custom_terminal_path_is_launched_directly() { - let launch = build_windows_terminal_launch( - r"C:\repo", - Some(r"C:\Tools\WezTerm\wezterm-gui.exe"), - None, - ); - - assert_eq!( - launch, - WindowsTerminalLaunch { - program: r"C:\Tools\WezTerm\wezterm-gui.exe".to_string(), - args: Vec::new(), - current_dir: Some(PathBuf::from(r"C:\repo")), - } - ); - } - - #[serial] - #[test] - fn command_for_log_includes_program_and_arguments() { - let mut command = Command::new("test-program"); - command.args(["--flag", "value with space"]); - - assert_eq!( - command_for_log(&command), - "test-program --flag value with space" - ); - } - - #[serial] - #[test] - fn output_with_timeout_captures_fast_success_stdout_and_stderr() { - #[cfg(target_os = "windows")] - let mut command = command_for_script("echo stdout-text && echo stderr-text 1>&2"); - #[cfg(not(target_os = "windows"))] - let mut command = command_for_script("printf stdout-text; printf stderr-text >&2"); - command.stdout(Stdio::piped()).stderr(Stdio::piped()); - - let output = output_with_timeout(&mut command, 1).expect("command should exit"); - - assert!(output.status.success()); - assert!( - String::from_utf8_lossy(&output.stdout).contains("stdout-text"), - "stdout was {:?}", - String::from_utf8_lossy(&output.stdout) - ); - assert!( - String::from_utf8_lossy(&output.stderr).contains("stderr-text"), - "stderr was {:?}", - String::from_utf8_lossy(&output.stderr) - ); - } - - #[serial] - #[test] - fn output_with_timeout_returns_non_success_status_for_fast_failure() { - #[cfg(target_os = "windows")] - let mut command = command_for_script("exit /B 7"); - #[cfg(not(target_os = "windows"))] - let mut command = command_for_script("exit 7"); - command.stdout(Stdio::piped()).stderr(Stdio::piped()); - - let output = output_with_timeout(&mut command, 1).expect("command should exit"); - - assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(7)); - } - - #[serial] - #[test] - fn output_with_timeout_returns_none_for_missing_program() { - let mut command = Command::new("__worktree_manager_missing_program__"); - command.stdout(Stdio::piped()).stderr(Stdio::piped()); - - assert!(output_with_timeout(&mut command, 1).is_none()); - } - - #[serial] - #[test] - fn output_with_timeout_kills_process_when_deadline_expires() { - #[cfg(target_os = "windows")] - let mut command = command_for_script("ping -n 3 127.0.0.1 > nul"); - #[cfg(not(target_os = "windows"))] - let mut command = command_for_script("sleep 2"); - command.stdout(Stdio::piped()).stderr(Stdio::piped()); - - let started = Instant::now(); - let output = output_with_timeout(&mut command, 0); - - assert!(output.is_none()); - assert!( - started.elapsed() < Duration::from_secs(2), - "timeout branch should return promptly" - ); - } - - #[serial] - #[test] - fn windows_shell_command_maps_shell_ids_and_custom_paths() { - assert!(windows_shell_command(None).is_empty()); - assert!(windows_shell_command(Some("auto")).is_empty()); - assert_eq!(windows_shell_command(Some("cmd")), vec!["cmd.exe"]); - assert_eq!(windows_shell_command(Some("pwsh")), vec!["pwsh.exe"]); - assert_eq!( - windows_shell_command(Some(r"C:\Tools\nu.exe")), - vec![r"C:\Tools\nu.exe"] - ); - - let bash = windows_shell_command(Some("bash")); - assert_eq!(bash.len(), 3); - assert_eq!(bash[1], "--login"); - assert_eq!(bash[2], "-i"); - } - - #[serial] - #[test] - fn path_like_executable_detects_absolute_and_separator_paths() { - assert!(path_like_executable(r"C:\Tools\app.exe")); - assert!(path_like_executable("/usr/local/bin/app")); - assert!(path_like_executable("relative/app")); - assert!(!path_like_executable("cmd")); - } - - #[serial] - #[test] - fn editor_cli_command_maps_known_editors_and_defaults_to_vscode() { - assert_eq!(editor_cli_command("vscode"), "code"); - assert_eq!(editor_cli_command("cursor"), "cursor"); - assert_eq!(editor_cli_command("antigravity"), "antigravity"); - assert_eq!(editor_cli_command("idea"), "idea"); - assert_eq!(editor_cli_command("codex"), "codex"); - assert_eq!(editor_cli_command("unknown"), "code"); - } - - #[serial] - #[test] - fn gitbash_terminal_launch_builds_cd_argument_without_spawning() { - let launch = build_windows_terminal_launch(r"C:\repo", Some("gitbash"), None); - - assert!(launch.program.ends_with("git-bash.exe"), "{launch:?}"); - assert_eq!(launch.args, vec![r"--cd=C:\repo".to_string()]); - assert_eq!(launch.current_dir, None); - } - - #[serial] - #[test] - fn windows_terminal_cmd_launch_plan_uses_start_and_cd() { - let launch = build_windows_terminal_launch(r"C:\repo with spaces", Some("cmd"), None); - - assert_eq!(launch.program, "cmd"); - assert_eq!( - launch.args, - vec![ - "/c".to_string(), - "start".to_string(), - "cmd".to_string(), - "/k".to_string(), - r"cd /d C:\repo with spaces".to_string() - ] - ); - assert_eq!(launch.current_dir, None); - } - - #[serial] - #[test] - fn windows_terminal_powershell_launch_plan_sets_location() { - let launch = - build_windows_terminal_launch(r"C:\repo with spaces", Some("powershell"), None); - - assert_eq!(launch.program, "cmd"); - assert_eq!( - launch.args, - vec![ - "/c".to_string(), - "start".to_string(), - "powershell".to_string(), - "-NoExit".to_string(), - "-Command".to_string(), - r"Set-Location 'C:\repo with spaces'".to_string() - ] - ); - assert_eq!(launch.current_dir, None); - } - - #[serial] - #[test] - fn windows_terminal_auto_launch_plan_appends_requested_shell() { - let launch = build_windows_terminal_launch(r"C:\repo", Some("auto"), Some("pwsh")); - - assert_eq!(launch.program, "wt"); - assert_eq!( - launch.args, - vec![ - "-d".to_string(), - r"C:\repo".to_string(), - "pwsh.exe".to_string() - ] - ); - assert_eq!(launch.current_dir, None); - } - - #[serial] - #[test] - fn windows_terminal_custom_launch_plan_sets_current_dir() { - let launch = build_windows_terminal_launch(r"C:\repo", Some("custom-term"), None); - - assert_eq!(launch.program, "custom-term"); - assert!(launch.args.is_empty()); - assert_eq!(launch.current_dir, Some(PathBuf::from(r"C:\repo"))); - } - - #[serial] - #[test] - fn get_app_version_returns_package_version() { - assert_eq!(get_app_version(), env!("CARGO_PKG_VERSION")); - } - - #[serial] - #[test] - fn detect_tools_blocking_finds_fake_cli_tools_from_path() { - let _serial = lock_system_test(); - let temp = tempfile::tempdir().expect("fake tool dir"); - for name in [ - "code", - "cursor", - "antigravity", - "idea", - "codex", - "zed", - "sublime_text", - "zsh", - "bash", - "fish", - "nu", - "gnome-terminal", - "konsole", - "xterm", - "alacritty", - "kitty", - "ghostty", - "wezterm", - "tilix", - ] { - make_fake_command(temp.path(), name, "exit 0"); - } - let _path = EnvVarGuard::prepend_path(temp.path()); - - let tools = detect_tools_blocking(); - - assert!(tools.git.iter().any(|tool| tool.id == "git")); - for id in [ - "vscode", - "cursor", - "antigravity", - "idea", - "codex", - "zed", - "sublime", - ] { - assert!( - tools.editors.iter().any(|tool| tool.id == id), - "missing editor {id}: {:?}", - tools.editors - ); - } - for id in ["zsh", "bash", "fish", "nu"] { - assert!( - tools.shells.iter().any(|tool| tool.id == id), - "missing shell {id}: {:?}", - tools.shells - ); - } - #[cfg(target_os = "linux")] - for id in [ - "gnome-terminal", - "konsole", - "xterm", - "alacritty", - "kitty", - "ghostty", - "wezterm", - "tilix", - ] { - assert!( - tools.terminals.iter().any(|tool| tool.id == id), - "missing terminal {id}: {:?}", - tools.terminals - ); - } - #[cfg(target_os = "macos")] - assert!(tools.terminals.iter().any(|tool| tool.id == "terminal")); - } - - #[serial] - #[test] - fn get_crash_report_takes_pending_report_once() { - let _serial = lock_system_test(); - let mut pending = pending_crash_report() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let previous = pending.take(); - *pending = Some(CrashReport { - abnormal_exit: true, - crash_detail: Some("panic at startup".to_string()), - previous_session_info: Some("session.running".to_string()), - }); - drop(pending); - - let first = get_crash_report().expect("pending crash report"); - let second = get_crash_report(); - - assert!(first.abnormal_exit); - assert_eq!(first.crash_detail.as_deref(), Some("panic at startup")); - assert_eq!( - first.previous_session_info.as_deref(), - Some("session.running") - ); - assert!(second.is_none()); - - let mut pending = pending_crash_report() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *pending = previous; - } - - #[serial] - #[test] - fn terminate_process_rejects_invalid_and_current_process_ids() { - assert_eq!( - terminate_process_impl(0), - Err("Invalid process id".to_string()) - ); - assert_eq!( - terminate_process_impl(std::process::id()), - Err("Refusing to terminate the current app process".to_string()) - ); - } - - #[serial] - #[test] - fn open_editor_at_path_uses_custom_executable_and_path_argument() { - let _serial = lock_system_test(); - let temp = tempfile::tempdir().expect("temp dir"); - let record = temp.path().join("editor-args.txt"); - let editor = make_recording_command(temp.path(), "custom-editor", &record); - let request = crate::types::OpenEditorRequest { - editor: "cursor".to_string(), - path: temp.path().join("workspace").to_string_lossy().to_string(), - }; - - open_editor_at_path(&request, Some(editor.to_str().unwrap())).expect("spawn editor"); - std::thread::sleep(Duration::from_millis(50)); - - assert_eq!(read_recorded_args(&record), vec![request.path]); - } - - #[serial] - #[test] - fn open_editor_at_path_reports_missing_custom_executable() { - let request = crate::types::OpenEditorRequest { - editor: "cursor".to_string(), - path: "/tmp/workspace".to_string(), - }; - let missing = tempfile::tempdir().unwrap().path().join("missing-editor"); - - let err = open_editor_at_path(&request, Some(missing.to_str().unwrap())).unwrap_err(); - - assert!(err.contains("无法打开编辑器")); - assert!(err.contains("missing-editor")); - } - - #[serial] - #[test] - fn open_editor_at_path_uses_codex_app_subcommand_for_custom_executable() { - let _serial = lock_system_test(); - let temp = tempfile::tempdir().expect("temp dir"); - let record = temp.path().join("codex-editor-args.txt"); - let editor = make_recording_command(temp.path(), "custom-codex", &record); - let request = crate::types::OpenEditorRequest { - editor: "codex".to_string(), - path: temp.path().join("workspace").to_string_lossy().to_string(), - }; - - open_editor_at_path(&request, Some(editor.to_str().unwrap())).expect("spawn codex"); - - assert_eq!( - read_recorded_args(&record), - vec!["app".to_string(), request.path] - ); - } - - #[cfg(target_os = "macos")] - #[serial] - #[test] - fn open_in_terminal_uses_open_with_selected_macos_app() { - let _serial = lock_system_test(); - let temp = tempfile::tempdir().expect("temp dir"); - let record = temp.path().join("open-args.txt"); - make_recording_command(temp.path(), "open", &record); - let _path = EnvVarGuard::prepend_path(temp.path()); - let workspace = temp.path().join("space dir"); - std::fs::create_dir_all(&workspace).unwrap(); - - open_in_terminal( - workspace.to_string_lossy().to_string(), - Some("warp".to_string()), - Some("bash".to_string()), - ) - .expect("spawn open"); - std::thread::sleep(Duration::from_millis(50)); - - assert_eq!( - read_recorded_args(&record), - vec![ - "-a".to_string(), - "Warp".to_string(), - workspace.to_string_lossy().to_string() - ] - ); - } - - #[cfg(target_os = "linux")] - #[serial] - #[test] - fn open_in_terminal_uses_first_available_linux_terminal_with_cwd() { - let _serial = lock_system_test(); - let temp = tempfile::tempdir().expect("temp dir"); - let record = temp.path().join("terminal-cwd.txt"); - make_fake_command( - temp.path(), - "x-terminal-emulator", - &format!("pwd > {}", shell_quote(&record)), - ); - let _path = EnvVarGuard::prepend_path(temp.path()); - let workspace = temp.path().join("workspace"); - std::fs::create_dir_all(&workspace).unwrap(); - - open_in_terminal(workspace.to_string_lossy().to_string(), None, None) - .expect("spawn terminal"); - std::thread::sleep(Duration::from_millis(50)); - - assert_eq!( - std::fs::read_to_string(&record).unwrap().trim(), - workspace.to_string_lossy() - ); - } - - #[cfg(any(target_os = "macos", target_os = "linux"))] - #[serial] - #[test] - fn reveal_in_finder_spawns_platform_file_manager_with_path() { - let _serial = lock_system_test(); - let temp = tempfile::tempdir().expect("temp dir"); - let record = temp.path().join("reveal-args.txt"); - #[cfg(target_os = "macos")] - make_recording_command(temp.path(), "open", &record); - #[cfg(target_os = "linux")] - make_recording_command(temp.path(), "xdg-open", &record); - let _path = EnvVarGuard::prepend_path(temp.path()); - let target = temp.path().join("target folder"); - std::fs::create_dir_all(&target).unwrap(); - - reveal_in_finder(target.to_string_lossy().to_string()).expect("spawn file manager"); - std::thread::sleep(Duration::from_millis(50)); - - assert_eq!( - read_recorded_args(&record), - vec![target.to_string_lossy().to_string()] - ); - } - - #[cfg(target_os = "macos")] - #[serial] - #[test] - fn log_dir_uses_home_library_logs_and_open_launcher() { - let _serial = lock_system_test(); - let temp = tempfile::tempdir().expect("temp dir"); - let bin = tempfile::tempdir().expect("bin dir"); - let record = temp.path().join("log-open-args.txt"); - make_recording_command(bin.path(), "open", &record); - let _home = EnvVarGuard::set("HOME", temp.path()); - let _path = EnvVarGuard::prepend_path(bin.path()); - - let log_dir = get_platform_log_dir().expect("mac log dir"); - open_log_dir().expect("open log dir"); - std::thread::sleep(Duration::from_millis(50)); - - assert_eq!( - log_dir, - temp.path().join("Library/Logs/com.guo.worktree-manager") - ); - assert!(log_dir.exists()); - assert_eq!( - read_recorded_args(&record), - vec![log_dir.to_string_lossy().to_string()] - ); - } - - #[cfg(target_os = "linux")] - #[serial] - #[test] - fn log_dir_uses_xdg_data_home_and_xdg_open_launcher() { - let _serial = lock_system_test(); - let temp = tempfile::tempdir().expect("temp dir"); - let bin = tempfile::tempdir().expect("bin dir"); - let record = temp.path().join("log-open-args.txt"); - make_recording_command(bin.path(), "xdg-open", &record); - let data_home = temp.path().join("data"); - let _xdg = EnvVarGuard::set("XDG_DATA_HOME", &data_home); - let _path = EnvVarGuard::prepend_path(bin.path()); - - let log_dir = get_platform_log_dir().expect("linux log dir"); - open_log_dir().expect("open log dir"); - std::thread::sleep(Duration::from_millis(50)); - - assert_eq!(log_dir, data_home.join("com.guo.worktree-manager/logs")); - assert!(log_dir.exists()); - assert_eq!( - read_recorded_args(&record), - vec![log_dir.to_string_lossy().to_string()] - ); - } - - #[cfg(target_os = "macos")] - #[serial] - #[test] - fn get_platform_log_dir_reports_missing_home_on_macos() { - let _serial = lock_system_test(); - let _home = EnvVarGuard::remove("HOME"); - - assert_eq!(get_platform_log_dir(), Err("无法获取用户目录".to_string())); - } - - #[serial] - #[test] - fn get_app_icon_returns_none_for_missing_path_without_extracting() { - let missing = tempfile::tempdir() - .unwrap() - .path() - .join("missing-app") - .to_string_lossy() - .to_string(); - - assert!(get_app_icon(missing).is_none()); - } - - #[serial] - #[tokio::test] - async fn check_mirror_update_parses_manifest_and_adds_current_version() { - let Ok((base_url, server)) = spawn_manifest_server( - StatusCode::OK, - json!({ - "version": "9.8.7", - "pub_date": "2026-06-11T00:00:00Z", - "notes": "unit manifest" - }), - ) - .await - else { - // The managed sandbox can deny loopback binds; avoid external APIs in that case. - return; - }; - - let manifest = check_mirror_update(base_url) - .await - .expect("mirror manifest"); - server.abort(); - - assert_eq!(manifest["version"], "9.8.7"); - assert_eq!(manifest["pub_date"], "2026-06-11T00:00:00Z"); - assert_eq!(manifest["notes"], "unit manifest"); - assert_eq!(manifest["current_version"], env!("CARGO_PKG_VERSION")); - } - - #[serial] - #[tokio::test] - async fn check_mirror_update_reports_non_success_http_status() { - let Ok((base_url, server)) = - spawn_manifest_server(StatusCode::BAD_GATEWAY, json!({"error": "bad mirror"})).await - else { - // The managed sandbox can deny loopback binds; avoid external APIs in that case. - return; - }; - - let err = check_mirror_update(base_url).await.unwrap_err(); - server.abort(); - - assert_eq!(err, "Mirror returned HTTP 502 Bad Gateway"); - } - - #[serial] - #[tokio::test] - async fn check_mirror_update_reports_invalid_mirror_url_without_http() { - let err = check_mirror_update("not a url".to_string()) - .await - .unwrap_err(); - - assert!(err.contains("Failed to fetch mirror manifest"), "{err}"); - } - - #[serial] - #[test] - fn save_custom_mirrors_persists_config_and_get_mirror_sources_appends_them() { - let _serial = lock_system_test(); - let _cache = GlobalConfigCacheGuard::clear(); - let temp = tempfile::tempdir().expect("temp home"); - let _home = EnvVarGuard::set("HOME", temp.path()); - let custom = crate::types::CustomMirror { - name: "Local Mirror".to_string(), - url: "https://mirror.example/".to_string(), - }; - - save_custom_mirrors(vec![custom.clone()]).expect("save custom mirror"); - let sources = get_mirror_sources(); - let config_text = std::fs::read_to_string(crate::config::get_global_config_path()).unwrap(); - let config_json: serde_json::Value = serde_json::from_str(&config_text).unwrap(); - - let saved = sources - .iter() - .find(|source| source.name == custom.name) - .expect("custom mirror source"); - assert_eq!(saved.url, custom.url); - assert!(!saved.builtin); - assert_eq!(config_json["custom_mirrors"][0]["name"], custom.name); - assert_eq!(config_json["custom_mirrors"][0]["url"], custom.url); - } - - #[serial] - #[test] - fn open_in_editor_wrapper_uses_custom_executable() { - let _serial = lock_system_test(); - let temp = tempfile::tempdir().expect("temp dir"); - let record = temp.path().join("wrapped-editor-args.txt"); - let editor = make_recording_command(temp.path(), "wrapped-editor", &record); - let request_path = temp.path().join("workspace").to_string_lossy().to_string(); - let request = crate::types::OpenEditorRequest { - editor: "vscode".to_string(), - path: request_path.clone(), - }; - - open_in_editor(request, Some(editor.to_string_lossy().to_string())) - .expect("open editor through command wrapper"); - - assert_eq!(read_recorded_args(&record), vec![request_path]); - } - - #[cfg(target_os = "macos")] - #[serial] - #[test] - fn macos_editor_app_launch_records_known_app_names() { - let _serial = lock_system_test(); - let temp = tempfile::tempdir().expect("temp dir"); - let record = temp.path().join("open-editor-args.txt"); - make_recording_command(temp.path(), "open", &record); - let _path = EnvVarGuard::prepend_path(temp.path()); - let workspace = temp.path().join("space dir"); - std::fs::create_dir_all(&workspace).unwrap(); - let request = crate::types::OpenEditorRequest { - editor: "idea".to_string(), - path: workspace.to_string_lossy().to_string(), - }; - - open_editor_at_path(&request, None).expect("open editor app through macOS open"); - - assert_eq!( - read_recorded_args(&record), - vec![ - "-a".to_string(), - "IntelliJ IDEA".to_string(), - workspace.to_string_lossy().to_string() - ] - ); - assert_eq!(editor_app_name("vscode"), "Visual Studio Code"); - assert_eq!(editor_app_name("cursor"), "Cursor"); - assert_eq!(editor_app_name("antigravity"), "Antigravity"); - assert_eq!(editor_app_name("codex"), "Codex"); - assert_eq!(editor_app_name("unknown"), "Visual Studio Code"); - } - - #[cfg(target_os = "macos")] - #[serial] - #[test] - fn macos_custom_app_and_icon_failure_paths_return_without_display() { - let _serial = lock_system_test(); - let temp = tempfile::tempdir().expect("temp dir"); - let record = temp.path().join("custom-app-open-args.txt"); - make_recording_command(temp.path(), "open", &record); - let _path = EnvVarGuard::prepend_path(temp.path()); - let workspace = temp.path().join("workspace"); - std::fs::create_dir_all(&workspace).unwrap(); - let app_bundle = temp.path().join("Custom Editor.app"); - std::fs::create_dir_all(&app_bundle).expect("create custom app bundle"); - let request = crate::types::OpenEditorRequest { - editor: "cursor".to_string(), - path: workspace.to_string_lossy().to_string(), - }; - - open_editor_at_path(&request, Some(app_bundle.to_str().unwrap())) - .expect("custom .app should be launched through open -a"); - assert_eq!( - read_recorded_args(&record), - vec![ - "-a".to_string(), - app_bundle.to_string_lossy().to_string(), - workspace.to_string_lossy().to_string() - ] - ); - - let app_contents = app_bundle.join("Contents"); - let resources = app_contents.join("Resources"); - std::fs::create_dir_all(&resources).expect("create app resources"); - std::fs::write( - app_contents.join("Info.plist"), - r#" - -CFBundleIconFileMissingIcon -"#, - ) - .expect("write plist with missing icon"); - assert!(get_app_icon(app_bundle.to_string_lossy().to_string()).is_none()); - - std::fs::write(resources.join("MissingIcon.icns"), b"not an icns").expect("write bad icon"); - assert!(get_app_icon(app_bundle.to_string_lossy().to_string()).is_none()); - } - - #[cfg(target_os = "macos")] - #[serial] - #[test] - fn internal_launcher_wrappers_forward_to_platform_commands() { - let _serial = lock_system_test(); - let temp = tempfile::tempdir().expect("temp dir"); - let open_record = temp.path().join("internal-open-args.txt"); - make_recording_command(temp.path(), "open", &open_record); - let _path = EnvVarGuard::prepend_path(temp.path()); - let target = temp.path().join("target folder"); - std::fs::create_dir_all(&target).unwrap(); - - open_in_terminal_internal(&target.to_string_lossy(), Some("ghostty"), None) - .expect("open terminal through internal wrapper"); - assert_eq!( - read_recorded_args(&open_record), - vec![ - "-a".to_string(), - "Ghostty".to_string(), - target.to_string_lossy().to_string() - ] - ); - - let reveal_record = temp.path().join("internal-reveal-args.txt"); - make_recording_command(temp.path(), "open", &reveal_record); - reveal_in_finder_internal(&target.to_string_lossy()) - .expect("reveal through internal wrapper"); - assert_eq!( - read_recorded_args(&reveal_record), - vec![target.to_string_lossy().to_string()] - ); - - assert!( - get_app_icon_internal(&temp.path().join("missing.app").to_string_lossy()).is_none() - ); - } - - #[serial] - #[test] - fn terminate_process_reports_os_error_for_nonexistent_process() { - let impossible_pid = u32::MAX; - assert_ne!(impossible_pid, std::process::id()); - - let err = terminate_process_impl(impossible_pid).unwrap_err(); - - assert!( - err.contains("Failed to terminate process"), - "unexpected termination error: {err}" - ); - assert!(err.contains(&impossible_pid.to_string()), "{err}"); - } - - #[serial] - #[tokio::test] - async fn mirror_update_handles_missing_fields_invalid_json_and_unknown_speed_url() { - let Ok((base_url, missing_fields_server)) = - spawn_manifest_server(StatusCode::OK, json!({})).await - else { - return; - }; - let manifest = check_mirror_update(base_url) - .await - .expect("missing fields default to empty strings"); - missing_fields_server.abort(); - assert_eq!(manifest["version"], ""); - assert_eq!(manifest["pub_date"], ""); - assert_eq!(manifest["notes"], ""); - assert_eq!(manifest["current_version"], env!("CARGO_PKG_VERSION")); - - let Ok((bad_url, bad_json_server)) = - spawn_text_server(StatusCode::OK, "not valid json").await - else { - return; - }; - let err = check_mirror_update(bad_url).await.unwrap_err(); - bad_json_server.abort(); - assert!(err.contains("Failed to parse mirror manifest"), "{err}"); - - let speed_err = speed_test_single_mirror("https://not-a-configured-mirror.invalid/".into()) - .await - .unwrap_err(); - assert!( - speed_err.contains("Mirror not found: https://not-a-configured-mirror.invalid/"), - "{speed_err}" - ); - } - - #[serial] - #[tokio::test] - async fn detect_tools_internal_and_git_path_commands_are_reachable() { - set_git_path("".to_string()); - set_git_path_internal(""); - - let tools = detect_tools_internal().await; - - assert!( - tools.git.iter().any(|tool| tool.id == "git"), - "expected git in detected tools: {:?}", - tools.git - ); - } -} - -// ==================== 前端日志转发 ==================== - -#[tauri::command] -pub(crate) async fn frontend_log(level: String, message: String) { - // Truncate to 4096 chars and strip control characters to prevent log injection - let sanitized: String = message - .chars() - .take(4096) - .filter(|c| !c.is_control() || *c == '\n') - .collect(); - match level.as_str() { - "error" => log::error!("[frontend] {}", sanitized), - "warn" => log::warn!("[frontend] {}", sanitized), - "info" => log::info!("[frontend] {}", sanitized), - "debug" => log::debug!("[frontend] {}", sanitized), - _ => log::info!("[frontend] {}", sanitized), - } -} diff --git a/src-tauri/src/commands/vault.rs b/src-tauri/src/commands/vault.rs deleted file mode 100644 index dd850d5..0000000 --- a/src-tauri/src/commands/vault.rs +++ /dev/null @@ -1,1658 +0,0 @@ -use std::fs; -use std::path::{Component, Path}; - -use serde::{Deserialize, Serialize}; - -use crate::config::get_window_workspace_path; -use crate::config::{load_workspace_config, save_workspace_config_internal}; - -/// Remove a symlink or junction. On Windows, directory symlinks/junctions -/// require `remove_dir` instead of `remove_file` (which returns OS error 5). -fn remove_symlink(path: &Path) -> std::io::Result<()> { - #[cfg(windows)] - { - if path.is_dir() { - fs::remove_dir(path) - } else { - fs::remove_file(path) - } - } - #[cfg(not(windows))] - { - fs::remove_file(path) - } -} - -// ==================== Data Structures ==================== - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SyncedItem { - pub name: String, - pub item_type: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FailedVaultItem { - pub path: String, - pub reason: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VaultStatus { - pub connected: bool, - pub vault_path: Option, - pub synced_items: Vec, -} - -#[derive(Debug, Clone, Serialize)] -pub struct VaultLinkResponse { - pub connected: bool, - pub synced_items: Vec, - pub failed_items: Vec, - pub error: Option, - pub warning: Option, -} - -// ==================== Core Helper Functions ==================== - -/// Splits a full vault workspace path into (vault_root, workspace_path). -/// -/// Given "/Users/guo/Work/GuoVault/Guo/workspaces/worktree-manager", -/// returns ("/Users/guo/Work/GuoVault/Guo", "workspaces/worktree-manager"). -/// -/// Looks for "/workspaces/" as the split marker. Falls back to using the -/// parent directory as root and filename as workspace_path. -pub fn split_vault_path(full_path: &str) -> Option<(String, String)> { - if full_path.is_empty() { - return None; - } - - // Primary: look for "/workspaces/" marker - if let Some(idx) = full_path.find("/workspaces/") { - let vault_root = &full_path[..idx]; - let workspace_path = &full_path[idx + 1..]; // skip the leading '/' - if !vault_root.is_empty() && !workspace_path.is_empty() { - return Some((vault_root.to_string(), workspace_path.to_string())); - } - } - - // Fallback: parent dir as root, filename as workspace_path - let path = Path::new(full_path); - let parent = path.parent()?.to_str()?; - let file_name = path.file_name()?.to_str()?; - if parent.is_empty() || file_name.is_empty() { - return None; - } - Some((parent.to_string(), file_name.to_string())) -} - -/// Reads the vault path from `.ai/local-overrides.json` in the workspace root. -/// -/// Extracts `vaultRoot` and `vaultWorkspacePath`, combines them into -/// `{vaultRoot}/{vaultWorkspacePath}`. Returns None if the file or fields -/// are missing. -pub fn read_vault_path_from_overrides(workspace_root: &Path) -> Option { - let overrides_path = workspace_root.join(".ai").join("local-overrides.json"); - let content = fs::read_to_string(&overrides_path).ok()?; - let json: serde_json::Value = serde_json::from_str(&content).ok()?; - - let vault_root = json.get("vaultRoot")?.as_str()?; - let vault_workspace_path = json.get("vaultWorkspacePath")?.as_str()?; - - if vault_root.is_empty() || vault_workspace_path.is_empty() { - return None; - } - - Some(format!("{}/{}", vault_root, vault_workspace_path)) -} - -/// Lists vault-synced symlinks in the workspace root directory. -/// -/// A synced item is a symlink in workspace_root whose target lives inside -/// the vault workspace directory (read from overrides). -/// Returns a sorted vec. Returns empty vec if no vault is connected. -pub fn list_synced_items( - workspace_root: &Path, - vault_workspace_dir: Option<&Path>, -) -> Vec { - let vault_dir = match vault_workspace_dir { - Some(d) if d.exists() => d, - _ => return vec![], - }; - - let mut items = vec![]; - if let Ok(entries) = fs::read_dir(workspace_root) { - for entry in entries.flatten() { - let path = entry.path(); - // Check if this is a symlink pointing into the vault - if let Ok(target) = fs::read_link(&path) { - let target_abs = if target.is_absolute() { - target - } else { - workspace_root.join(&target) - }; - if target_abs.starts_with(vault_dir) { - let name = entry.file_name().to_string_lossy().to_string(); - let item_type = if path.is_dir() { "directory" } else { "file" }; - items.push(SyncedItem { - name, - item_type: item_type.to_string(), - }); - } - } - } - } - items.sort_by(|a, b| a.name.cmp(&b.name)); - items -} - -/// Built-in files/dirs that must never be overwritten by vault symlinks. -const BUILT_IN_BLACKLIST: &[&str] = &[ - ".DS_Store", - "Thumbs.db", - ".git", - "node_modules", - ".worktree-manager.json", - "projects", - "worktrees", -]; - -/// Creates symlinks directly in workspace root pointing to vault workspace entries. -/// -/// For each entry in `vault_workspace_dir`: -/// - Skips OS metadata files and Worktree built-in files/dirs -/// - If workspace root already has a non-symlink file/dir with the same name, -/// back it up to `{name}.local` before creating the symlink. -/// - If a symlink already exists (from a previous vault link), remove and recreate it. -/// -/// `extra_ignored` allows passing additional names to skip (e.g. custom `worktrees_dir`). -/// -/// Also removes stale symlinks from any previous vault connection. -/// -/// Returns `(synced_items, failed_items)` where failed_items contains items that could -/// not be symlinked (max 3). Partial success is allowed - some items may succeed while -/// others fail. -pub fn create_vault_symlinks( - workspace_root: &Path, - vault_workspace_dir: &Path, - extra_ignored: &[&str], -) -> Result<(Vec, Vec), String> { - use std::collections::HashSet; - - // First: remove old vault symlinks (symlinks in workspace root pointing to any vault) - remove_vault_symlinks(workspace_root)?; - - // Read entries from vault workspace dir - let entries = fs::read_dir(vault_workspace_dir) - .map_err(|e| format!("Failed to read vault workspace directory: {}", e))?; - - let ignored: HashSet<&str> = BUILT_IN_BLACKLIST - .iter() - .chain(extra_ignored.iter()) - .copied() - .collect(); - let mut items: Vec = Vec::new(); - let mut failed_items: Vec = Vec::new(); - - for entry in entries { - let entry = match entry { - Ok(e) => e, - Err(e) => { - // Continue on error, collect failed entry - if failed_items.len() < 3 { - failed_items.push(FailedVaultItem { - path: String::new(), - reason: format!("Failed to read directory entry: {}", e), - }); - } - continue; - } - }; - let source = entry.path(); - let file_name = match entry.file_name().to_str() { - Some(name) => name.to_string(), - None => { - if failed_items.len() < 3 { - failed_items.push(FailedVaultItem { - path: String::new(), - reason: "Invalid file name".to_string(), - }); - } - continue; - } - }; - - // Skip blacklisted files - if ignored.contains(file_name.as_str()) { - continue; - } - - let link_path = workspace_root.join(&file_name); - - // Handle existing file/dir/symlink at the link path - if link_path.symlink_metadata().is_ok() { - let is_symlink = link_path - .symlink_metadata() - .map(|m| m.file_type().is_symlink()) - .unwrap_or(false); - - if is_symlink { - // Check if it points into the current vault (safe to replace) - // Otherwise backup the symlink target info before removing - let is_vault_link = if let Ok(target) = fs::read_link(&link_path) { - target.starts_with(source.parent().unwrap_or(Path::new(""))) - } else { - false - }; - - if !is_vault_link { - // Non-vault symlink — backup by saving target as {name}.local.link - if let Ok(target) = fs::read_link(&link_path) { - let backup_path = workspace_root.join(format!("{}.local.link", file_name)); - if let Err(e) = fs::write(&backup_path, target.to_string_lossy().as_bytes()) - { - if failed_items.len() < 3 { - failed_items.push(FailedVaultItem { - path: file_name.clone(), - reason: format!("Failed to backup symlink target: {}", e), - }); - } - continue; - } - log::info!( - "[vault] Backed up symlink {} → {}.local.link", - file_name, - file_name - ); - } - } - - if let Err(e) = remove_symlink(&link_path) { - if failed_items.len() < 3 { - failed_items.push(FailedVaultItem { - path: file_name.clone(), - reason: format!("Failed to remove existing symlink: {}", e), - }); - } - continue; - } - } else { - // Existing real file/dir — backup to {name}.local - let backup_path = workspace_root.join(format!("{}.local", file_name)); - if let Err(e) = fs::rename(&link_path, &backup_path) { - if failed_items.len() < 3 { - failed_items.push(FailedVaultItem { - path: file_name.clone(), - reason: format!("Failed to backup to '{}.local': {}", file_name, e), - }); - } - continue; - } - log::info!("[vault] Backed up {} → {}.local", file_name, file_name); - } - } - - // Create symlink - #[cfg(unix)] - let symlink_ok = std::os::unix::fs::symlink(&source, &link_path).is_ok(); - #[cfg(windows)] - let symlink_ok = if source.is_dir() { - std::os::windows::fs::symlink_dir(&source, &link_path).is_ok() - } else { - std::os::windows::fs::symlink_file(&source, &link_path).is_ok() - }; - - if !symlink_ok { - let err_msg = if source.is_dir() { - "Failed to create symlink (directory)" - } else { - "Failed to create symlink (file)" - }; - if failed_items.len() < 3 { - failed_items.push(FailedVaultItem { - path: file_name.clone(), - reason: err_msg.to_string(), - }); - } - continue; - } - - let item_type = if source.is_dir() { "directory" } else { "file" }; - items.push(SyncedItem { - name: file_name, - item_type: item_type.to_string(), - }); - } - - items.sort_by(|a, b| a.name.cmp(&b.name)); - Ok((items, failed_items)) -} - -/// Workspace-critical names that must not appear in a Vault directory. -const CONFLICT_NAMES: &[&str] = &[".worktree-manager.json", "projects", ".git", "node_modules"]; - -/// Check if vault directory contains files/folders that conflict with workspace structure. -/// Returns a list of conflicting names. Empty means safe to proceed. -fn check_vault_conflicts( - vault_workspace_dir: &Path, - extra_ignored: &[&str], -) -> Result, String> { - let entries = fs::read_dir(vault_workspace_dir) - .map_err(|e| format!("Failed to read vault directory: {}", e))?; - - let mut conflicts: Vec = Vec::new(); - for entry in entries { - let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; - let name = entry.file_name().to_string_lossy().to_string(); - - if name == ".DS_Store" || name == "Thumbs.db" { - continue; - } - if CONFLICT_NAMES.contains(&name.as_str()) { - conflicts.push(name); - continue; - } - if extra_ignored.contains(&name.as_str()) { - conflicts.push(name); - } - } - Ok(conflicts) -} - -/// Sync vault-linked items from workspace root into every existing worktree. -fn sync_vault_to_all_worktrees( - workspace_root: &Path, - worktrees_dir: &str, - item_names: &[String], -) -> Result<(usize, Vec), String> { - let worktrees_path = workspace_root.join(worktrees_dir); - if !worktrees_path.exists() { - return Ok((0, vec![])); - } - - let mut synced_count = 0; - let mut errors: Vec = Vec::new(); - - let entries = fs::read_dir(&worktrees_path) - .map_err(|e| format!("Failed to read worktrees directory: {}", e))?; - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - if name.starts_with('.') || name.ends_with(".archive") { - continue; - } - - for item in item_names { - let src = workspace_root.join(item); - let dst = path.join(item); - if src.exists() && !dst.exists() { - if let Err(e) = crate::commands::worktree::create_symlink(&src, &dst) { - errors.push(format!("{} in {}: {}", item, name, e)); - } else { - synced_count += 1; - } - } - } - } - - Ok((synced_count, errors)) -} - -/// Removes vault symlinks from workspace root. -/// -/// Scans workspace root for symlinks, removes those whose target matches -/// the previously configured vault path (from overrides), or any broken symlinks. -/// Restores `.local` backups if they exist. -fn remove_vault_symlinks_matching( - workspace_root: &Path, - vault_path: Option<&Path>, -) -> Result<(), String> { - if let Ok(entries) = fs::read_dir(workspace_root) { - for entry in entries.flatten() { - let path = entry.path(); - let meta = match path.symlink_metadata() { - Ok(m) => m, - Err(_) => continue, - }; - - if !meta.file_type().is_symlink() { - continue; - } - - let should_remove = if let Ok(target) = fs::read_link(&path) { - let target_abs = if target.is_absolute() { - target.clone() - } else { - workspace_root.join(&target) - }; - // Remove if pointing into old vault, or if broken - vault_path.is_some_and(|vp| target_abs.starts_with(vp)) || !target_abs.exists() - } else { - false - }; - - if should_remove { - let name = entry.file_name().to_string_lossy().to_string(); - remove_symlink(&path) - .map_err(|e| format!("Failed to remove symlink '{}': {}", name, e))?; - - // Restore .local backup (regular file/dir) if exists - let backup_path = workspace_root.join(format!("{}.local", name)); - if backup_path.exists() { - fs::rename(&backup_path, &path) - .map_err(|e| format!("Failed to restore '{}.local': {}", name, e))?; - log::info!("[vault] Restored {}.local → {}", name, name); - } - - // Restore .local.link backup (symlink) if exists - if restore_local_link_backup(workspace_root, &name)? { - log::info!("[vault] Restored symlink {}.local.link → {}", name, name); - } - } - } - } - Ok(()) -} - -fn restore_local_link_backup(workspace_root: &Path, name: &str) -> Result { - let link_backup = workspace_root.join(format!("{}.local.link", name)); - if !link_backup.exists() { - return Ok(false); - } - - let target = fs::read_to_string(&link_backup) - .map_err(|e| format!("Failed to read symlink backup '{}.local.link': {}", name, e))?; - let target = target.trim(); - if target.is_empty() { - return Ok(false); - } - - let path = workspace_root.join(name); - let target_path = Path::new(target); - if !is_workspace_relative_symlink_target(target_path) { - log::warn!( - "[vault] Skipping unsafe symlink backup '{}.local.link' with target '{}'", - name, - target - ); - return Ok(false); - } - - #[cfg(unix)] - { - std::os::unix::fs::symlink(target_path, &path) - .map_err(|e| format!("Failed to restore symlink '{}': {}", name, e))?; - } - - #[cfg(windows)] - { - let resolved_target = if target_path.is_absolute() { - target_path.to_path_buf() - } else { - workspace_root.join(target_path) - }; - let use_dir_symlink = fs::metadata(&resolved_target) - .map(|m| m.is_dir()) - .unwrap_or(false); - - if use_dir_symlink { - std::os::windows::fs::symlink_dir(target_path, &path) - .map_err(|e| format!("Failed to restore symlink '{}': {}", name, e))?; - } else { - std::os::windows::fs::symlink_file(target_path, &path) - .map_err(|e| format!("Failed to restore symlink '{}': {}", name, e))?; - } - } - - fs::remove_file(&link_backup).map_err(|e| { - format!( - "Failed to remove symlink backup '{}.local.link': {}", - name, e - ) - })?; - Ok(true) -} - -fn is_workspace_relative_symlink_target(target: &Path) -> bool { - if target.is_absolute() { - return false; - } - - let mut depth = 0usize; - for component in target.components() { - match component { - Component::Normal(_) => depth += 1, - Component::CurDir => {} - Component::ParentDir => { - if depth == 0 { - return false; - } - depth -= 1; - } - Component::RootDir | Component::Prefix(_) => return false, - } - } - - true -} - -fn remove_vault_symlinks(workspace_root: &Path) -> Result<(), String> { - let vault_path = read_vault_path_from_overrides(workspace_root); - remove_vault_symlinks_matching(workspace_root, vault_path.as_deref().map(Path::new)) -} - -/// Saves vault configuration to `.ai/local-overrides.json`. -/// -/// Reads existing file (or starts from empty `{}`), sets `vaultRoot` and -/// `vaultWorkspacePath`, and writes back. -pub fn save_vault_to_overrides( - workspace_root: &Path, - vault_root: &str, - vault_workspace_path: &str, -) -> Result<(), String> { - let ai_dir = workspace_root.join(".ai"); - fs::create_dir_all(&ai_dir).map_err(|e| format!("Failed to create .ai/ directory: {}", e))?; - - let overrides_path = ai_dir.join("local-overrides.json"); - - let mut json: serde_json::Value = if overrides_path.exists() { - let content = fs::read_to_string(&overrides_path) - .map_err(|e| format!("Failed to read local-overrides.json: {}", e))?; - serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse local-overrides.json: {}", e))? - } else { - serde_json::json!({}) - }; - - let obj = json - .as_object_mut() - .ok_or_else(|| "local-overrides.json is not a JSON object".to_string())?; - obj.insert( - "vaultRoot".to_string(), - serde_json::Value::String(vault_root.to_string()), - ); - obj.insert( - "vaultWorkspacePath".to_string(), - serde_json::Value::String(vault_workspace_path.to_string()), - ); - - let content = serde_json::to_string_pretty(&json) - .map_err(|e| format!("Failed to serialize local-overrides.json: {}", e))?; - fs::write(&overrides_path, content) - .map_err(|e| format!("Failed to write local-overrides.json: {}", e))?; - - Ok(()) -} - -/// Removes vault configuration from `.ai/local-overrides.json`. -/// -/// Removes only the `vaultRoot` and `vaultWorkspacePath` fields, preserving -/// other settings. -pub fn clear_vault_from_overrides(workspace_root: &Path) -> Result<(), String> { - let overrides_path = workspace_root.join(".ai").join("local-overrides.json"); - - if !overrides_path.exists() { - return Ok(()); - } - - let content = fs::read_to_string(&overrides_path) - .map_err(|e| format!("Failed to read local-overrides.json: {}", e))?; - let mut json: serde_json::Value = serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse local-overrides.json: {}", e))?; - - if let Some(obj) = json.as_object_mut() { - obj.remove("vaultRoot"); - obj.remove("vaultWorkspacePath"); - } - - let content = serde_json::to_string_pretty(&json) - .map_err(|e| format!("Failed to serialize local-overrides.json: {}", e))?; - fs::write(&overrides_path, content) - .map_err(|e| format!("Failed to write local-overrides.json: {}", e))?; - - Ok(()) -} - -/// Update `vault_linked_workspace_items` in `.worktree-manager.json`. -/// -/// Sets the field to the given list of item names. -/// Pass an empty vec to clear the field. -pub fn update_vault_linked_items(workspace_root: &Path, items: &[String]) -> Result<(), String> { - let config_path = workspace_root.join(".worktree-manager.json"); - if !config_path.exists() { - return Ok(()); // No workspace config — nothing to update - } - - let workspace_path = workspace_root.to_string_lossy().to_string(); - let mut config = load_workspace_config(&workspace_path); - config.vault_linked_workspace_items = items.to_vec(); - save_workspace_config_internal(&workspace_path, &config) -} - -fn get_vault_linked_items(workspace_root: &Path) -> Vec { - let config_path = workspace_root.join(".worktree-manager.json"); - if !config_path.exists() { - return vec![]; - } - - let workspace_path = workspace_root.to_string_lossy().to_string(); - load_workspace_config(&workspace_path).vault_linked_workspace_items -} - -fn restore_previous_vault_state( - workspace_root: &Path, - attempted_vault_dir: Option<&Path>, - previous_vault_path: Option<&str>, - previous_linked_items: &[String], - extra_ignored: &[&str], -) -> Result<(), String> { - if let Some(dir) = attempted_vault_dir { - remove_vault_symlinks_matching(workspace_root, Some(dir))?; - } - - match previous_vault_path { - Some(previous_path) => { - let previous_dir = Path::new(previous_path); - if !previous_dir.is_dir() { - return Err(format!( - "Cannot restore previous vault directory: {}", - previous_path - )); - } - - let (vault_root, vault_workspace_path) = split_vault_path(previous_path) - .ok_or_else(|| format!("Cannot parse previous vault path: {}", previous_path))?; - save_vault_to_overrides(workspace_root, &vault_root, &vault_workspace_path)?; - create_vault_symlinks(workspace_root, previous_dir, extra_ignored)?; - } - None => { - clear_vault_from_overrides(workspace_root)?; - } - } - - update_vault_linked_items(workspace_root, previous_linked_items)?; - Ok(()) -} - -// ==================== Impl Functions ==================== - -/// Returns the current vault status for the workspace bound to the given window. -pub fn vault_status_impl(window_label: &str) -> Result { - let workspace_path = - get_window_workspace_path(window_label).ok_or("No workspace bound to window")?; - let workspace_root = Path::new(&workspace_path); - - let vault_full_path = read_vault_path_from_overrides(workspace_root); - let vault_dir = vault_full_path.as_ref().map(|p| Path::new(p.as_str())); - let synced_items = list_synced_items(workspace_root, vault_dir); - let connected = vault_full_path.is_some(); - - Ok(VaultStatus { - connected, - vault_path: vault_full_path, - synced_items, - }) -} - -/// Links or unlinks a vault for the workspace bound to the given window. -/// -/// - `path = Some(...)`: validate, create symlinks, save overrides -/// - `path = None`: remove .vault/, clear overrides -pub fn vault_link_impl( - window_label: &str, - path: Option, - keep_symlinks: bool, -) -> Result { - let workspace_path = - get_window_workspace_path(window_label).ok_or("No workspace bound to window")?; - let workspace_root = Path::new(&workspace_path); - - match path { - Some(vault_path) => { - let vault_dir = Path::new(&vault_path); - let previous_vault_path = read_vault_path_from_overrides(workspace_root); - let previous_linked_items = get_vault_linked_items(workspace_root); - - // Build extra blacklist from workspace config (e.g. custom worktrees_dir) - let ws_config = load_workspace_config(&workspace_path); - let mut extra_ignored: Vec<&str> = Vec::new(); - let worktrees_dir = ws_config.worktrees_dir.as_str(); - if worktrees_dir != "worktrees" { - extra_ignored.push(worktrees_dir); - } - - // Validate directory exists - if !vault_dir.is_dir() { - return Ok(VaultLinkResponse { - connected: false, - synced_items: Vec::new(), - failed_items: Vec::new(), - error: Some(format!("Directory does not exist: {}", vault_path)), - warning: None, - }); - } - - // Check for structural conflicts before proceeding - let conflicts = check_vault_conflicts(vault_dir, &extra_ignored)?; - if !conflicts.is_empty() { - let msg = format!( - "以下文件/文件夹与 WorktreeManager 的结构冲突,无法挂载。请移除或更改名字后重试: {}", - conflicts.join(", ") - ); - return Ok(VaultLinkResponse { - connected: false, - synced_items: Vec::new(), - failed_items: Vec::new(), - error: Some(msg), - warning: None, - }); - } - - // Prevent self-link - let canonical_workspace = workspace_root - .canonicalize() - .map_err(|e| format!("Failed to resolve workspace path: {}", e))?; - let canonical_vault = vault_dir - .canonicalize() - .map_err(|e| format!("Failed to resolve vault path: {}", e))?; - if canonical_workspace == canonical_vault { - return Ok(VaultLinkResponse { - connected: false, - synced_items: Vec::new(), - failed_items: Vec::new(), - error: Some("Cannot link a workspace to itself".to_string()), - warning: None, - }); - } - - // Create symlinks (partial failure allowed - some items may fail) - let (synced_items, failed_items) = - create_vault_symlinks(workspace_root, vault_dir, &extra_ignored)?; - - // Save overrides - let (vault_root, vault_workspace_path) = split_vault_path(&vault_path) - .ok_or_else(|| format!("Cannot parse vault path: {}", vault_path))?; - let item_names: Vec = synced_items.iter().map(|i| i.name.clone()).collect(); - let persist_result = (|| -> Result<(), String> { - save_vault_to_overrides(workspace_root, &vault_root, &vault_workspace_path)?; - update_vault_linked_items(workspace_root, &item_names)?; - Ok(()) - })(); - - if let Err(persist_err) = persist_result { - restore_previous_vault_state( - workspace_root, - Some(vault_dir), - previous_vault_path.as_deref(), - &previous_linked_items, - &extra_ignored, - ) - .map_err(|rollback_err| { - format!("{} (rollback failed: {})", persist_err, rollback_err) - })?; - return Err(persist_err); - } - - // Sync vault items to all existing worktrees - let (synced_count, sync_errors) = - sync_vault_to_all_worktrees(workspace_root, worktrees_dir, &item_names)?; - if synced_count > 0 { - log::info!("[vault] Synced {} items to worktrees", synced_count); - } - if !sync_errors.is_empty() { - log::warn!("[vault] Sync errors: {:?}", sync_errors); - } - - Ok(VaultLinkResponse { - connected: true, - synced_items, - failed_items, - error: None, - warning: None, - }) - } - None => { - let previous_vault_path = read_vault_path_from_overrides(workspace_root); - let previous_linked_items = get_vault_linked_items(workspace_root); - - // Disconnect: optionally remove vault symlinks, clear overrides, clear linked items - if !keep_symlinks { - remove_vault_symlinks(workspace_root)?; - } - let persist_result = (|| -> Result<(), String> { - clear_vault_from_overrides(workspace_root)?; - update_vault_linked_items(workspace_root, &[])?; - Ok(()) - })(); - - if let Err(persist_err) = persist_result { - restore_previous_vault_state( - workspace_root, - None, - previous_vault_path.as_deref(), - &previous_linked_items, - &[], - ) - .map_err(|rollback_err| { - format!("{} (rollback failed: {})", persist_err, rollback_err) - })?; - return Err(persist_err); - } - - Ok(VaultLinkResponse { - connected: false, - synced_items: Vec::new(), - failed_items: Vec::new(), - error: None, - warning: None, - }) - } - } -} - -// ==================== Tauri IPC Wrappers ==================== - -#[tauri::command] -pub(crate) fn vault_status(window: tauri::Window) -> Result { - vault_status_impl(window.label()) -} - -#[tauri::command] -pub(crate) fn vault_link( - window: tauri::Window, - path: Option, - keep_symlinks: Option, -) -> Result { - vault_link_impl(window.label(), path, keep_symlinks.unwrap_or(false)) -} - -/// List children of a vault item (file or directory). -/// Returns at most 100 items. Returns an error if the directory contains >99 entries. -#[tauri::command] -pub(crate) fn list_vault_item_children( - vault_path: String, - relative_path: String, -) -> Result, String> { - use std::fs; - - let relative = Path::new(&relative_path); - if relative - .components() - .any(|component| matches!(component, Component::ParentDir)) - { - return Err("路径越界".to_string()); - } - - let vault_root = Path::new(&vault_path); - let canonical_vault = vault_root - .canonicalize() - .map_err(|e| format!("Failed to resolve vault path: {}", e))?; - let dir = vault_root.join(relative); - let canonical_dir = dir - .canonicalize() - .map_err(|e| format!("Failed to resolve directory: {}", e))?; - - if !canonical_dir.starts_with(&canonical_vault) { - return Err("路径越界".to_string()); - } - - if !canonical_dir.is_dir() { - return Err(format!("'{}' is not a directory", relative_path)); - } - - let entries = - fs::read_dir(&canonical_dir).map_err(|e| format!("Failed to read directory: {}", e))?; - - let mut children: Vec = Vec::new(); - for entry in entries { - let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; - let name = entry.file_name().to_string_lossy().to_string(); - // Skip hidden/system files - if name.starts_with('.') { - continue; - } - let item_type = if entry.path().is_dir() { - "directory" - } else { - "file" - }; - children.push(crate::types::VaultItemChild { - name, - item_type: item_type.to_string(), - }); - if children.len() >= 100 { - return Err(format!( - "Directory '{}' contains too many items (>99)", - relative_path - )); - } - } - - children.sort_by(|a, b| match (a.item_type.as_str(), b.item_type.as_str()) { - ("directory", "file") => std::cmp::Ordering::Less, - ("file", "directory") => std::cmp::Ordering::Greater, - _ => a.name.cmp(&b.name), - }); - - Ok(children) -} - -// ==================== Tests ==================== - -#[cfg(all(test, not(windows)))] -mod tests { - use serial_test::serial; - - use super::*; - use crate::config::{load_workspace_config, save_workspace_config_internal}; - use crate::state::{WINDOW_WORKSPACES, WORKSPACE_CONFIG_CACHE}; - use crate::types::WorkspaceConfig; - use std::fs; - use tempfile::TempDir; - - // ---- split_vault_path ---- - - #[serial] - #[test] - fn test_split_vault_path_with_workspaces() { - let result = split_vault_path("/Users/guo/Work/GuoVault/Guo/workspaces/worktree-manager"); - assert!(result.is_some()); - let (root, ws_path) = result.unwrap(); - assert_eq!(root, "/Users/guo/Work/GuoVault/Guo"); - assert_eq!(ws_path, "workspaces/worktree-manager"); - } - - #[serial] - #[test] - fn test_split_vault_path_with_nested_workspaces() { - let result = split_vault_path("/vault/root/workspaces/deep/nested/project"); - assert!(result.is_some()); - let (root, ws_path) = result.unwrap(); - assert_eq!(root, "/vault/root"); - assert_eq!(ws_path, "workspaces/deep/nested/project"); - } - - #[serial] - #[test] - fn test_split_vault_path_fallback() { - let result = split_vault_path("/some/random/path"); - assert!(result.is_some()); - let (root, ws_path) = result.unwrap(); - assert_eq!(root, "/some/random"); - assert_eq!(ws_path, "path"); - } - - #[serial] - #[test] - fn test_split_vault_path_empty() { - assert!(split_vault_path("").is_none()); - } - - // ---- read_vault_path_from_overrides ---- - - #[serial] - #[test] - fn test_read_overrides_valid() { - let tmp = TempDir::new().unwrap(); - let ai_dir = tmp.path().join(".ai"); - fs::create_dir_all(&ai_dir).unwrap(); - fs::write( - ai_dir.join("local-overrides.json"), - r#"{"vaultRoot": "/Users/guo/Work/GuoVault/Guo", "vaultWorkspacePath": "workspaces/worktree-manager"}"#, - ) - .unwrap(); - - let result = read_vault_path_from_overrides(tmp.path()); - assert_eq!( - result, - Some("/Users/guo/Work/GuoVault/Guo/workspaces/worktree-manager".to_string()) - ); - } - - #[serial] - #[test] - fn test_read_overrides_missing_file() { - let tmp = TempDir::new().unwrap(); - let result = read_vault_path_from_overrides(tmp.path()); - assert!(result.is_none()); - } - - #[serial] - #[test] - fn test_read_overrides_missing_fields() { - let tmp = TempDir::new().unwrap(); - let ai_dir = tmp.path().join(".ai"); - fs::create_dir_all(&ai_dir).unwrap(); - - // Missing vaultWorkspacePath - fs::write( - ai_dir.join("local-overrides.json"), - r#"{"vaultRoot": "/some/root"}"#, - ) - .unwrap(); - assert!(read_vault_path_from_overrides(tmp.path()).is_none()); - - // Missing vaultRoot - fs::write( - ai_dir.join("local-overrides.json"), - r#"{"vaultWorkspacePath": "workspaces/foo"}"#, - ) - .unwrap(); - assert!(read_vault_path_from_overrides(tmp.path()).is_none()); - } - - #[serial] - #[test] - fn test_read_overrides_empty_values() { - let tmp = TempDir::new().unwrap(); - let ai_dir = tmp.path().join(".ai"); - fs::create_dir_all(&ai_dir).unwrap(); - fs::write( - ai_dir.join("local-overrides.json"), - r#"{"vaultRoot": "", "vaultWorkspacePath": "workspaces/foo"}"#, - ) - .unwrap(); - assert!(read_vault_path_from_overrides(tmp.path()).is_none()); - } - - // ---- list_vault_item_children ---- - - #[serial] - #[test] - fn test_list_vault_item_children_allows_normal_child_path() { - let vault = TempDir::new().unwrap(); - fs::create_dir_all(vault.path().join("notes")).unwrap(); - fs::write(vault.path().join("notes").join("b.md"), "b").unwrap(); - fs::write(vault.path().join("notes").join("a.md"), "a").unwrap(); - - let children = list_vault_item_children( - vault.path().to_string_lossy().to_string(), - "notes".to_string(), - ) - .unwrap(); - - let names: Vec<_> = children.into_iter().map(|child| child.name).collect(); - assert_eq!(names, vec!["a.md", "b.md"]); - } - - #[serial] - #[test] - fn test_list_vault_item_children_rejects_parent_path_escape() { - let parent = TempDir::new().unwrap(); - let vault_path = parent.path().join("vault"); - let outside_path = parent.path().join("outside"); - fs::create_dir_all(&vault_path).unwrap(); - fs::create_dir_all(&outside_path).unwrap(); - fs::write(outside_path.join("secret.md"), "secret").unwrap(); - - let err = list_vault_item_children( - vault_path.to_string_lossy().to_string(), - "../outside".to_string(), - ) - .unwrap_err(); - - assert!(err.contains("路径越界")); - } - - // ---- list_synced_items ---- - - #[serial] - #[test] - fn test_list_synced_items() { - let workspace = TempDir::new().unwrap(); - let vault_source = TempDir::new().unwrap(); - - // Create vault source entries - fs::write(vault_source.path().join("CLAUDE.md"), "# test").unwrap(); - fs::create_dir_all(vault_source.path().join("architecture")).unwrap(); - fs::write(vault_source.path().join("repos.md"), "repos").unwrap(); - - // Create symlinks in workspace root pointing to vault - #[cfg(unix)] - { - std::os::unix::fs::symlink( - vault_source.path().join("CLAUDE.md"), - workspace.path().join("CLAUDE.md"), - ) - .unwrap(); - std::os::unix::fs::symlink( - vault_source.path().join("architecture"), - workspace.path().join("architecture"), - ) - .unwrap(); - std::os::unix::fs::symlink( - vault_source.path().join("repos.md"), - workspace.path().join("repos.md"), - ) - .unwrap(); - } - - // Also create a non-vault file (should not be listed) - fs::write(workspace.path().join("local-file.txt"), "local").unwrap(); - - let items = list_synced_items(workspace.path(), Some(vault_source.path())); - assert_eq!(items.len(), 3); - assert_eq!(items[0].name, "CLAUDE.md"); - assert_eq!(items[0].item_type, "file"); - assert_eq!(items[1].name, "architecture"); - assert_eq!(items[1].item_type, "directory"); - assert_eq!(items[2].name, "repos.md"); - assert_eq!(items[2].item_type, "file"); - } - - #[serial] - #[test] - fn test_list_synced_items_no_vault() { - let workspace = TempDir::new().unwrap(); - fs::write(workspace.path().join("local.md"), "local").unwrap(); - - let items = list_synced_items(workspace.path(), None); - assert!(items.is_empty()); - } - - #[serial] - #[test] - fn test_list_synced_items_no_symlinks() { - let workspace = TempDir::new().unwrap(); - let vault_source = TempDir::new().unwrap(); - fs::write(workspace.path().join("local.md"), "local").unwrap(); - - let items = list_synced_items(workspace.path(), Some(vault_source.path())); - assert!(items.is_empty()); - } - - // ---- create_vault_symlinks ---- - - #[serial] - #[test] - fn test_create_symlinks_at_workspace_root() { - let workspace = TempDir::new().unwrap(); - let vault_source = TempDir::new().unwrap(); - - fs::write(vault_source.path().join("CLAUDE.md"), "# claude").unwrap(); - fs::create_dir_all(vault_source.path().join("memory")).unwrap(); - fs::write(vault_source.path().join("repos.md"), "repos").unwrap(); - - let (items, _failed) = - create_vault_symlinks(workspace.path(), vault_source.path(), &[]).unwrap(); - assert_eq!(items.len(), 3); - - // Symlinks created directly in workspace root (not in .vault/) - assert!(workspace.path().join("CLAUDE.md").exists()); - assert!(workspace.path().join("memory").is_dir()); - assert!(workspace.path().join("repos.md").exists()); - - // Verify symlink targets - let link_target = fs::read_link(workspace.path().join("CLAUDE.md")).unwrap(); - assert_eq!(link_target, vault_source.path().join("CLAUDE.md")); - } - - #[serial] - #[test] - fn test_create_symlinks_backs_up_existing_files() { - let workspace = TempDir::new().unwrap(); - let vault_source = TempDir::new().unwrap(); - - // Create existing local file - fs::write(workspace.path().join("CLAUDE.md"), "local content").unwrap(); - - // Vault has same-named file - fs::write(vault_source.path().join("CLAUDE.md"), "vault content").unwrap(); - - let (items, _failed) = - create_vault_symlinks(workspace.path(), vault_source.path(), &[]).unwrap(); - assert_eq!(items.len(), 1); - - // Original backed up to .local - let backup = workspace.path().join("CLAUDE.md.local"); - assert!(backup.exists()); - assert_eq!(fs::read_to_string(&backup).unwrap(), "local content"); - - // Symlink created - let link_target = fs::read_link(workspace.path().join("CLAUDE.md")).unwrap(); - assert_eq!(link_target, vault_source.path().join("CLAUDE.md")); - } - - #[serial] - #[test] - fn test_create_symlinks_replaces_old_vault_symlinks() { - let workspace = TempDir::new().unwrap(); - let vault_source_1 = TempDir::new().unwrap(); - let vault_source_2 = TempDir::new().unwrap(); - - // First vault: create symlink manually + set overrides - fs::write(vault_source_1.path().join("old.md"), "old").unwrap(); - #[cfg(unix)] - std::os::unix::fs::symlink( - vault_source_1.path().join("old.md"), - workspace.path().join("old.md"), - ) - .unwrap(); - - // Point overrides to vault_source_1 so remove_vault_symlinks can identify the old links - let v1 = vault_source_1.path().to_str().unwrap(); - let parent = Path::new(v1).parent().unwrap().to_str().unwrap(); - let name = Path::new(v1).file_name().unwrap().to_str().unwrap(); - save_vault_to_overrides(workspace.path(), parent, name).unwrap(); - - assert!(workspace.path().join("old.md").exists()); - - // Second vault: create_vault_symlinks should remove old + create new - fs::write(vault_source_2.path().join("new.md"), "new").unwrap(); - let (items, _failed) = - create_vault_symlinks(workspace.path(), vault_source_2.path(), &[]).unwrap(); - - // Old symlink removed, new one created - assert!(!workspace.path().join("old.md").exists()); - assert!(workspace.path().join("new.md").exists()); - assert_eq!(items.len(), 1); - assert_eq!(items[0].name, "new.md"); - } - - #[serial] - #[test] - fn test_disconnect_restores_backups() { - let workspace = TempDir::new().unwrap(); - let vault_source = TempDir::new().unwrap(); - - // Create local file that will be backed up - fs::write(workspace.path().join("CLAUDE.md"), "local").unwrap(); - - // Vault has same-named file - fs::write(vault_source.path().join("CLAUDE.md"), "vault").unwrap(); - - // Connect vault (backs up CLAUDE.md → CLAUDE.md.local) - create_vault_symlinks(workspace.path(), vault_source.path(), &[]).unwrap(); - assert!(workspace.path().join("CLAUDE.md.local").exists()); - - // Set overrides so remove_vault_symlinks can identify the links - let v = vault_source.path().to_str().unwrap(); - let parent = Path::new(v).parent().unwrap().to_str().unwrap(); - let name = Path::new(v).file_name().unwrap().to_str().unwrap(); - save_vault_to_overrides(workspace.path(), parent, name).unwrap(); - - // Disconnect (should restore backup) - remove_vault_symlinks(workspace.path()).unwrap(); - assert!(!workspace.path().join("CLAUDE.md.local").exists()); - assert!(workspace.path().join("CLAUDE.md").exists()); - assert_eq!( - fs::read_to_string(workspace.path().join("CLAUDE.md")).unwrap(), - "local" - ); - } - - #[serial] - #[test] - fn test_vault_status_reports_connected_even_with_no_synced_items() { - let workspace = TempDir::new().unwrap(); - let vault_source = TempDir::new().unwrap(); - let workspace_path = workspace.path().to_string_lossy().to_string(); - let window_label = "vault-status-empty-test"; - - { - let mut windows = WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - windows.insert(window_label.to_string(), workspace_path.clone()); - } - { - let mut cache = WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = None; - } - - save_workspace_config_internal( - &workspace_path, - &WorkspaceConfig { - name: "demo".to_string(), - ..WorkspaceConfig::default() - }, - ) - .unwrap(); - - let v = vault_source.path().to_str().unwrap(); - let parent = Path::new(v).parent().unwrap().to_str().unwrap(); - let name = Path::new(v).file_name().unwrap().to_str().unwrap(); - save_vault_to_overrides(workspace.path(), parent, name).unwrap(); - - let status = vault_status_impl(window_label).unwrap(); - assert!(status.connected); - assert_eq!( - status.vault_path, - Some(vault_source.path().to_string_lossy().to_string()) - ); - assert!(status.synced_items.is_empty()); - - { - let mut windows = WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - windows.remove(window_label); - } - { - let mut cache = WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = None; - } - } - - #[serial] - #[test] - fn test_create_symlinks_backs_up_non_vault_symlink_and_disconnect_restores_it() { - let workspace = TempDir::new().unwrap(); - let vault_source = TempDir::new().unwrap(); - - let local_target = workspace.path().join("local-claude.md"); - fs::write(&local_target, "local symlink target").unwrap(); - fs::write(vault_source.path().join("CLAUDE.md"), "vault").unwrap(); - - #[cfg(unix)] - std::os::unix::fs::symlink("local-claude.md", workspace.path().join("CLAUDE.md")).unwrap(); - - create_vault_symlinks(workspace.path(), vault_source.path(), &[]).unwrap(); - - let link_backup = workspace.path().join("CLAUDE.md.local.link"); - assert!(link_backup.exists()); - assert_eq!(fs::read_to_string(&link_backup).unwrap(), "local-claude.md"); - - let link_target = fs::read_link(workspace.path().join("CLAUDE.md")).unwrap(); - assert_eq!(link_target, vault_source.path().join("CLAUDE.md")); - - let v = vault_source.path().to_str().unwrap(); - let parent = Path::new(v).parent().unwrap().to_str().unwrap(); - let name = Path::new(v).file_name().unwrap().to_str().unwrap(); - save_vault_to_overrides(workspace.path(), parent, name).unwrap(); - - remove_vault_symlinks(workspace.path()).unwrap(); - - assert!(!link_backup.exists()); - let restored_target = fs::read_link(workspace.path().join("CLAUDE.md")).unwrap(); - assert_eq!(restored_target, Path::new("local-claude.md")); - } - - #[serial] - #[test] - fn test_restore_local_link_backup_helper_recreates_symlink() { - let workspace = TempDir::new().unwrap(); - - let local_target = workspace.path().join("local-claude.md"); - fs::write(&local_target, "local symlink target").unwrap(); - - let link_backup = workspace.path().join("CLAUDE.md.local.link"); - fs::write(&link_backup, "local-claude.md").unwrap(); - - let restored = restore_local_link_backup(workspace.path(), "CLAUDE.md").unwrap(); - assert!(restored); - assert!(!link_backup.exists()); - - let restored_target = fs::read_link(workspace.path().join("CLAUDE.md")).unwrap(); - assert_eq!(restored_target, Path::new("local-claude.md")); - } - - #[serial] - #[test] - fn test_restore_local_link_backup_helper_skips_absolute_target() { - let workspace = TempDir::new().unwrap(); - let local_target_dir = TempDir::new().unwrap(); - let local_target = local_target_dir.path().join("local-claude.md"); - fs::write(&local_target, "local symlink target").unwrap(); - - let link_backup = workspace.path().join("CLAUDE.md.local.link"); - fs::write(&link_backup, local_target.to_string_lossy().as_bytes()).unwrap(); - - let restored = restore_local_link_backup(workspace.path(), "CLAUDE.md").unwrap(); - assert!(!restored); - assert!(link_backup.exists()); - assert!(!workspace.path().join("CLAUDE.md").exists()); - } - - #[serial] - #[test] - fn test_restore_local_link_backup_helper_skips_parent_escape_target() { - let workspace = TempDir::new().unwrap(); - let link_backup = workspace.path().join("CLAUDE.md.local.link"); - fs::write(&link_backup, "../local-claude.md").unwrap(); - - let restored = restore_local_link_backup(workspace.path(), "CLAUDE.md").unwrap(); - assert!(!restored); - assert!(link_backup.exists()); - assert!(!workspace.path().join("CLAUDE.md").exists()); - } - - #[cfg(windows)] - #[serial] - #[test] - fn test_restore_local_link_backup_helper_recreates_directory_symlink_on_windows() { - let workspace = TempDir::new().unwrap(); - let local_target_dir = TempDir::new().unwrap(); - - let local_target = local_target_dir.path().join("local-directory"); - fs::create_dir_all(&local_target).unwrap(); - - let link_backup = workspace.path().join("memory.local.link"); - fs::write(&link_backup, local_target.to_string_lossy().as_bytes()).unwrap(); - - let restored = restore_local_link_backup(workspace.path(), "memory").unwrap(); - assert!(restored); - assert!(!link_backup.exists()); - - let restored_target = fs::read_link(workspace.path().join("memory")).unwrap(); - assert_eq!(restored_target, local_target); - } - - #[serial] - #[test] - fn test_update_vault_linked_items_refreshes_workspace_config_cache() { - let workspace = TempDir::new().unwrap(); - let workspace_path = workspace.path().to_string_lossy().to_string(); - - { - let mut cache = WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = None; - } - - save_workspace_config_internal( - &workspace_path, - &WorkspaceConfig { - name: "demo".to_string(), - ..WorkspaceConfig::default() - }, - ) - .unwrap(); - - let initial = load_workspace_config(&workspace_path); - assert!(initial.vault_linked_workspace_items.is_empty()); - - update_vault_linked_items(workspace.path(), &[String::from("memory")]).unwrap(); - - let updated = load_workspace_config(&workspace_path); - assert_eq!(updated.vault_linked_workspace_items, vec!["memory"]); - - { - let mut cache = WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = None; - } - } - - #[serial] - #[test] - fn test_vault_link_rolls_back_workspace_changes_when_overrides_save_fails() { - let workspace = TempDir::new().unwrap(); - let vault_source = TempDir::new().unwrap(); - let workspace_path = workspace.path().to_string_lossy().to_string(); - let window_label = "vault-rollback-test"; - - { - let mut windows = WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - windows.insert(window_label.to_string(), workspace_path.clone()); - } - { - let mut cache = WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = None; - } - - save_workspace_config_internal( - &workspace_path, - &WorkspaceConfig { - name: "demo".to_string(), - ..WorkspaceConfig::default() - }, - ) - .unwrap(); - - fs::write(workspace.path().join("CLAUDE.md"), "local content").unwrap(); - fs::write(vault_source.path().join("CLAUDE.md"), "vault content").unwrap(); - - let ai_dir = workspace.path().join(".ai"); - fs::create_dir_all(&ai_dir).unwrap(); - fs::create_dir(ai_dir.join("local-overrides.json")).unwrap(); - - let err = vault_link_impl( - window_label, - Some(vault_source.path().to_string_lossy().to_string()), - false, - ) - .unwrap_err(); - - assert!(err.contains("local-overrides.json")); - assert_eq!( - fs::read_to_string(workspace.path().join("CLAUDE.md")).unwrap(), - "local content" - ); - assert!(!workspace - .path() - .join("CLAUDE.md") - .symlink_metadata() - .unwrap() - .file_type() - .is_symlink()); - assert!(!workspace.path().join("CLAUDE.md.local").exists()); - assert!(load_workspace_config(&workspace_path) - .vault_linked_workspace_items - .is_empty()); - - { - let mut windows = WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - windows.remove(window_label); - } - { - let mut cache = WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = None; - } - } - - // ---- save/clear overrides ---- - - #[serial] - #[test] - fn test_save_and_clear_overrides() { - let tmp = TempDir::new().unwrap(); - - // Save vault info - save_vault_to_overrides( - tmp.path(), - "/Users/guo/Work/GuoVault/Guo", - "workspaces/worktree-manager", - ) - .unwrap(); - - // Verify it was saved - let overrides_path = tmp.path().join(".ai").join("local-overrides.json"); - assert!(overrides_path.exists()); - - let content = fs::read_to_string(&overrides_path).unwrap(); - let json: serde_json::Value = serde_json::from_str(&content).unwrap(); - assert_eq!( - json["vaultRoot"].as_str().unwrap(), - "/Users/guo/Work/GuoVault/Guo" - ); - assert_eq!( - json["vaultWorkspacePath"].as_str().unwrap(), - "workspaces/worktree-manager" - ); - - // Read it back - let read_result = read_vault_path_from_overrides(tmp.path()); - assert_eq!( - read_result, - Some("/Users/guo/Work/GuoVault/Guo/workspaces/worktree-manager".to_string()) - ); - - // Clear vault info - clear_vault_from_overrides(tmp.path()).unwrap(); - - // Verify fields are gone - let content = fs::read_to_string(&overrides_path).unwrap(); - let json: serde_json::Value = serde_json::from_str(&content).unwrap(); - assert!(json.get("vaultRoot").is_none()); - assert!(json.get("vaultWorkspacePath").is_none()); - - // Read should return None - assert!(read_vault_path_from_overrides(tmp.path()).is_none()); - } - - #[serial] - #[test] - fn test_save_overrides_preserves_other_fields() { - let tmp = TempDir::new().unwrap(); - let ai_dir = tmp.path().join(".ai"); - fs::create_dir_all(&ai_dir).unwrap(); - - // Write existing overrides with other fields - fs::write( - ai_dir.join("local-overrides.json"), - r#"{"someOtherSetting": true, "anotherField": "hello"}"#, - ) - .unwrap(); - - // Save vault info - save_vault_to_overrides(tmp.path(), "/vault/root", "workspaces/test").unwrap(); - - // Verify other fields are preserved - let content = fs::read_to_string(ai_dir.join("local-overrides.json")).unwrap(); - let json: serde_json::Value = serde_json::from_str(&content).unwrap(); - assert_eq!(json["someOtherSetting"].as_bool().unwrap(), true); - assert_eq!(json["anotherField"].as_str().unwrap(), "hello"); - assert_eq!(json["vaultRoot"].as_str().unwrap(), "/vault/root"); - assert_eq!( - json["vaultWorkspacePath"].as_str().unwrap(), - "workspaces/test" - ); - - // Clear vault info - other fields should remain - clear_vault_from_overrides(tmp.path()).unwrap(); - let content = fs::read_to_string(ai_dir.join("local-overrides.json")).unwrap(); - let json: serde_json::Value = serde_json::from_str(&content).unwrap(); - assert_eq!(json["someOtherSetting"].as_bool().unwrap(), true); - assert_eq!(json["anotherField"].as_str().unwrap(), "hello"); - assert!(json.get("vaultRoot").is_none()); - assert!(json.get("vaultWorkspacePath").is_none()); - } - - #[serial] - #[test] - fn test_clear_overrides_missing_file() { - let tmp = TempDir::new().unwrap(); - // Should not error if file doesn't exist - let result = clear_vault_from_overrides(tmp.path()); - assert!(result.is_ok()); - } -} diff --git a/src-tauri/src/commands/voice.rs b/src-tauri/src/commands/voice.rs deleted file mode 100644 index a1d0770..0000000 --- a/src-tauri/src/commands/voice.rs +++ /dev/null @@ -1,1716 +0,0 @@ -use base64::engine::general_purpose::STANDARD as BASE64; -use base64::Engine; -use futures_util::{SinkExt, StreamExt}; -use once_cell::sync::Lazy; -use std::sync::Mutex; -use tokio::sync::{mpsc, watch}; -use tokio_tungstenite::tungstenite::Message; - -use tauri::Emitter; - -use crate::config::{load_global_config, save_global_config_internal}; - -use crate::state::APP_HANDLE; - -// ==================== Voice Session State ==================== - -struct VoiceSession { - audio_tx: mpsc::Sender>, - stop_tx: watch::Sender, -} - -static VOICE_SESSION: Lazy>> = Lazy::new(|| Mutex::new(None)); - -fn emit_event(event: &str, payload: serde_json::Value) { - if let Some(handle) = APP_HANDLE.lock().ok().and_then(|h| h.clone()) { - let _ = handle.emit(event, payload.clone()); - } - // Also broadcast to WebSocket clients - if let Ok(json_str) = serde_json::to_string(&serde_json::json!({ - "event": event, - "payload": payload, - })) { - let _ = crate::state::VOICE_BROADCAST.send(json_str); - } -} - -/// 从 Dashscope 返回的 JSON 中提取事件名称 -/// 客户端发送的指令用 header.action,服务端返回的事件用 header.event -fn get_event_name(json: &serde_json::Value) -> &str { - json["header"]["event"].as_str().unwrap_or("") -} - -// ==================== Dashscope API Key Commands ==================== - -pub(crate) fn get_dashscope_api_key_inner() -> Result, String> { - let config = load_global_config(); - Ok(config.dashscope_api_key) -} - -pub(crate) fn set_dashscope_api_key_inner(key: String) -> Result<(), String> { - let mut config = load_global_config(); - config.dashscope_api_key = if key.is_empty() { None } else { Some(key) }; - save_global_config_internal(&config)?; - Ok(()) -} - -#[tauri::command] -pub(crate) async fn get_dashscope_api_key() -> Result, String> { - get_dashscope_api_key_inner() -} - -#[tauri::command] -pub(crate) async fn set_dashscope_api_key(key: String) -> Result<(), String> { - set_dashscope_api_key_inner(key) -} - -#[tauri::command] -pub(crate) fn check_dashscope_api_key() -> bool { - let config = crate::config::load_global_config(); - config - .dashscope_api_key - .as_ref() - .map(|k| !k.is_empty()) - .unwrap_or(false) -} - -// ==================== Commit AI API Key Commands ==================== - -#[allow(dead_code)] -pub(crate) fn get_commit_ai_api_key_inner() -> Result, String> { - let config = load_global_config(); - Ok(config.commit_ai_api_key) -} - -#[allow(dead_code)] -pub(crate) fn set_commit_ai_api_key_inner(key: String) -> Result<(), String> { - let mut config = load_global_config(); - config.commit_ai_api_key = if key.is_empty() { None } else { Some(key) }; - save_global_config_internal(&config)?; - Ok(()) -} - -#[tauri::command] -#[allow(dead_code)] -pub(crate) async fn get_commit_ai_api_key() -> Result, String> { - get_commit_ai_api_key_inner() -} - -#[tauri::command] -#[allow(dead_code)] -pub(crate) async fn set_commit_ai_api_key(key: String) -> Result<(), String> { - set_commit_ai_api_key_inner(key) -} - -pub(crate) fn set_commit_ai_enabled_inner(enabled: bool) -> Result<(), String> { - let mut config = load_global_config(); - config.commit_ai_enabled = enabled; - save_global_config_internal(&config)?; - Ok(()) -} - -#[tauri::command] -#[allow(dead_code)] -pub(crate) async fn set_commit_ai_enabled(enabled: bool) -> Result<(), String> { - set_commit_ai_enabled_inner(enabled) -} - -#[tauri::command] -#[allow(dead_code)] -pub(crate) async fn get_commit_ai_enabled() -> bool { - let config = crate::config::load_global_config(); - config.commit_ai_enabled -} - -#[tauri::command] -#[allow(dead_code)] -pub(crate) fn check_commit_ai_api_key() -> bool { - let config = crate::config::load_global_config(); - config - .commit_ai_api_key - .as_ref() - .map(|k| !k.is_empty()) - .unwrap_or(false) -} - -// ==================== Dashscope Base URL Commands ==================== - -const DEFAULT_DASHSCOPE_WS_URL: &str = "wss://dashscope.aliyuncs.com/api-ws/v1/inference/"; - -pub(crate) fn get_dashscope_base_url_inner() -> Result, String> { - let config = load_global_config(); - Ok(config.dashscope_base_url) -} - -pub(crate) fn set_dashscope_base_url_inner(url: String) -> Result<(), String> { - let mut config = load_global_config(); - config.dashscope_base_url = if url.is_empty() { None } else { Some(url) }; - save_global_config_internal(&config)?; - Ok(()) -} - -#[tauri::command] -pub(crate) async fn get_dashscope_base_url() -> Result, String> { - get_dashscope_base_url_inner() -} - -#[tauri::command] -pub(crate) async fn set_dashscope_base_url(url: String) -> Result<(), String> { - set_dashscope_base_url_inner(url) -} - -// ==================== Voice Refine Toggle ==================== - -pub(crate) fn get_voice_refine_enabled_inner() -> Result { - let config = load_global_config(); - Ok(config.voice_refine_enabled) -} - -pub(crate) fn set_voice_refine_enabled_inner(enabled: bool) -> Result<(), String> { - let mut config = load_global_config(); - config.voice_refine_enabled = enabled; - save_global_config_internal(&config)?; - Ok(()) -} - -#[tauri::command] -pub(crate) async fn get_voice_refine_enabled() -> Result { - get_voice_refine_enabled_inner() -} - -#[tauri::command] -pub(crate) async fn set_voice_refine_enabled(enabled: bool) -> Result<(), String> { - set_voice_refine_enabled_inner(enabled) -} - -// ==================== Voice Refine Base URL ==================== - -const DEFAULT_REFINE_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1"; - -pub(crate) fn get_voice_refine_base_url_inner() -> Result, String> { - let config = load_global_config(); - Ok(config.voice_refine_base_url) -} - -pub(crate) fn set_voice_refine_base_url_inner(url: String) -> Result<(), String> { - let mut config = load_global_config(); - config.voice_refine_base_url = if url.is_empty() { None } else { Some(url) }; - save_global_config_internal(&config)?; - Ok(()) -} - -#[tauri::command] -pub(crate) async fn get_voice_refine_base_url() -> Result, String> { - get_voice_refine_base_url_inner() -} - -#[tauri::command] -pub(crate) async fn set_voice_refine_base_url(url: String) -> Result<(), String> { - set_voice_refine_base_url_inner(url) -} - -// ==================== Voice Model Config ==================== - -pub(crate) fn get_voice_asr_model_inner() -> Result, String> { - let config = load_global_config(); - Ok(config.voice_asr_model) -} - -pub(crate) fn set_voice_asr_model_inner(model: String) -> Result<(), String> { - let mut config = load_global_config(); - config.voice_asr_model = if model.is_empty() { None } else { Some(model) }; - save_global_config_internal(&config)?; - Ok(()) -} - -#[tauri::command] -pub(crate) async fn get_voice_asr_model() -> Result, String> { - get_voice_asr_model_inner() -} - -#[tauri::command] -pub(crate) async fn set_voice_asr_model(model: String) -> Result<(), String> { - set_voice_asr_model_inner(model) -} - -pub(crate) fn get_voice_refine_model_inner() -> Result, String> { - let config = load_global_config(); - Ok(config.voice_refine_model) -} - -pub(crate) fn set_voice_refine_model_inner(model: String) -> Result<(), String> { - let mut config = load_global_config(); - config.voice_refine_model = if model.is_empty() { None } else { Some(model) }; - save_global_config_internal(&config)?; - Ok(()) -} - -#[tauri::command] -pub(crate) async fn get_voice_refine_model() -> Result, String> { - get_voice_refine_model_inner() -} - -#[tauri::command] -pub(crate) async fn set_voice_refine_model(model: String) -> Result<(), String> { - set_voice_refine_model_inner(model) -} - -// ==================== Dashscope Models List ==================== - -pub(crate) async fn list_dashscope_models_inner() -> Result, String> { - let config = load_global_config(); - let api_key = config.dashscope_api_key.ok_or("未配置 Dashscope API Key")?; - let base_url = config - .voice_refine_base_url - .unwrap_or_else(|| DEFAULT_REFINE_BASE_URL.to_string()); - let url = format!("{}/models", base_url.trim_end_matches('/')); - - let client = reqwest::Client::new(); - let resp = client - .get(&url) - .header("Authorization", format!("Bearer {}", api_key)) - .send() - .await - .map_err(|e| format!("Failed to fetch models: {}", e))?; - - if !resp.status().is_success() { - let msg = resp.text().await.unwrap_or_default(); - return Err(format!("Models API error: {}", msg)); - } - - let body: serde_json::Value = resp - .json() - .await - .map_err(|e| format!("Parse models response error: {}", e))?; - - let mut models: Vec = body["data"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|m| m["id"].as_str().map(|s| s.to_string())) - .collect(); - models.sort(); - Ok(models) -} - -#[tauri::command] -pub(crate) async fn list_dashscope_models() -> Result, String> { - list_dashscope_models_inner().await -} - -// ==================== Voice Session Commands ==================== - -pub(crate) async fn voice_start_inner(sample_rate: Option) -> Result<(), String> { - // Check if already active - { - let session = VOICE_SESSION.lock().map_err(|e| e.to_string())?; - if session.is_some() { - return Err("语音会话已在进行中".to_string()); - } - } - - let config = load_global_config(); - let api_key = config - .dashscope_api_key - .filter(|k| !k.is_empty()) - .ok_or_else(|| "请先在设置中配置 Dashscope API Key".to_string())?; - - let actual_sample_rate = sample_rate.unwrap_or(16000); - - // Build WebSocket request with auth header - let ws_url = config - .dashscope_base_url - .filter(|u| !u.is_empty()) - .unwrap_or_else(|| DEFAULT_DASHSCOPE_WS_URL.to_string()); - // Extract host from URL for the Host header - let ws_host = ws_url - .replace("wss://", "") - .replace("ws://", "") - .split('/') - .next() - .unwrap_or("dashscope.aliyuncs.com") - .to_string(); - - let request = tokio_tungstenite::tungstenite::http::Request::builder() - .uri(&ws_url) - .header("Authorization", format!("Bearer {}", api_key)) - .header("Host", &ws_host) - .header("Connection", "Upgrade") - .header("Upgrade", "websocket") - .header("Sec-WebSocket-Version", "13") - .header( - "Sec-WebSocket-Key", - tokio_tungstenite::tungstenite::handshake::client::generate_key(), - ) - .body(()) - .map_err(|e| format!("构建 WebSocket 请求失败: {}", e))?; - - let (ws_stream, _) = tokio_tungstenite::connect_async(request) - .await - .map_err(|e| format!("WebSocket 连接失败: {}", e))?; - - let (mut ws_write, mut ws_read) = ws_stream.split(); - - // Generate a unique task ID - let task_id = uuid::Uuid::new_v4().to_string(); - - // Send run-task message (客户端指令用 header.action) - let run_task = serde_json::json!({ - "header": { - "action": "run-task", - "task_id": task_id, - "streaming": "duplex" - }, - "payload": { - "task_group": "audio", - "task": "asr", - "function": "recognition", - "model": config.voice_asr_model.as_deref().unwrap_or("paraformer-realtime-v2"), - "parameters": { - "format": "pcm", - "sample_rate": actual_sample_rate, - "disfluency_removal_enabled": true - }, - "input": {} - } - }); - - ws_write - .send(Message::Text(run_task.to_string().into())) - .await - .map_err(|e| format!("发送 run-task 失败: {}", e))?; - - // Wait for task-started event (服务端事件用 header.event) - tokio::time::timeout(std::time::Duration::from_secs(10), async { - while let Some(msg) = ws_read.next().await { - match msg { - Ok(Message::Text(text)) => { - if let Ok(json) = serde_json::from_str::(&text) { - let event = get_event_name(&json); - if event == "task-started" { - return Ok(()); - } - if event == "task-failed" { - let err_msg = json["header"]["error_message"] - .as_str() - .unwrap_or("unknown error"); - return Err(format!("Dashscope 任务启动失败: {}", err_msg)); - } - } - } - Err(e) => return Err(format!("WebSocket 读取错误: {}", e)), - _ => {} - } - } - Err("WebSocket 连接意外关闭".to_string()) - }) - .await - .map_err(|_| "等待 Dashscope 响应超时".to_string())??; - - // Create channels - let (audio_tx, audio_rx) = mpsc::channel::>(64); - let (stop_tx, stop_rx) = watch::channel(false); - - // Store session - { - let mut session = VOICE_SESSION.lock().map_err(|e| e.to_string())?; - *session = Some(VoiceSession { audio_tx, stop_tx }); - } - - // Spawn background task that owns ws_write, ws_read, and the channel receivers - tokio::spawn(voice_session_task( - ws_write, ws_read, audio_rx, stop_rx, task_id, - )); - - Ok(()) -} - -#[tauri::command] -pub(crate) async fn voice_start(sample_rate: Option) -> Result<(), String> { - voice_start_inner(sample_rate).await -} - -/// Background task handling bidirectional WebSocket communication with Dashscope -async fn voice_session_task( - mut ws_write: futures_util::stream::SplitSink< - tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, - Message, - >, - mut ws_read: futures_util::stream::SplitStream< - tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, - >, - mut audio_rx: mpsc::Receiver>, - mut stop_rx: watch::Receiver, - task_id: String, -) { - emit_event("voice-started", serde_json::json!({})); - let mut last_final = String::new(); - - loop { - tokio::select! { - // Forward audio data from frontend to Dashscope - audio = audio_rx.recv() => { - match audio { - Some(pcm_data) => { - if let Err(e) = ws_write.send(Message::Binary(pcm_data.into())).await { - log::error!("[voice] Failed to send audio: {}", e); - emit_event("voice-error", serde_json::json!({ "message": format!("发送音频数据失败: {}", e) })); - break; - } - } - None => break, // Channel closed - } - } - // Receive recognition results from Dashscope - msg = ws_read.next() => { - match msg { - Some(Ok(Message::Text(text))) => { - handle_dashscope_message(&text, &mut last_final); - if let Ok(json) = serde_json::from_str::(&text) { - let event = get_event_name(&json); - if event == "task-finished" || event == "task-failed" { - break; - } - } - } - Some(Ok(Message::Close(_))) | None => break, - Some(Err(e)) => { - log::error!("[voice] WebSocket read error: {}", e); - emit_event("voice-error", serde_json::json!({ "message": format!("WebSocket 错误: {}", e) })); - break; - } - _ => {} - } - } - // Stop signal from voice_stop command - _ = stop_rx.changed() => { - if *stop_rx.borrow() { - // Send finish-task to Dashscope (客户端指令用 header.action) - let finish = serde_json::json!({ - "header": { - "action": "finish-task", - "task_id": task_id, - "streaming": "duplex" - }, - "payload": { - "input": {} - } - }); - let _ = ws_write.send(Message::Text(finish.to_string().into())).await; - - // Drain remaining results with a timeout (dedup via last_final) - drain_final_results(&mut ws_read, &mut last_final).await; - break; - } - } - } - } - - // Cleanup - let _ = ws_write.close().await; - { - if let Ok(mut session) = VOICE_SESSION.lock() { - *session = None; - } - } - emit_event("voice-stopped", serde_json::json!({})); -} - -/// Process a single Dashscope event message. -/// Returns the sentence text if a final (sentence_end) result was emitted, for dedup tracking. -fn handle_dashscope_message(text: &str, last_final: &mut String) { - let Ok(json) = serde_json::from_str::(text) else { - return; - }; - let event = get_event_name(&json); - - match event { - "result-generated" => { - // sentence 结构: { text, sentence_end, begin_time, end_time, ... } - if let Some(sentence) = json["payload"]["output"]["sentence"].as_object() { - let text = sentence.get("text").and_then(|v| v.as_str()).unwrap_or(""); - let is_sentence_end = sentence - .get("sentence_end") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if !text.is_empty() { - // Skip duplicate final results (Dashscope re-sends on finish-task) - if is_sentence_end && text == last_final.as_str() { - return; - } - if is_sentence_end { - *last_final = text.to_string(); - } - emit_event( - "voice-result", - serde_json::json!({ - "text": text, - "is_final": is_sentence_end - }), - ); - } - } - } - "task-failed" => { - let err_msg = json["header"]["error_message"] - .as_str() - .unwrap_or("unknown error"); - emit_event("voice-error", serde_json::json!({ "message": err_msg })); - } - _ => {} - } -} - -/// Wait up to 3 seconds for Dashscope to send task-finished after finish-task. -/// Emits new results but skips duplicates via `last_final` tracking. -async fn drain_final_results( - ws_read: &mut futures_util::stream::SplitStream< - tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, - >, - last_final: &mut String, -) { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(3); - loop { - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); - if remaining.is_zero() { - break; - } - match tokio::time::timeout(remaining, ws_read.next()).await { - Ok(Some(Ok(Message::Text(text)))) => { - handle_dashscope_message(&text, last_final); - if let Ok(json) = serde_json::from_str::(&text) { - let event = get_event_name(&json); - if event == "task-finished" || event == "task-failed" { - break; - } - } - } - _ => break, - } - } -} - -pub(crate) fn voice_send_audio_inner(data: String) -> Result<(), String> { - let pcm_bytes = BASE64 - .decode(&data) - .map_err(|e| format!("Base64 解码失败: {}", e))?; - - let session = VOICE_SESSION.lock().map_err(|e| e.to_string())?; - if let Some(ref s) = *session { - s.audio_tx - .try_send(pcm_bytes) - .map_err(|e| format!("发送音频数据失败: {}", e))?; - Ok(()) - } else { - Err("没有活跃的语音会话".to_string()) - } -} - -pub(crate) fn voice_stop_inner() -> Result<(), String> { - let session = VOICE_SESSION.lock().map_err(|e| e.to_string())?; - if let Some(ref s) = *session { - let _ = s.stop_tx.send(true); - Ok(()) - } else { - Ok(()) // Already stopped - } -} - -pub(crate) fn voice_is_active_inner() -> Result { - let session = VOICE_SESSION.lock().map_err(|e| e.to_string())?; - Ok(session.is_some()) -} - -#[tauri::command] -pub(crate) async fn voice_send_audio(data: String) -> Result<(), String> { - voice_send_audio_inner(data) -} - -#[tauri::command] -pub(crate) async fn voice_stop() -> Result<(), String> { - voice_stop_inner() -} - -#[tauri::command] -pub(crate) async fn voice_is_active() -> Result { - voice_is_active_inner() -} - -// ==================== Unified AI Chat Helper ==================== - -pub(crate) async fn call_ai_chat( - messages: Vec, - model: Option<&str>, - temperature: f64, - purpose: &str, -) -> Result { - let messages_value = serde_json::Value::Array(messages); - - // Try cloud first - if crate::cloud_client::is_cloud_configured() { - match crate::cloud_client::cloud_ai_chat( - &messages_value, - model, - false, - purpose, - Some(temperature), - ) - .await - { - Ok(resp_text) => { - let resp: serde_json::Value = serde_json::from_str(&resp_text) - .map_err(|e| format!("parse cloud response error: {}", e))?; - let content = resp["choices"][0]["message"]["content"] - .as_str() - .unwrap_or("") - .to_string(); - return Ok(content); - } - Err(e) if e.is_auth_failed() => { - return Err(format!("云端认证已过期,请重新配对: {}", e)); - } - Err(e) if e.is_network_error() => { - log::warn!("Cloud AI failed (network), falling back to local: {}", e); - } - Err(e) => { - return Err(format!("云端 AI 请求失败: {}", e)); - } - } - } - - // Fallback: local Dashscope - let config = crate::config::load_global_config(); - // Use commit_ai_api_key for commit_ai purpose, dashscope_api_key for others - let (api_key, base_url) = if purpose == "commit_ai" { - let key = config - .commit_ai_api_key - .ok_or("未配置 Commit AI API Key,请在设置中配置")?; - let url = config - .dashscope_base_url - .unwrap_or_else(|| DEFAULT_REFINE_BASE_URL.to_string()); - (key, url) - } else { - let key = config - .dashscope_api_key - .ok_or("未配置 AI 能力(无云端连接且无本地 API Key)")?; - let url = config - .voice_refine_base_url - .unwrap_or_else(|| DEFAULT_REFINE_BASE_URL.to_string()); - (key, url) - }; - - let url = format!("{}/chat/completions", base_url.trim_end_matches('/')); - let body = serde_json::json!({ - "model": model.unwrap_or("qwen-turbo-latest"), - "messages": messages_value, - "temperature": temperature, - }); - - let client = reqwest::Client::new(); - let resp = client - .post(&url) - .header("Authorization", format!("Bearer {}", api_key)) - .header("Content-Type", "application/json") - .json(&body) - .send() - .await - .map_err(|e| format!("AI request failed: {}", e))?; - - if !resp.status().is_success() { - let msg = resp.text().await.unwrap_or_default(); - return Err(format!("AI API error: {}", msg)); - } - - let resp_json: serde_json::Value = resp - .json() - .await - .map_err(|e| format!("parse response error: {}", e))?; - Ok(resp_json["choices"][0]["message"]["content"] - .as_str() - .unwrap_or("") - .to_string()) -} - -// ==================== AI Text Refinement (Qwen LLM) ==================== - -const REFINE_SYSTEM_PROMPT: &str = r#"你是一个语音转文字的排版工具(类似 Typeless)。用户在 中给你语音识别原文,你负责清理和排版,然后原样输出。 - -## 清理规则 -- 去除语气词(嗯、呃、那个、就是、然后、对、啊)和口语填充词 -- 去除重复表达和多余停顿 -- 修正明显的语音识别错误(同音字纠错) -- 补充合理的标点符号 - -## 排版规则 -- 当用户说出"第一/第二/第三"或"首先/其次/最后"等序号词时,格式化为编号列表 -- 当内容包含明显的多个要点时,用换行分隔 -- 终端命令(git, npm, cd 等)保留原始格式,用 backtick 包裹 - -## 严格禁止 -- 严禁回答问题、计算结果、补充信息、给出建议 -- 严禁改变语义——疑问句保持疑问句,陈述句保持陈述句 -- 严禁添加解释、引号、前缀、XML 标签或任何额外内容 -- 你不是 AI 助手,不要尝试理解或执行用户的意图,只做排版 - -## 示例 -输入: 嗯那个1加1等于几呢 -输出: 1加1等于几? - -输入: 呃就是说git push到那个origin main上面 -输出: `git push origin main` - -输入: 我觉得有三个问题啊第一就是性能不太好第二是那个界面有点丑然后第三个就是文档太少了 -输出: 我觉得有三个问题: -1. 性能不太好 -2. 界面有点丑 -3. 文档太少了 - -输入: 帮我把那个删除按钮的颜色改成红色然后把确认弹窗的文案改成你确定要删除吗 -输出: 把删除按钮的颜色改成红色,把确认弹窗的文案改成"你确定要删除吗?""#; - -pub(crate) async fn voice_refine_text_inner(text: String) -> Result { - let trimmed = text.trim(); - if trimmed.is_empty() { - return Ok(String::new()); - } - - let user_content = format!("{}", trimmed); - let messages = vec![ - serde_json::json!({"role": "system", "content": REFINE_SYSTEM_PROMPT}), - serde_json::json!({"role": "user", "content": user_content}), - ]; - - let config = crate::config::load_global_config(); - let refine_model = config - .voice_refine_model - .as_deref() - .unwrap_or("qwen3.7-max"); - let result = call_ai_chat(messages, Some(refine_model), 0.0, "voice_refine").await?; - Ok(if result.is_empty() { - trimmed.to_string() - } else { - result.trim().to_string() - }) -} - -#[tauri::command] -pub(crate) async fn voice_refine_text(text: String) -> Result { - voice_refine_text_inner(text).await -} - -// ==================== AI Commit Message Generation ==================== - -const COMMIT_MSG_SYSTEM_PROMPT: &str = "\ -你是一个 Git commit message 生成器。用户会给你 git diff 信息,你需要生成一个简洁的 commit message。\n\ -\n\ -规则:\n\ -- 使用 Conventional Commits 格式:type(scope): description\n\ -- type 可以是:feat, fix, refactor, style, docs, chore, perf, test\n\ -- scope 是可选的,表示影响的模块\n\ -- description 用中文,简洁描述变更内容\n\ -- 只输出一行 commit message,不要加任何解释或额外内容\n\ -- 如果变更涉及多个方面,选择最主要的变更来写\n\ -\n\ -示例:\n\ -feat(ui): 添加分支切换按钮\n\ -fix(git): 修复推送失败时的错误处理\n\ -refactor(backend): 重构工作区状态获取逻辑"; - -#[tauri::command] -pub(crate) async fn generate_commit_message(diff: String) -> Result { - let trimmed = diff.trim(); - if trimmed.is_empty() { - return Err("No diff provided".to_string()); - } - - let messages = vec![ - serde_json::json!({"role": "system", "content": COMMIT_MSG_SYSTEM_PROMPT}), - serde_json::json!({"role": "user", "content": trimmed}), - ]; - - let result = call_ai_chat(messages, None, 0.3, "commit_ai").await?; - Ok(if result.is_empty() { - "chore: update".to_string() - } else { - result.trim().to_string() - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::{ - extract::State, - http::{HeaderMap, StatusCode}, - routing::{get, post}, - Json, Router, - }; - use futures_util::{SinkExt, StreamExt}; - use once_cell::sync::Lazy; - use serde_json::{json, Value}; - use serial_test::serial; - use std::path::PathBuf; - use std::sync::{Arc, Mutex, MutexGuard}; - use std::time::Duration; - use tokio::net::TcpListener; - use tokio_tungstenite::tungstenite::handshake::server::{Request, Response}; - - static VOICE_EVENT_TEST_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); - - fn lock_voice_event_tests() -> MutexGuard<'static, ()> { - VOICE_EVENT_TEST_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - } - - struct VoiceSessionGuard { - previous: Option, - } - - impl VoiceSessionGuard { - fn isolated() -> Self { - let previous = { - let mut session = VOICE_SESSION - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - session.take() - }; - Self { previous } - } - } - - impl Drop for VoiceSessionGuard { - fn drop(&mut self) { - let mut session = VOICE_SESSION - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *session = self.previous.take(); - } - } - - struct ConfigCacheGuard { - previous: Option, - _lock: FileLockGuard, - } - - impl ConfigCacheGuard { - fn with_global_config(config: crate::types::GlobalConfig) -> Self { - let lock = FileLockGuard::acquire(); - let previous = { - let mut cache = crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::replace(&mut *cache, Some(config)) - }; - Self { - previous, - _lock: lock, - } - } - - fn with_dashscope(base_url: String, api_key: &str) -> Self { - let mut config = crate::types::GlobalConfig::default(); - config.dashscope_api_key = Some(api_key.to_string()); - config.voice_refine_base_url = Some(base_url); - Self::with_global_config(config) - } - } - - impl Drop for ConfigCacheGuard { - fn drop(&mut self) { - let mut cache = crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = self.previous.take(); - } - } - - struct FileLockGuard { - path: PathBuf, - } - - impl FileLockGuard { - fn acquire() -> Self { - let path = std::env::temp_dir().join("worktree-manager-global-config-cache.lock"); - for _ in 0..500 { - match std::fs::create_dir(&path) { - Ok(()) => return Self { path }, - Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { - std::thread::sleep(std::time::Duration::from_millis(2)); - } - Err(err) => panic!("failed to create test lock {:?}: {}", path, err), - } - } - panic!("timed out waiting for test lock {:?}", path); - } - } - - impl Drop for FileLockGuard { - fn drop(&mut self) { - let _ = std::fs::remove_dir(&self.path); - } - } - - struct TempHomeGuard { - previous_home: Option, - previous_cache: Option, - _lock: FileLockGuard, - _temp_dir: tempfile::TempDir, - } - - impl TempHomeGuard { - fn new() -> Self { - let lock = FileLockGuard::acquire(); - let temp_dir = tempfile::tempdir().expect("create temp home"); - let previous_home = std::env::var_os("HOME"); - let previous_cache = { - let mut cache = crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *cache) - }; - std::env::set_var("HOME", temp_dir.path()); - Self { - previous_home, - previous_cache, - _lock: lock, - _temp_dir: temp_dir, - } - } - } - - impl Drop for TempHomeGuard { - fn drop(&mut self) { - match &self.previous_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } - let mut cache = crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = self.previous_cache.take(); - } - } - - #[derive(Clone, Debug, Default)] - struct HttpCapture { - authorization: Option, - content_type: Option, - body: Option, - } - - fn header_value(headers: &HeaderMap, name: &str) -> Option { - headers - .get(name) - .and_then(|value| value.to_str().ok()) - .map(str::to_string) - } - - async fn spawn_models_server( - status: StatusCode, - response: Value, - captures: Arc>>, - ) -> Result { - let app = Router::new() - .route( - "/models", - get( - move |headers: HeaderMap, - State(captures): State>>>| { - let response = response.clone(); - async move { - captures - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .push(HttpCapture { - authorization: header_value(&headers, "authorization"), - content_type: header_value(&headers, "content-type"), - body: None, - }); - (status, Json(response)) - } - }, - ), - ) - .with_state(captures); - let listener = match TcpListener::bind("127.0.0.1:0").await { - Ok(listener) => listener, - Err(err) => return Err(format!("local bind unavailable: {}", err)), - }; - let addr = listener.local_addr().expect("models addr"); - tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - Ok(format!("http://{}", addr)) - } - - async fn spawn_chat_server( - status: StatusCode, - response: Value, - captures: Arc>>, - ) -> Result { - let app = Router::new() - .route( - "/chat/completions", - post( - move |headers: HeaderMap, - State(captures): State>>>, - Json(body): Json| { - let response = response.clone(); - async move { - captures - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .push(HttpCapture { - authorization: header_value(&headers, "authorization"), - content_type: header_value(&headers, "content-type"), - body: Some(body), - }); - (status, Json(response)) - } - }, - ), - ) - .with_state(captures); - let listener = match TcpListener::bind("127.0.0.1:0").await { - Ok(listener) => listener, - Err(err) => return Err(format!("local bind unavailable: {}", err)), - }; - let addr = listener.local_addr().expect("chat addr"); - tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - Ok(format!("http://{}", addr)) - } - - #[derive(Debug, Default)] - struct WsCapture { - authorization: Option, - host: Option, - text_messages: Vec, - binary_messages: Vec>, - } - - async fn spawn_dashscope_ws_server(capture: Arc>) -> Result { - let listener = match TcpListener::bind("127.0.0.1:0").await { - Ok(listener) => listener, - Err(err) => return Err(format!("local bind unavailable: {}", err)), - }; - let addr = listener.local_addr().expect("ws addr"); - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept ws"); - let capture_for_headers = capture.clone(); - let mut ws = tokio_tungstenite::accept_hdr_async( - stream, - move |request: &Request, response: Response| { - let mut capture = capture_for_headers - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - capture.authorization = request - .headers() - .get("authorization") - .and_then(|value| value.to_str().ok()) - .map(str::to_string); - capture.host = request - .headers() - .get("host") - .and_then(|value| value.to_str().ok()) - .map(str::to_string); - Ok(response) - }, - ) - .await - .expect("handshake ws"); - - if let Some(Ok(Message::Text(text))) = ws.next().await { - capture - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .text_messages - .push(serde_json::from_str(&text).expect("run-task json")); - ws.send(Message::Text( - json!({ "header": { "event": "task-started" } }) - .to_string() - .into(), - )) - .await - .expect("send task-started"); - } - - while let Some(message) = ws.next().await { - match message.expect("ws message") { - Message::Binary(data) => { - capture - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .binary_messages - .push(data.to_vec()); - } - Message::Text(text) => { - let value: Value = serde_json::from_str(&text).expect("finish-task json"); - let action = value["header"]["action"].as_str().map(str::to_string); - capture - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .text_messages - .push(value); - if action.as_deref() == Some("finish-task") { - ws.send(Message::Text( - json!({ - "header": { "event": "result-generated" }, - "payload": { - "output": { - "sentence": { - "text": "final text", - "sentence_end": true - } - } - } - }) - .to_string() - .into(), - )) - .await - .expect("send result"); - ws.send(Message::Text( - json!({ "header": { "event": "task-finished" } }) - .to_string() - .into(), - )) - .await - .expect("send finished"); - break; - } - } - Message::Close(_) => break, - _ => {} - } - } - }); - Ok(format!("ws://{}/voice", addr)) - } - - #[serial] - #[test] - fn get_event_name_reads_server_event_not_client_action() { - assert_eq!( - get_event_name(&json!({ "header": { "event": "task-started" } })), - "task-started" - ); - assert_eq!( - get_event_name(&json!({ "header": { "action": "run-task" } })), - "" - ); - assert_eq!(get_event_name(&json!({ "payload": {} })), ""); - } - - #[serial] - #[test] - fn voice_send_audio_validates_base64_before_session_lookup() { - let _session = VoiceSessionGuard::isolated(); - - let err = voice_send_audio_inner("not valid base64".to_string()).unwrap_err(); - - assert!(err.starts_with("Base64 解码失败:")); - } - - #[serial] - #[test] - fn voice_session_state_reports_inactive_and_stop_is_idempotent() { - let _session = VoiceSessionGuard::isolated(); - - assert!(!voice_is_active_inner().unwrap()); - assert_eq!(voice_stop_inner(), Ok(())); - assert_eq!( - voice_send_audio_inner(BASE64.encode([1u8, 2, 3])).unwrap_err(), - "没有活跃的语音会话" - ); - } - - #[serial] - #[test] - fn voice_session_state_sends_audio_and_stop_signal_when_active() { - let _session = VoiceSessionGuard::isolated(); - let (audio_tx, mut audio_rx) = mpsc::channel::>(1); - let (stop_tx, stop_rx) = watch::channel(false); - { - let mut session = VOICE_SESSION - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *session = Some(VoiceSession { audio_tx, stop_tx }); - } - - assert!(voice_is_active_inner().unwrap()); - voice_send_audio_inner(BASE64.encode([4u8, 5, 6])).unwrap(); - voice_stop_inner().unwrap(); - { - let mut session = VOICE_SESSION - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *session = None; - } - - assert_eq!(audio_rx.try_recv().unwrap(), vec![4u8, 5, 6]); - assert!(*stop_rx.borrow()); - } - - #[serial] - #[tokio::test] - async fn async_voice_command_wrappers_delegate_to_inner_functions() { - let _home = TempHomeGuard::new(); - let _session = VoiceSessionGuard::isolated(); - - set_dashscope_api_key("dash-wrapper".to_string()) - .await - .unwrap(); - set_dashscope_base_url("wss://wrapper.example/ws".to_string()) - .await - .unwrap(); - set_voice_refine_enabled(false).await.unwrap(); - set_voice_refine_base_url("https://wrapper.example/v1".to_string()) - .await - .unwrap(); - set_voice_asr_model("asr-wrapper".to_string()) - .await - .unwrap(); - set_voice_refine_model("refine-wrapper".to_string()) - .await - .unwrap(); - set_commit_ai_api_key("commit-wrapper".to_string()) - .await - .unwrap(); - set_commit_ai_enabled(false).await.unwrap(); - - assert_eq!( - get_dashscope_api_key().await.unwrap(), - Some("dash-wrapper".to_string()) - ); - assert_eq!( - get_dashscope_base_url().await.unwrap(), - Some("wss://wrapper.example/ws".to_string()) - ); - assert!(!get_voice_refine_enabled().await.unwrap()); - assert_eq!( - get_voice_refine_base_url().await.unwrap(), - Some("https://wrapper.example/v1".to_string()) - ); - assert_eq!( - get_voice_asr_model().await.unwrap(), - Some("asr-wrapper".to_string()) - ); - assert_eq!( - get_voice_refine_model().await.unwrap(), - Some("refine-wrapper".to_string()) - ); - assert_eq!( - get_commit_ai_api_key().await.unwrap(), - Some("commit-wrapper".to_string()) - ); - assert!(!get_commit_ai_enabled().await); - assert!(voice_is_active().await.unwrap() == false); - assert!(voice_send_audio("not base64".to_string()) - .await - .unwrap_err() - .starts_with("Base64 解码失败:")); - assert_eq!(voice_stop().await, Ok(())); - assert_eq!(voice_refine_text(" ".to_string()).await.unwrap(), ""); - } - - #[serial] - #[tokio::test] - async fn voice_start_reports_missing_key_and_invalid_websocket_url_before_session_creation() { - let _session = VoiceSessionGuard::isolated(); - let _missing_config = - ConfigCacheGuard::with_global_config(crate::types::GlobalConfig::default()); - - assert_eq!( - voice_start(None).await.unwrap_err(), - "请先在设置中配置 Dashscope API Key" - ); - drop(_missing_config); - - let mut config = crate::types::GlobalConfig::default(); - config.dashscope_api_key = Some("dash-key".to_string()); - config.dashscope_base_url = Some("http:// bad url".to_string()); - let _invalid_url = ConfigCacheGuard::with_global_config(config); - let err = voice_start_inner(Some(44100)).await.unwrap_err(); - - assert!(err.starts_with("构建 WebSocket 请求失败:")); - assert!(!voice_is_active_inner().unwrap()); - } - - #[serial] - #[tokio::test] - async fn handle_dashscope_message_emits_result_and_skips_duplicate_final() { - let _event_lock = lock_voice_event_tests(); - let mut rx = crate::state::VOICE_BROADCAST.subscribe(); - let mut last_final = String::new(); - let message = json!({ - "header": { "event": "result-generated" }, - "payload": { - "output": { - "sentence": { - "text": "hello world", - "sentence_end": true - } - } - } - }) - .to_string(); - - handle_dashscope_message(&message, &mut last_final); - - let emitted = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) - .await - .unwrap() - .unwrap(); - let event: Value = serde_json::from_str(&emitted).unwrap(); - assert_eq!(event["event"], "voice-result"); - assert_eq!(event["payload"]["text"], "hello world"); - assert_eq!(event["payload"]["is_final"], true); - assert_eq!(last_final, "hello world"); - - handle_dashscope_message(&message, &mut last_final); - let duplicate = tokio::time::timeout(std::time::Duration::from_millis(50), rx.recv()).await; - assert!(duplicate.is_err()); - } - - #[serial] - #[tokio::test] - async fn handle_dashscope_message_emits_task_failed_error() { - let _event_lock = lock_voice_event_tests(); - let mut rx = crate::state::VOICE_BROADCAST.subscribe(); - let mut last_final = String::new(); - - handle_dashscope_message( - &json!({ - "header": { - "event": "task-failed", - "error_message": "bad credentials" - } - }) - .to_string(), - &mut last_final, - ); - - let emitted = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) - .await - .unwrap() - .unwrap(); - let event: Value = serde_json::from_str(&emitted).unwrap(); - assert_eq!(event["event"], "voice-error"); - assert_eq!(event["payload"]["message"], "bad credentials"); - assert_eq!(last_final, ""); - } - - #[serial] - #[tokio::test] - async fn call_ai_chat_reports_missing_local_key_before_request() { - let _config = ConfigCacheGuard::with_global_config(crate::types::GlobalConfig::default()); - let messages = vec![json!({ "role": "user", "content": "hello" })]; - - let err = call_ai_chat(messages, Some("qwen-custom"), 0.4, "voice_refine") - .await - .unwrap_err(); - - assert_eq!(err, "未配置 AI 能力(无云端连接且无本地 API Key)"); - } - - #[serial] - #[tokio::test] - async fn call_ai_chat_maps_invalid_base_url_before_network() { - let _config = ConfigCacheGuard::with_dashscope("://bad-url".to_string(), "dash-key"); - let messages = vec![json!({ "role": "user", "content": "hello" })]; - - let err = call_ai_chat(messages, Some("qwen-custom"), 0.4, "voice_refine") - .await - .unwrap_err(); - - assert!(err.starts_with("AI request failed:")); - assert!(err.contains("builder error")); - } - - #[serial] - #[test] - fn config_setters_persist_values_and_empty_strings_as_none() { - let _home = TempHomeGuard::new(); - - set_dashscope_api_key_inner("dash-key".to_string()).unwrap(); - set_dashscope_base_url_inner("wss://example.test/ws".to_string()).unwrap(); - set_voice_refine_enabled_inner(false).unwrap(); - set_voice_refine_base_url_inner("https://refine.example/v1/".to_string()).unwrap(); - set_voice_asr_model_inner("asr-custom".to_string()).unwrap(); - set_voice_refine_model_inner("refine-custom".to_string()).unwrap(); - set_commit_ai_api_key_inner("commit-key".to_string()).unwrap(); - set_commit_ai_enabled_inner(false).unwrap(); - - assert_eq!( - get_dashscope_api_key_inner().unwrap(), - Some("dash-key".to_string()) - ); - assert!(check_dashscope_api_key()); - assert_eq!( - get_dashscope_base_url_inner().unwrap(), - Some("wss://example.test/ws".to_string()) - ); - assert!(!get_voice_refine_enabled_inner().unwrap()); - assert_eq!( - get_voice_refine_base_url_inner().unwrap(), - Some("https://refine.example/v1/".to_string()) - ); - assert_eq!( - get_voice_asr_model_inner().unwrap(), - Some("asr-custom".to_string()) - ); - assert_eq!( - get_voice_refine_model_inner().unwrap(), - Some("refine-custom".to_string()) - ); - assert_eq!( - get_commit_ai_api_key_inner().unwrap(), - Some("commit-key".to_string()) - ); - assert!(check_commit_ai_api_key()); - assert!(!crate::config::load_global_config().commit_ai_enabled); - - set_dashscope_api_key_inner(String::new()).unwrap(); - set_dashscope_base_url_inner(String::new()).unwrap(); - set_voice_refine_base_url_inner(String::new()).unwrap(); - set_voice_asr_model_inner(String::new()).unwrap(); - set_voice_refine_model_inner(String::new()).unwrap(); - set_commit_ai_api_key_inner(String::new()).unwrap(); - - assert_eq!(get_dashscope_api_key_inner().unwrap(), None); - assert!(!check_dashscope_api_key()); - assert_eq!(get_dashscope_base_url_inner().unwrap(), None); - assert_eq!(get_voice_refine_base_url_inner().unwrap(), None); - assert_eq!(get_voice_asr_model_inner().unwrap(), None); - assert_eq!(get_voice_refine_model_inner().unwrap(), None); - assert_eq!(get_commit_ai_api_key_inner().unwrap(), None); - assert!(!check_commit_ai_api_key()); - } - - #[serial] - #[tokio::test] - async fn list_dashscope_models_sends_bearer_header_and_sorts_ids() { - let captures = Arc::new(Mutex::new(Vec::new())); - let Ok(base_url) = spawn_models_server( - StatusCode::OK, - json!({ - "data": [ - { "id": "qwen-b" }, - { "id": "qwen-a" }, - { "not_id": "ignored" } - ] - }), - captures.clone(), - ) - .await - else { - // The managed sandbox can deny loopback binds; this test never calls external APIs. - return; - }; - let _config = ConfigCacheGuard::with_dashscope(format!("{}/", base_url), "dash-key"); - - let models = list_dashscope_models_inner().await.unwrap(); - - assert_eq!(models, vec!["qwen-a".to_string(), "qwen-b".to_string()]); - let capture = captures.lock().unwrap().first().cloned().unwrap(); - assert_eq!(capture.authorization.as_deref(), Some("Bearer dash-key")); - assert_eq!(capture.body, None); - } - - #[serial] - #[tokio::test] - async fn list_dashscope_models_reports_server_error_body() { - let captures = Arc::new(Mutex::new(Vec::new())); - let Ok(base_url) = spawn_models_server( - StatusCode::UNAUTHORIZED, - json!({"message": "bad key"}), - captures, - ) - .await - else { - // The managed sandbox can deny loopback binds; this test never calls external APIs. - return; - }; - let _config = ConfigCacheGuard::with_dashscope(base_url, "dash-key"); - - let err = list_dashscope_models_inner().await.unwrap_err(); - - assert!(err.starts_with("Models API error:")); - assert!(err.contains("bad key")); - } - - #[serial] - #[tokio::test] - async fn call_ai_chat_posts_request_body_and_parses_response_content() { - let captures = Arc::new(Mutex::new(Vec::new())); - let Ok(base_url) = spawn_chat_server( - StatusCode::OK, - json!({ - "choices": [ - { "message": { "content": "refined text" } } - ] - }), - captures.clone(), - ) - .await - else { - // The managed sandbox can deny loopback binds; this test never calls external APIs. - return; - }; - let _config = ConfigCacheGuard::with_dashscope(format!("{}/", base_url), "dash-key"); - let messages = vec![json!({"role": "user", "content": "hello"})]; - - let content = call_ai_chat(messages.clone(), Some("qwen-unit"), 0.25, "voice_refine") - .await - .unwrap(); - - assert_eq!(content, "refined text"); - let capture = captures.lock().unwrap().first().cloned().unwrap(); - assert_eq!(capture.authorization.as_deref(), Some("Bearer dash-key")); - assert_eq!(capture.content_type.as_deref(), Some("application/json")); - let body = capture.body.unwrap(); - assert_eq!(body["model"], "qwen-unit"); - assert_eq!(body["messages"], Value::Array(messages)); - assert_eq!(body["temperature"], 0.25); - } - - #[serial] - #[tokio::test] - async fn call_ai_chat_commit_ai_uses_commit_key_and_dashscope_base_url() { - let captures = Arc::new(Mutex::new(Vec::new())); - let Ok(base_url) = spawn_chat_server( - StatusCode::OK, - json!({ - "choices": [ - { "message": { "content": "fix(core): repair path" } } - ] - }), - captures.clone(), - ) - .await - else { - // The managed sandbox can deny loopback binds; this test never calls external APIs. - return; - }; - let mut config = crate::types::GlobalConfig::default(); - config.commit_ai_api_key = Some("commit-key".to_string()); - config.dashscope_base_url = Some(format!("{}/", base_url)); - let _config = ConfigCacheGuard::with_global_config(config); - - let content = call_ai_chat( - vec![json!({"role": "user", "content": "diff"})], - None, - 0.3, - "commit_ai", - ) - .await - .unwrap(); - - assert_eq!(content, "fix(core): repair path"); - let capture = captures.lock().unwrap().first().cloned().unwrap(); - assert_eq!(capture.authorization.as_deref(), Some("Bearer commit-key")); - assert_eq!(capture.body.unwrap()["model"], "qwen-turbo-latest"); - } - - #[serial] - #[tokio::test] - async fn voice_refine_and_commit_generation_handle_empty_inputs_without_network() { - let _config = ConfigCacheGuard::with_global_config(crate::types::GlobalConfig::default()); - - assert_eq!( - voice_refine_text_inner(" \n\t ".to_string()).await.unwrap(), - "" - ); - assert_eq!( - generate_commit_message(" \n\t ".to_string()) - .await - .unwrap_err(), - "No diff provided" - ); - } - - #[serial] - #[tokio::test] - async fn voice_start_send_audio_and_stop_use_dashscope_websocket_protocol() { - let _event_lock = lock_voice_event_tests(); - let _session = VoiceSessionGuard::isolated(); - let capture = Arc::new(Mutex::new(WsCapture::default())); - let Ok(ws_url) = spawn_dashscope_ws_server(capture.clone()).await else { - // The managed sandbox can deny loopback binds; this test never calls external APIs. - return; - }; - let mut config = crate::types::GlobalConfig::default(); - config.dashscope_api_key = Some("dash-key".to_string()); - config.dashscope_base_url = Some(ws_url.clone()); - config.voice_asr_model = Some("asr-unit".to_string()); - let _config = ConfigCacheGuard::with_global_config(config); - - voice_start_inner(Some(8000)).await.unwrap(); - assert!(voice_is_active_inner().unwrap()); - assert_eq!( - voice_start_inner(None).await.unwrap_err(), - "语音会话已在进行中" - ); - voice_send_audio_inner(BASE64.encode([9u8, 8, 7])).unwrap(); - - // 等待 mock server 实际收到音频帧后再停止,避免 stop 抢先关闭连接导致丢帧(llvm-cov 插桩下更易触发)。 - tokio::time::timeout(Duration::from_secs(2), async { - loop { - { - let c = capture - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if !c.binary_messages.is_empty() { - break; - } - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("server receives audio frame"); - - voice_stop_inner().unwrap(); - - tokio::time::timeout(Duration::from_secs(2), async { - loop { - if !voice_is_active_inner().unwrap() { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("voice session stops"); - - let capture = capture - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - assert_eq!(capture.authorization.as_deref(), Some("Bearer dash-key")); - assert!(capture - .host - .as_deref() - .unwrap_or("") - .starts_with("127.0.0.1:")); - assert_eq!(capture.binary_messages, vec![vec![9u8, 8, 7]]); - assert_eq!(capture.text_messages[0]["header"]["action"], "run-task"); - assert_eq!(capture.text_messages[0]["payload"]["model"], "asr-unit"); - assert_eq!( - capture.text_messages[0]["payload"]["parameters"]["sample_rate"], - 8000 - ); - assert_eq!( - capture.text_messages.last().unwrap()["header"]["action"], - "finish-task" - ); - } -} diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs deleted file mode 100644 index fcec77e..0000000 --- a/src-tauri/src/commands/window.rs +++ /dev/null @@ -1,1111 +0,0 @@ -use std::collections::HashMap; -use tauri::Emitter; - -use crate::config::{load_global_config, load_occupation_state}; -use crate::state::{ - APP_HANDLE, LOCK_BROADCAST, TERMINAL_STATES, TERMINAL_STATE_BROADCAST, WINDOW_WORKSPACES, - WORKTREE_LOCKS, -}; -use crate::types::TerminalState; - -// ==================== 多窗口管理 ==================== - -pub fn set_window_workspace_impl(window_label: &str, workspace_path: String) -> Result<(), String> { - let global = load_global_config(); - if !global.workspaces.iter().any(|w| w.path == workspace_path) { - log::warn!( - "[window] Workspace not found for binding: label={}, path={}", - window_label, - workspace_path - ); - return Err("Workspace not found".to_string()); - } - - let mut map = WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - map.insert(window_label.to_string(), workspace_path.clone()); - log::info!( - "[window] Window '{}' bound to workspace '{}'", - window_label, - workspace_path - ); - Ok(()) -} - -#[tauri::command] -pub(crate) fn set_window_workspace( - window: tauri::Window, - workspace_path: String, -) -> Result<(), String> { - set_window_workspace_impl(window.label(), workspace_path) -} - -#[tauri::command] -pub(crate) fn get_opened_workspaces() -> Vec { - let map = WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - map.values().cloned().collect() -} - -pub fn unregister_window_impl(window_label: &str) { - log::info!("[window] Unregistering window '{}'", window_label); - let label = window_label.to_string(); - { - let mut map = WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - map.remove(&label); - } - // 同时释放该窗口持有的所有 worktree 锁(包括带 cell_id 的格式 "label:cell_id") - let prefix = format!("{}:", window_label); - let affected_workspaces: Vec = { - let mut locks = WORKTREE_LOCKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let lock_count = locks - .iter() - .filter(|(_, v)| **v == label || v.starts_with(&prefix)) - .count(); - let affected: Vec = locks - .iter() - .filter(|(_, v)| **v == label || v.starts_with(&prefix)) - .map(|((ws_path, _), _)| ws_path.clone()) - .collect(); - locks.retain(|_, v| *v != label && !v.starts_with(&prefix)); - log::info!( - "[window] Window '{}' unregistered, released {} locks", - window_label, - lock_count - ); - affected - }; - for ws_path in affected_workspaces { - broadcast_lock_state(&ws_path); - } -} - -#[tauri::command] -pub(crate) fn unregister_window(window: tauri::Window) { - unregister_window_impl(window.label()) -} - -/// 锁定 worktree 到当前窗口,如果该 worktree 已被其他窗口锁定则返回错误 -pub fn lock_worktree_impl( - window_label: &str, - workspace_path: String, - worktree_name: String, -) -> Result<(), String> { - let label = window_label.to_string(); - { - let mut locks = WORKTREE_LOCKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let key = (workspace_path.clone(), worktree_name.clone()); - - if let Some(existing_label) = locks.get(&key) { - if *existing_label != label { - log::warn!( - "[window] Lock conflict: wt={} already locked by '{}', requested by '{}'", - worktree_name, - existing_label, - label - ); - return Err(format!("Worktree \"{}\" 已在其他窗口中打开", worktree_name)); - } - } - locks.insert(key, label); - } - log::info!( - "[window] Worktree locked: ws={}, wt={}, by={}", - workspace_path, - worktree_name, - window_label - ); - broadcast_lock_state(&workspace_path); - Ok(()) -} - -#[tauri::command] -pub(crate) fn lock_worktree( - window: tauri::Window, - workspace_path: String, - worktree_name: String, - cell_id: Option, -) -> Result<(), String> { - let label = match cell_id { - Some(id) => format!("{}:{}", window.label(), id), - None => window.label().to_string(), - }; - lock_worktree_impl(&label, workspace_path, worktree_name) -} - -/// 解锁当前窗口持有的指定 worktree -pub fn unlock_worktree_impl(window_label: &str, workspace_path: String, worktree_name: String) { - let label = window_label.to_string(); - { - let mut locks = WORKTREE_LOCKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let key = (workspace_path.clone(), worktree_name.clone()); - if let Some(existing_label) = locks.get(&key) { - if *existing_label == label { - locks.remove(&key); - log::info!( - "[window] Worktree unlocked: ws={}, wt={}, by={}", - workspace_path, - worktree_name, - window_label - ); - } - } - } - broadcast_lock_state(&workspace_path); -} - -#[tauri::command] -pub(crate) fn unlock_worktree( - window: tauri::Window, - workspace_path: String, - worktree_name: String, - cell_id: Option, -) { - let label = match cell_id { - Some(id) => format!("{}:{}", window.label(), id), - None => window.label().to_string(), - }; - unlock_worktree_impl(&label, workspace_path, worktree_name) -} - -/// 获取指定 workspace 中所有被锁定的 worktree 列表 (worktree_name -> window_label) -#[tauri::command] -pub(crate) fn get_locked_worktrees(workspace_path: String) -> HashMap { - let locks = WORKTREE_LOCKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - locks - .iter() - .filter(|((ws_path, _), _)| *ws_path == workspace_path) - .map(|((_, wt_name), label)| (wt_name.clone(), label.clone())) - .collect() -} - -/// 获取缓存的终端状态(用于客户端首次打开 worktree 时同步) -pub(crate) fn get_terminal_state_inner( - workspace_path: String, - worktree_name: String, -) -> Option { - let key = (workspace_path, worktree_name); - TERMINAL_STATES - .lock() - .ok() - .and_then(|states| states.get(&key).cloned()) -} - -#[tauri::command] -pub(crate) fn get_terminal_state( - workspace_path: String, - worktree_name: String, -) -> Option { - get_terminal_state_inner(workspace_path, worktree_name) -} - -/// 广播终端状态变化(用于桌面端同步到网页端) -#[tauri::command] -#[allow(clippy::too_many_arguments)] -pub(crate) fn broadcast_terminal_state( - app: tauri::AppHandle, - workspace_path: String, - worktree_name: String, - activated_terminals: Vec, - active_terminal_tab: Option, - terminal_visible: bool, - client_id: Option, - session_id: Option, -) { - log::debug!( - "[window] Broadcasting terminal state: ws={}, wt={}", - workspace_path, - worktree_name - ); - let key = (workspace_path.clone(), worktree_name.clone()); - - // 更新缓存 - if let Ok(mut states) = TERMINAL_STATES.lock() { - states.insert( - key, - TerminalState { - activated_terminals: activated_terminals.clone(), - active_terminal_tab: active_terminal_tab.clone(), - terminal_visible, - client_id: client_id.clone(), - session_id: session_id.clone(), - }, - ); - } - - // 广播给所有连接的客户端(WebSocket) - if let Ok(json_str) = serde_json::to_string(&serde_json::json!({ - "workspacePath": workspace_path, - "worktreeName": worktree_name, - "activatedTerminals": activated_terminals, - "activeTerminalTab": active_terminal_tab, - "terminalVisible": terminal_visible, - "clientId": client_id, - "sessionId": session_id, - })) { - let _ = TERMINAL_STATE_BROADCAST.send(json_str); - } - - // 同时通过 Tauri 事件发送给所有桌面端窗口 - let _ = app.emit( - "terminal-state-update", - serde_json::json!({ - "workspacePath": workspace_path, - "worktreeName": worktree_name, - "activatedTerminals": activated_terminals, - "activeTerminalTab": active_terminal_tab, - "terminalVisible": terminal_visible, - "clientId": client_id, - "sessionId": session_id, - }), - ); -} - -#[tauri::command] -pub(crate) async fn open_workspace_window( - app: tauri::AppHandle, - workspace_path: String, -) -> Result { - log::info!( - "[window] Opening new workspace window for: {}", - workspace_path - ); - let global = load_global_config(); - if !global.workspaces.iter().any(|w| w.path == workspace_path) { - log::warn!( - "[window] Workspace not found when opening window: {}", - workspace_path - ); - return Err("Workspace not found".to_string()); - } - - let ws_name = global - .workspaces - .iter() - .find(|w| w.path == workspace_path) - .map(|w| w.name.clone()) - .unwrap_or_else(|| "Worktree Manager".to_string()); - - let window_label = format!( - "workspace-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - ); - - let url = format!( - "index.html?workspace={}", - urlencoding::encode(&workspace_path) - ); - - let _webview = - tauri::WebviewWindowBuilder::new(&app, &window_label, tauri::WebviewUrl::App(url.into())) - .title(format!("Worktree Manager - {}", ws_name)) - .inner_size(1300.0, 900.0) - .min_inner_size(900.0, 500.0) - .build() - .map_err(|e| { - log::error!("[window] Failed to create window: {}", e); - format!("Failed to create window: {}", e) - })?; - - // 注册窗口绑定 - { - let mut map = WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - map.insert(window_label.clone(), workspace_path.clone()); - } - - log::info!( - "[window] Created window '{}' for workspace '{}'", - window_label, - workspace_path - ); - Ok(window_label) -} - -/// Broadcast the current lock state for a given workspace to all WebSocket clients. -/// `locks` must already be dropped before calling this to avoid deadlocks. -pub(crate) fn broadcast_lock_state(workspace_path: &str) { - let lock_snapshot: HashMap = { - let locks = WORKTREE_LOCKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - locks - .iter() - .filter(|((wp, _), _)| *wp == workspace_path) - .map(|((_, wt), lbl)| (wt.clone(), lbl.clone())) - .collect() - }; - log::debug!( - "[window] Broadcasting lock state for ws={}, locks={}", - workspace_path, - lock_snapshot.len() - ); - let occupation = load_occupation_state(workspace_path); - let payload = serde_json::json!({ - "workspacePath": workspace_path, - "locks": lock_snapshot, - "occupation": occupation, - }); - - if let Ok(json_str) = serde_json::to_string(&payload) { - let _ = LOCK_BROADCAST.send(json_str); - } - - if let Some(app_handle) = APP_HANDLE.lock().ok().and_then(|handle| handle.clone()) { - let _ = app_handle.emit("lock-state-update", payload); - } -} - -// ==================== DevTools ==================== - -#[tauri::command] -pub(crate) fn open_devtools(webview_window: tauri::WebviewWindow) { - #[cfg(any(debug_assertions, feature = "devtools"))] - webview_window.open_devtools(); - #[cfg(not(any(debug_assertions, feature = "devtools")))] - { - log::warn!("[window] DevTools requested but this build was compiled without devtools"); - let _ = webview_window; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::state::{GLOBAL_CONFIG_CACHE, TERMINAL_STATES}; - use crate::types::{GlobalConfig, TerminalState, WorkspaceRef}; - use serial_test::serial; - use std::collections::HashMap; - use std::path::PathBuf; - use std::time::Duration; - - struct NamedTestLock { - path: PathBuf, - } - - impl NamedTestLock { - fn acquire() -> Self { - let path = std::env::temp_dir().join("worktree-manager-command-test-global-lock"); - loop { - match std::fs::create_dir(&path) { - Ok(()) => return Self { path }, - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { - std::thread::sleep(Duration::from_millis(10)); - } - Err(e) => panic!("failed to acquire test lock at {:?}: {}", path, e), - } - } - } - } - - impl Drop for NamedTestLock { - fn drop(&mut self) { - let _ = std::fs::remove_dir(&self.path); - } - } - - struct WindowCommandStateGuard { - _lock: NamedTestLock, - previous_global: Option, - previous_windows: HashMap, - previous_locks: HashMap<(String, String), String>, - previous_terminal_states: HashMap<(String, String), TerminalState>, - } - - impl WindowCommandStateGuard { - fn with_global_config(config: GlobalConfig) -> Self { - let lock = NamedTestLock::acquire(); - let previous_global = { - let mut cache = GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::replace(&mut *cache, Some(config)) - }; - let previous_windows = { - let mut windows = WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *windows) - }; - let previous_locks = { - let mut locks = WORKTREE_LOCKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *locks) - }; - let previous_terminal_states = { - let mut states = TERMINAL_STATES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *states) - }; - Self { - _lock: lock, - previous_global, - previous_windows, - previous_locks, - previous_terminal_states, - } - } - } - - impl Drop for WindowCommandStateGuard { - fn drop(&mut self) { - let mut states = TERMINAL_STATES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *states = std::mem::take(&mut self.previous_terminal_states); - drop(states); - - let mut locks = WORKTREE_LOCKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *locks = std::mem::take(&mut self.previous_locks); - drop(locks); - - let mut windows = WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *windows = std::mem::take(&mut self.previous_windows); - drop(windows); - - let mut cache = GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = self.previous_global.take(); - } - } - - fn global_with_workspace(path: &str) -> GlobalConfig { - GlobalConfig { - workspaces: vec![WorkspaceRef { - name: "Window Test".to_string(), - path: path.to_string(), - }], - current_workspace: Some(path.to_string()), - ..GlobalConfig::default() - } - } - - fn temp_workspace_path() -> (tempfile::TempDir, String) { - let temp = tempfile::tempdir().expect("create workspace"); - let path = temp.path().to_string_lossy().to_string(); - (temp, path) - } - - #[serial] - #[test] - fn set_unregister_and_query_window_workspace_mapping() { - let (_temp, workspace_path) = temp_workspace_path(); - let _guard = - WindowCommandStateGuard::with_global_config(global_with_workspace(&workspace_path)); - - set_window_workspace_impl("window-a", workspace_path.clone()).expect("bind window"); - let opened = get_opened_workspaces(); - unregister_window_impl("window-a"); - - assert_eq!(opened, vec![workspace_path]); - assert!(get_opened_workspaces().is_empty()); - } - - #[serial] - #[test] - fn set_window_workspace_rejects_unknown_workspace_without_binding() { - let _guard = WindowCommandStateGuard::with_global_config(GlobalConfig::default()); - - let err = set_window_workspace_impl("window-a", "/missing/workspace".to_string()) - .expect_err("unknown workspace should fail"); - - assert_eq!(err, "Workspace not found"); - assert!(get_opened_workspaces().is_empty()); - } - - #[serial] - #[test] - fn lock_worktree_allows_same_owner_and_blocks_other_owner() { - let (_temp, workspace_path) = temp_workspace_path(); - let _guard = - WindowCommandStateGuard::with_global_config(global_with_workspace(&workspace_path)); - - lock_worktree_impl("window-a", workspace_path.clone(), "feature-a".to_string()) - .expect("first lock"); - lock_worktree_impl("window-a", workspace_path.clone(), "feature-a".to_string()) - .expect("same owner can refresh lock"); - let err = lock_worktree_impl("window-b", workspace_path.clone(), "feature-a".to_string()) - .expect_err("other owner should conflict"); - let locked = get_locked_worktrees(workspace_path); - - assert!(err.contains("feature-a"), "unexpected error: {err}"); - assert_eq!(locked.get("feature-a"), Some(&"window-a".to_string())); - assert_eq!(locked.len(), 1); - } - - #[serial] - #[test] - fn unlock_worktree_only_removes_lock_for_current_owner() { - let (_temp, workspace_path) = temp_workspace_path(); - let _guard = - WindowCommandStateGuard::with_global_config(global_with_workspace(&workspace_path)); - - lock_worktree_impl("owner", workspace_path.clone(), "feature-a".to_string()) - .expect("lock worktree"); - unlock_worktree_impl("other", workspace_path.clone(), "feature-a".to_string()); - let still_locked = get_locked_worktrees(workspace_path.clone()); - unlock_worktree_impl("owner", workspace_path.clone(), "feature-a".to_string()); - let unlocked = get_locked_worktrees(workspace_path); - - assert_eq!(still_locked.get("feature-a"), Some(&"owner".to_string())); - assert!(unlocked.is_empty()); - } - - #[serial] - #[test] - fn unregister_window_releases_plain_and_cell_locks_for_that_window_only() { - let (_temp, workspace_path) = temp_workspace_path(); - let _guard = - WindowCommandStateGuard::with_global_config(global_with_workspace(&workspace_path)); - - lock_worktree_impl("window-a", workspace_path.clone(), "feature-a".to_string()) - .expect("plain lock"); - lock_worktree_impl( - "window-a:cell-1", - workspace_path.clone(), - "feature-b".to_string(), - ) - .expect("cell lock"); - lock_worktree_impl("window-b", workspace_path.clone(), "feature-c".to_string()) - .expect("other window lock"); - - unregister_window_impl("window-a"); - let locked = get_locked_worktrees(workspace_path); - - assert!(!locked.contains_key("feature-a")); - assert!(!locked.contains_key("feature-b")); - assert_eq!(locked.get("feature-c"), Some(&"window-b".to_string())); - } - - #[serial] - #[test] - fn lock_keys_are_isolated_by_workspace_and_worktree_name() { - let (_temp_a, workspace_a) = temp_workspace_path(); - let (_temp_b, workspace_b) = temp_workspace_path(); - let config = GlobalConfig { - workspaces: vec![ - WorkspaceRef { - name: "A".to_string(), - path: workspace_a.clone(), - }, - WorkspaceRef { - name: "B".to_string(), - path: workspace_b.clone(), - }, - ], - ..GlobalConfig::default() - }; - let _guard = WindowCommandStateGuard::with_global_config(config); - - lock_worktree_impl("window-a", workspace_a.clone(), "feature".to_string()) - .expect("lock workspace a"); - lock_worktree_impl("window-b", workspace_b.clone(), "feature".to_string()) - .expect("same worktree name in different workspace is isolated"); - lock_worktree_impl("window-c", workspace_a.clone(), "other".to_string()) - .expect("different worktree in same workspace is isolated"); - - let locked_a = get_locked_worktrees(workspace_a); - let locked_b = get_locked_worktrees(workspace_b); - - assert_eq!(locked_a.get("feature"), Some(&"window-a".to_string())); - assert_eq!(locked_a.get("other"), Some(&"window-c".to_string())); - assert_eq!(locked_b.get("feature"), Some(&"window-b".to_string())); - } - - #[serial] - #[test] - fn terminal_state_cache_round_trips_by_workspace_and_worktree_key() { - let (_temp, workspace_path) = temp_workspace_path(); - let _guard = - WindowCommandStateGuard::with_global_config(global_with_workspace(&workspace_path)); - - TERMINAL_STATES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert( - (workspace_path.clone(), "feature-a".to_string()), - TerminalState { - activated_terminals: vec!["shell".to_string()], - active_terminal_tab: Some("shell".to_string()), - terminal_visible: true, - client_id: Some("client-1".to_string()), - session_id: Some("pty-1".to_string()), - }, - ); - - let found = get_terminal_state(workspace_path.clone(), "feature-a".to_string()) - .expect("cached terminal state"); - let missing = get_terminal_state(workspace_path, "feature-b".to_string()); - - assert_eq!(found.activated_terminals, vec!["shell"]); - assert_eq!(found.active_terminal_tab.as_deref(), Some("shell")); - assert!(found.terminal_visible); - assert_eq!(found.client_id.as_deref(), Some("client-1")); - assert_eq!(found.session_id.as_deref(), Some("pty-1")); - assert!(missing.is_none()); - } -} - -#[cfg(test)] -mod coverage_completion_tests { - use super::*; - use crate::state::{APP_HANDLE, GLOBAL_CONFIG_CACHE, TERMINAL_STATES}; - use crate::types::{GlobalConfig, TerminalState, WorkspaceRef}; - use serial_test::serial; - use std::collections::HashMap; - use std::path::PathBuf; - use std::time::Duration; - - struct NamedTestLock { - path: PathBuf, - } - - impl NamedTestLock { - fn acquire() -> Self { - let path = std::env::temp_dir().join("worktree-manager-command-test-global-lock"); - loop { - match std::fs::create_dir(&path) { - Ok(()) => return Self { path }, - Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { - std::thread::sleep(Duration::from_millis(10)); - } - Err(err) => panic!("failed to acquire test lock {:?}: {}", path, err), - } - } - } - } - - impl Drop for NamedTestLock { - fn drop(&mut self) { - let _ = std::fs::remove_dir(&self.path); - } - } - - struct StateGuard { - _lock: NamedTestLock, - previous_global: Option, - previous_windows: HashMap, - previous_locks: HashMap<(String, String), String>, - previous_terminal_states: HashMap<(String, String), TerminalState>, - previous_app_handle: Option, - } - - impl StateGuard { - fn with_workspaces(paths: &[String]) -> Self { - let lock = NamedTestLock::acquire(); - let config = GlobalConfig { - workspaces: paths - .iter() - .enumerate() - .map(|(index, path)| WorkspaceRef { - name: format!("Workspace {index}"), - path: path.clone(), - }) - .collect(), - current_workspace: paths.first().cloned(), - ..GlobalConfig::default() - }; - let previous_global = { - let mut cache = GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::replace(&mut *cache, Some(config)) - }; - let previous_windows = { - let mut windows = WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *windows) - }; - let previous_locks = { - let mut locks = WORKTREE_LOCKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *locks) - }; - let previous_terminal_states = { - let mut states = TERMINAL_STATES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *states) - }; - let previous_app_handle = APP_HANDLE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .take(); - - Self { - _lock: lock, - previous_global, - previous_windows, - previous_locks, - previous_terminal_states, - previous_app_handle, - } - } - } - - impl Drop for StateGuard { - fn drop(&mut self) { - *APP_HANDLE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = self.previous_app_handle.take(); - - let mut states = TERMINAL_STATES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *states = std::mem::take(&mut self.previous_terminal_states); - drop(states); - - let mut locks = WORKTREE_LOCKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *locks = std::mem::take(&mut self.previous_locks); - drop(locks); - - let mut windows = WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *windows = std::mem::take(&mut self.previous_windows); - drop(windows); - - let mut cache = GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = self.previous_global.take(); - } - } - - fn temp_workspace_path() -> (tempfile::TempDir, String) { - let temp = tempfile::tempdir().expect("create workspace"); - let path = temp.path().to_string_lossy().to_string(); - (temp, path) - } - - #[serial] - #[test] - fn unregister_window_without_matching_locks_preserves_other_state() { - let (_temp_a, workspace_a) = temp_workspace_path(); - let (_temp_b, workspace_b) = temp_workspace_path(); - let _guard = StateGuard::with_workspaces(&[workspace_a.clone(), workspace_b.clone()]); - WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert("window-b".to_string(), workspace_b.clone()); - WORKTREE_LOCKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert( - (workspace_b.clone(), "feature-b".to_string()), - "window-b".to_string(), - ); - - unregister_window_impl("window-a"); - - assert_eq!(get_opened_workspaces(), vec![workspace_b.clone()]); - assert_eq!( - get_locked_worktrees(workspace_b).get("feature-b"), - Some(&"window-b".to_string()) - ); - } - - #[serial] - #[test] - fn broadcast_lock_state_sends_workspace_specific_snapshot() { - let (_temp_a, workspace_a) = temp_workspace_path(); - let (_temp_b, workspace_b) = temp_workspace_path(); - let _guard = StateGuard::with_workspaces(&[workspace_a.clone(), workspace_b.clone()]); - WORKTREE_LOCKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .extend([ - ( - (workspace_a.clone(), "feature-a".to_string()), - "window-a".to_string(), - ), - ( - (workspace_b.clone(), "feature-b".to_string()), - "window-b".to_string(), - ), - ]); - let mut receiver = LOCK_BROADCAST.subscribe(); - - broadcast_lock_state(&workspace_a); - let payload: serde_json::Value = serde_json::from_str( - &receiver - .try_recv() - .expect("lock broadcast should contain one payload"), - ) - .expect("lock payload json"); - - assert_eq!(payload["workspacePath"], workspace_a); - assert_eq!(payload["locks"]["feature-a"], "window-a"); - assert!(payload["locks"].get("feature-b").is_none()); - assert!(payload["occupation"].is_null()); - } - - #[serial] - #[test] - fn lock_unlock_transitions_broadcast_each_state_change() { - let (_temp, workspace_path) = temp_workspace_path(); - let _guard = StateGuard::with_workspaces(std::slice::from_ref(&workspace_path)); - let mut receiver = LOCK_BROADCAST.subscribe(); - - lock_worktree_impl("window-a", workspace_path.clone(), "feature-a".to_string()) - .expect("lock feature"); - let locked_payload: serde_json::Value = - serde_json::from_str(&receiver.try_recv().expect("lock broadcast after lock")) - .expect("lock json"); - assert_eq!(locked_payload["locks"]["feature-a"], "window-a"); - - unlock_worktree_impl("window-a", workspace_path, "feature-a".to_string()); - let unlocked_payload: serde_json::Value = - serde_json::from_str(&receiver.try_recv().expect("lock broadcast after unlock")) - .expect("unlock json"); - assert_eq!( - unlocked_payload["locks"] - .as_object() - .expect("locks object") - .len(), - 0 - ); - } - - #[serial] - #[test] - fn window_workspace_map_matrix_isolated_by_labels_and_paths() { - let (_temp_a, workspace_a) = temp_workspace_path(); - let (_temp_b, workspace_b) = temp_workspace_path(); - let (_temp_c, workspace_c) = temp_workspace_path(); - let _guard = StateGuard::with_workspaces(&[ - workspace_a.clone(), - workspace_b.clone(), - workspace_c.clone(), - ]); - - set_window_workspace_impl("main", workspace_a.clone()).expect("bind main"); - set_window_workspace_impl("secondary", workspace_b.clone()).expect("bind secondary"); - set_window_workspace_impl("detached", workspace_c.clone()).expect("bind detached"); - set_window_workspace_impl("secondary", workspace_c.clone()).expect("rebind secondary"); - - let opened = get_opened_workspaces(); - assert!(opened.contains(&workspace_a)); - assert!(opened.contains(&workspace_c)); - assert!(!opened.contains(&workspace_b)); - assert_eq!(opened.len(), 3); - - let map = WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - assert_eq!(map.get("main"), Some(&workspace_a)); - assert_eq!(map.get("secondary"), Some(&workspace_c)); - assert_eq!(map.get("detached"), Some(&workspace_c)); - assert_eq!(map.len(), 3); - } - - #[serial] - #[test] - fn worktree_lock_matrix_keeps_workspace_worktree_and_owner_boundaries() { - let (_temp_a, workspace_a) = temp_workspace_path(); - let (_temp_b, workspace_b) = temp_workspace_path(); - let _guard = StateGuard::with_workspaces(&[workspace_a.clone(), workspace_b.clone()]); - let lock_cases = [ - ("window-a", workspace_a.clone(), "feature-alpha", true), - ("window-a", workspace_a.clone(), "feature-alpha", true), - ("window-b", workspace_a.clone(), "feature-alpha", false), - ("window-b", workspace_a.clone(), "feature-beta", true), - ("window-c", workspace_b.clone(), "feature-alpha", true), - ("window-c:cell-1", workspace_b.clone(), "feature-cell", true), - ( - "window-c:cell-2", - workspace_b.clone(), - "feature-cell", - false, - ), - ("window-d", workspace_a.clone(), "feature-delta", true), - ]; - - for (owner, workspace, worktree, should_succeed) in lock_cases { - let result = lock_worktree_impl(owner, workspace, worktree.to_string()); - - assert_eq!(result.is_ok(), should_succeed, "{owner}:{worktree}"); - } - - let locked_a = get_locked_worktrees(workspace_a.clone()); - assert_eq!(locked_a.get("feature-alpha"), Some(&"window-a".to_string())); - assert_eq!(locked_a.get("feature-beta"), Some(&"window-b".to_string())); - assert_eq!(locked_a.get("feature-delta"), Some(&"window-d".to_string())); - assert_eq!(locked_a.len(), 3); - - let locked_b = get_locked_worktrees(workspace_b.clone()); - assert_eq!(locked_b.get("feature-alpha"), Some(&"window-c".to_string())); - assert_eq!( - locked_b.get("feature-cell"), - Some(&"window-c:cell-1".to_string()) - ); - assert_eq!(locked_b.len(), 2); - - unlock_worktree_impl("window-x", workspace_a.clone(), "feature-alpha".to_string()); - assert_eq!( - get_locked_worktrees(workspace_a.clone()).get("feature-alpha"), - Some(&"window-a".to_string()) - ); - unlock_worktree_impl("window-a", workspace_a.clone(), "feature-alpha".to_string()); - assert!(!get_locked_worktrees(workspace_a).contains_key("feature-alpha")); - } - - #[serial] - #[test] - fn unregister_window_matrix_removes_plain_and_cell_locks_only_for_matching_window() { - let (_temp_a, workspace_a) = temp_workspace_path(); - let (_temp_b, workspace_b) = temp_workspace_path(); - let _guard = StateGuard::with_workspaces(&[workspace_a.clone(), workspace_b.clone()]); - WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .extend([ - ("window-a".to_string(), workspace_a.clone()), - ("window-b".to_string(), workspace_b.clone()), - ("window-c".to_string(), workspace_b.clone()), - ]); - WORKTREE_LOCKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .extend([ - ( - (workspace_a.clone(), "feature-a".to_string()), - "window-a".to_string(), - ), - ( - (workspace_a.clone(), "feature-a-cell".to_string()), - "window-a:cell-1".to_string(), - ), - ( - (workspace_b.clone(), "feature-b".to_string()), - "window-b".to_string(), - ), - ( - (workspace_b.clone(), "feature-c".to_string()), - "window-c:cell-9".to_string(), - ), - ]); - let mut receiver = LOCK_BROADCAST.subscribe(); - - unregister_window_impl("window-a"); - - let opened = get_opened_workspaces(); - assert!(!opened.contains(&workspace_a)); - assert!(opened.contains(&workspace_b)); - assert_eq!(opened.len(), 2); - let locked_a = get_locked_worktrees(workspace_a.clone()); - let locked_b = get_locked_worktrees(workspace_b.clone()); - assert!(locked_a.is_empty()); - assert_eq!(locked_b.get("feature-b"), Some(&"window-b".to_string())); - assert_eq!( - locked_b.get("feature-c"), - Some(&"window-c:cell-9".to_string()) - ); - - let payload: serde_json::Value = - serde_json::from_str(&receiver.try_recv().expect("workspace a broadcast")) - .expect("broadcast json"); - assert_eq!(payload["workspacePath"], workspace_a); - assert_eq!(payload["locks"].as_object().expect("locks object").len(), 0); - } - - #[serial] - #[test] - fn terminal_state_matrix_round_trips_distinct_tabs_clients_and_sessions() { - let (_temp_a, workspace_a) = temp_workspace_path(); - let (_temp_b, workspace_b) = temp_workspace_path(); - let _guard = StateGuard::with_workspaces(&[workspace_a.clone(), workspace_b.clone()]); - let state_cases = [ - ( - workspace_a.clone(), - "feature-a", - vec!["shell".to_string()], - Some("shell".to_string()), - true, - Some("client-a".to_string()), - Some("pty-a".to_string()), - ), - ( - workspace_a.clone(), - "feature-b", - vec!["shell".to_string(), "logs".to_string()], - Some("logs".to_string()), - false, - Some("client-b".to_string()), - Some("pty-b".to_string()), - ), - ( - workspace_b.clone(), - "feature-a", - Vec::new(), - None, - false, - None, - None, - ), - ]; - - for ( - workspace, - worktree, - activated_terminals, - active_terminal_tab, - terminal_visible, - client_id, - session_id, - ) in state_cases - { - TERMINAL_STATES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert( - (workspace.clone(), worktree.to_string()), - TerminalState { - activated_terminals: activated_terminals.clone(), - active_terminal_tab: active_terminal_tab.clone(), - terminal_visible, - client_id: client_id.clone(), - session_id: session_id.clone(), - }, - ); - - let found = get_terminal_state_inner(workspace, worktree.to_string()) - .expect("terminal state should round trip"); - - assert_eq!(found.activated_terminals, activated_terminals); - assert_eq!(found.active_terminal_tab, active_terminal_tab); - assert_eq!(found.terminal_visible, terminal_visible); - assert_eq!(found.client_id, client_id); - assert_eq!(found.session_id, session_id); - } - - assert!(get_terminal_state_inner(workspace_b, "missing".to_string()).is_none()); - } -} diff --git a/src-tauri/src/commands/workspace.rs b/src-tauri/src/commands/workspace.rs deleted file mode 100644 index 362fa72..0000000 --- a/src-tauri/src/commands/workspace.rs +++ /dev/null @@ -1,1002 +0,0 @@ -use std::fs; -use std::path::PathBuf; - -use crate::config::{ - get_window_workspace_config, get_window_workspace_path, get_workspace_config_path, - load_global_config, save_global_config_internal, save_workspace_config_internal, -}; -use crate::state::{WINDOW_WORKSPACES, WORKSPACE_CONFIG_CACHE}; -use crate::types::{ - default_linked_workspace_items, default_uat_branch, WorkspaceConfig, WorkspaceRef, -}; -use crate::utils::normalize_path; - -// ==================== Tauri 命令:Workspace 管理 ==================== - -#[tauri::command] -pub(crate) fn list_workspaces() -> Vec { - let global = load_global_config(); - global - .workspaces - .into_iter() - .map(|mut w| { - w.path = normalize_path(&w.path); - w - }) - .collect() -} - -pub fn get_current_workspace_impl(window_label: &str) -> Option { - let global = load_global_config(); - let current_path = get_window_workspace_path(window_label)?; - global - .workspaces - .iter() - .find(|w| w.path == current_path) - .cloned() - .map(|mut w| { - w.path = normalize_path(&w.path); - w - }) -} - -#[tauri::command] -pub(crate) fn get_current_workspace( - window: tauri::Window, - workspace_path: Option, -) -> Option { - if let Some(path) = workspace_path { - let global = load_global_config(); - global - .workspaces - .iter() - .find(|w| w.path == path) - .cloned() - .map(|mut w| { - w.path = normalize_path(&w.path); - w - }) - } else { - get_current_workspace_impl(window.label()) - } -} - -pub fn switch_workspace_impl(window_label: &str, path: String) -> Result<(), String> { - let path = normalize_path(&path); - let mut global = load_global_config(); - - let previous = global - .current_workspace - .clone() - .unwrap_or_else(|| "".to_string()); - log::info!( - "[workspace] Switching workspace: from='{}' to='{}' (window={})", - previous, - path, - window_label - ); - - // 验证 workspace 存在 - if !global.workspaces.iter().any(|w| w.path == path) { - log::error!("[workspace] Workspace not found: {}", path); - return Err("Workspace not found".to_string()); - } - - global.current_workspace = Some(path.clone()); - save_global_config_internal(&global)?; - - // 绑定窗口 workspace - { - let mut map = WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - map.insert(window_label.to_string(), path.clone()); - } - - // 清除 workspace 配置缓存 - { - let mut cache = WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = None; - } - - log::info!("[workspace] Successfully switched to workspace '{}'", path); - Ok(()) -} - -#[tauri::command] -pub(crate) fn switch_workspace( - window: tauri::Window, - path: String, - workspace_path: Option, -) -> Result<(), String> { - if workspace_path.is_some() { - // Non-primary cell: verify workspace exists but don't bind to window - let global = load_global_config(); - if !global.workspaces.iter().any(|w| w.path == path) { - return Err("Workspace not found".to_string()); - } - // Clear config cache so fresh data is loaded - let mut cache = WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = None; - Ok(()) - } else { - switch_workspace_impl(window.label(), path) - } -} - -#[tauri::command] -pub(crate) fn add_workspace(name: String, path: String) -> Result<(), String> { - let path = normalize_path(&path); - log::info!( - "[workspace] Adding workspace: name='{}', path='{}'", - name, - path - ); - let mut global = load_global_config(); - - // 检查是否已存在 - if global.workspaces.iter().any(|w| w.path == path) { - log::warn!("[workspace] Workspace already exists at path: {}", path); - return Err("Workspace with this path already exists".to_string()); - } - - // 检查路径是否存在 - let workspace_path = PathBuf::from(&path); - if !workspace_path.exists() { - log::error!("[workspace] Path does not exist: {}", path); - return Err("Path does not exist".to_string()); - } - - // 自动创建 projects/ 目录(如果不存在) - let projects_dir = workspace_path.join("projects"); - if !projects_dir.exists() { - fs::create_dir_all(&projects_dir) - .map_err(|e| format!("Failed to create projects directory: {}", e))?; - log::info!( - "[workspace] Auto-created projects/ directory at {:?}", - projects_dir - ); - } - - // 添加到列表 - global.workspaces.push(WorkspaceRef { - name: name.clone(), - path: path.clone(), - }); - - // 如果是第一个或者当前没有选中的,则设为当前 - if global.current_workspace.is_none() { - log::info!("[workspace] Setting as current workspace (first workspace)"); - global.current_workspace = Some(path.clone()); - } - - save_global_config_internal(&global)?; - - // 如果 workspace 目录下没有配置文件,创建默认配置 - let ws_config_path = get_workspace_config_path(&path); - if !ws_config_path.exists() { - log::info!( - "[workspace] Creating default workspace config at {:?}", - ws_config_path - ); - let default_ws_config = WorkspaceConfig { - name: name.clone(), - ..WorkspaceConfig::default() - }; - save_workspace_config_internal(&path, &default_ws_config)?; - } - - log::info!( - "[workspace] Successfully added workspace '{}' at '{}'", - name, - path - ); - Ok(()) -} - -#[tauri::command] -pub(crate) fn remove_workspace(path: String) -> Result<(), String> { - let path = normalize_path(&path); - log::info!("[workspace] Removing workspace at path: '{}'", path); - let mut global = load_global_config(); - - let count_before = global.workspaces.len(); - // 移除 - global.workspaces.retain(|w| w.path != path); - let removed = count_before - global.workspaces.len(); - - if removed == 0 { - log::warn!("[workspace] No workspace found at path: {}", path); - } - - // 如果删除的是当前选中的,切换到第一个 - if global.current_workspace.as_ref() == Some(&path) { - let new_current = global.workspaces.first().map(|w| w.path.clone()); - log::info!( - "[workspace] Removed current workspace, switching to: {}", - new_current.as_deref().unwrap_or("") - ); - global.current_workspace = new_current; - } - - save_global_config_internal(&global)?; - - log::info!("[workspace] Successfully removed workspace '{}'", path); - Ok(()) -} - -#[tauri::command] -pub(crate) fn create_workspace(name: String, path: String) -> Result<(), String> { - let path = normalize_path(&path); - log::info!( - "[workspace] Creating new workspace: name='{}', path='{}'", - name, - path - ); - let workspace_path = PathBuf::from(&path); - - // 创建目录结构 - log::info!("[workspace] Creating directory structure at {}", path); - fs::create_dir_all(workspace_path.join("projects")) - .map_err(|e| format!("Failed to create workspace directory: {}", e))?; - fs::create_dir_all(workspace_path.join("worktrees")) - .map_err(|e| format!("Failed to create worktrees directory: {}", e))?; - - // 创建 workspace 配置 - log::info!("[workspace] Saving workspace config"); - let ws_config = WorkspaceConfig { - name: name.clone(), - worktrees_dir: "worktrees".to_string(), - projects: vec![], - linked_workspace_items: default_linked_workspace_items(), - vault_linked_workspace_items: vec![], - uat_branch: default_uat_branch(), - archived_worktrees: vec![], - worktree_colors: std::collections::HashMap::new(), - tags: vec![], - }; - save_workspace_config_internal(&path, &ws_config)?; - - // 添加到全局配置 - add_workspace(name.clone(), path.clone())?; - - log::info!( - "[workspace] Successfully created workspace '{}' at '{}'", - name, - path - ); - Ok(()) -} - -// ==================== Tauri 命令:Workspace 配置 ==================== - -pub fn get_workspace_config_impl(window_label: &str) -> Result { - let (_, config) = get_window_workspace_config(window_label).ok_or("No workspace selected")?; - Ok(config) -} - -#[tauri::command] -pub(crate) fn get_workspace_config( - window: tauri::Window, - workspace_path: Option, -) -> Result { - if let Some(path) = workspace_path { - Ok(crate::config::load_workspace_config(&path)) - } else { - get_workspace_config_impl(window.label()) - } -} - -pub fn save_workspace_config_impl( - window_label: &str, - config: WorkspaceConfig, -) -> Result<(), String> { - let workspace_path = get_window_workspace_path(window_label).ok_or("No workspace selected")?; - save_workspace_config_internal(&workspace_path, &config) -} - -#[tauri::command] -pub(crate) fn save_workspace_config( - window: tauri::Window, - config: WorkspaceConfig, - workspace_path: Option, -) -> Result<(), String> { - if let Some(path) = workspace_path { - save_workspace_config_internal(&path, &config) - } else { - save_workspace_config_impl(window.label(), config) - } -} - -#[tauri::command] -pub(crate) fn load_workspace_config_by_path(path: String) -> Result { - Ok(crate::config::load_workspace_config(&path)) -} - -#[tauri::command] -pub(crate) fn save_workspace_config_by_path( - path: String, - config: WorkspaceConfig, -) -> Result<(), String> { - save_workspace_config_internal(&path, &config) -} - -pub fn get_config_path_info_impl(window_label: &str) -> String { - if let Some(workspace_path) = get_window_workspace_path(window_label) { - normalize_path(&get_workspace_config_path(&workspace_path).to_string_lossy()) - } else { - normalize_path(&crate::config::get_global_config_path().to_string_lossy()) - } -} - -#[tauri::command] -pub(crate) fn get_config_path_info( - window: tauri::Window, - workspace_path: Option, -) -> String { - if let Some(path) = workspace_path { - normalize_path(&get_workspace_config_path(&path).to_string_lossy()) - } else { - get_config_path_info_impl(window.label()) - } -} - -// ==================== HTTP Server 共享接口 ==================== - -pub fn add_workspace_internal(name: &str, path: &str) -> Result<(), String> { - let path = normalize_path(path); - let path = path.as_str(); - let mut global = load_global_config(); - if global.workspaces.iter().any(|w| w.path == path) { - return Err("Workspace with this path already exists".to_string()); - } - let workspace_path = PathBuf::from(path); - if !workspace_path.exists() { - return Err("Path does not exist".to_string()); - } - - // 自动创建 projects/ 目录(如果不存在) - let projects_dir = workspace_path.join("projects"); - if !projects_dir.exists() { - fs::create_dir_all(&projects_dir) - .map_err(|e| format!("Failed to create projects directory: {}", e))?; - } - - global.workspaces.push(WorkspaceRef { - name: name.to_string(), - path: path.to_string(), - }); - if global.current_workspace.is_none() { - global.current_workspace = Some(path.to_string()); - } - save_global_config_internal(&global)?; - let ws_config_path = get_workspace_config_path(path); - if !ws_config_path.exists() { - let default_ws_config = WorkspaceConfig { - name: name.to_string(), - ..WorkspaceConfig::default() - }; - save_workspace_config_internal(path, &default_ws_config)?; - } - Ok(()) -} - -pub fn remove_workspace_internal(path: &str) -> Result<(), String> { - let path = normalize_path(path); - let path = path.as_str(); - let mut global = load_global_config(); - global.workspaces.retain(|w| w.path != path); - if global.current_workspace.as_deref() == Some(path) { - global.current_workspace = global.workspaces.first().map(|w| w.path.clone()); - } - save_global_config_internal(&global)?; - Ok(()) -} - -pub fn create_workspace_internal(name: &str, path: &str) -> Result<(), String> { - let path = normalize_path(path); - let path = path.as_str(); - let workspace_path = PathBuf::from(path); - fs::create_dir_all(workspace_path.join("projects")) - .map_err(|e| format!("Failed to create workspace directory: {}", e))?; - fs::create_dir_all(workspace_path.join("worktrees")) - .map_err(|e| format!("Failed to create worktrees directory: {}", e))?; - let ws_config = WorkspaceConfig { - name: name.to_string(), - worktrees_dir: "worktrees".to_string(), - projects: vec![], - linked_workspace_items: default_linked_workspace_items(), - vault_linked_workspace_items: vec![], - uat_branch: default_uat_branch(), - archived_worktrees: vec![], - worktree_colors: std::collections::HashMap::new(), - tags: vec![], - }; - save_workspace_config_internal(path, &ws_config)?; - add_workspace_internal(name, path)?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::state::{GLOBAL_CONFIG_CACHE, WINDOW_WORKSPACES, WORKSPACE_CONFIG_CACHE}; - use crate::types::{GlobalConfig, ProjectConfig, WorkspaceConfig, WorkspaceRef}; - use serial_test::serial; - use std::path::{Path, PathBuf}; - use std::time::Duration; - - struct NamedTestLock { - path: PathBuf, - } - - impl NamedTestLock { - fn acquire() -> Self { - let path = std::env::temp_dir().join("worktree-manager-command-test-global-lock"); - loop { - match std::fs::create_dir(&path) { - Ok(()) => return Self { path }, - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { - std::thread::sleep(Duration::from_millis(10)); - } - Err(e) => panic!("failed to acquire test lock at {:?}: {}", path, e), - } - } - } - } - - impl Drop for NamedTestLock { - fn drop(&mut self) { - let _ = std::fs::remove_dir(&self.path); - } - } - - struct WorkspaceCommandStateGuard { - _lock: NamedTestLock, - previous_global: Option, - previous_workspace_cache: Option<(String, WorkspaceConfig)>, - previous_home: Option, - #[cfg(target_os = "windows")] - previous_appdata: Option, - #[cfg(target_os = "windows")] - previous_userprofile: Option, - window_labels: Vec, - _temp_home: tempfile::TempDir, - } - - impl WorkspaceCommandStateGuard { - fn with_global_config(config: GlobalConfig) -> Self { - let lock = NamedTestLock::acquire(); - let temp_home = tempfile::tempdir().expect("create temp config home"); - let previous_home = std::env::var("HOME").ok(); - #[cfg(target_os = "windows")] - let previous_appdata = std::env::var("APPDATA").ok(); - #[cfg(target_os = "windows")] - let previous_userprofile = std::env::var("USERPROFILE").ok(); - - set_config_root(temp_home.path()); - let previous_global = replace_global_cache(Some(config)); - let previous_workspace_cache = replace_workspace_cache(None); - - Self { - _lock: lock, - previous_global, - previous_workspace_cache, - previous_home, - #[cfg(target_os = "windows")] - previous_appdata, - #[cfg(target_os = "windows")] - previous_userprofile, - window_labels: Vec::new(), - _temp_home: temp_home, - } - } - - fn track_window(&mut self, label: &str) { - self.window_labels.push(label.to_string()); - } - } - - impl Drop for WorkspaceCommandStateGuard { - fn drop(&mut self) { - let mut windows = WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - for label in &self.window_labels { - windows.remove(label); - } - drop(windows); - - let _ = replace_workspace_cache(self.previous_workspace_cache.take()); - let _ = replace_global_cache(self.previous_global.take()); - restore_env_var("HOME", &self.previous_home); - #[cfg(target_os = "windows")] - { - restore_env_var("APPDATA", &self.previous_appdata); - restore_env_var("USERPROFILE", &self.previous_userprofile); - } - } - } - - fn replace_global_cache(config: Option) -> Option { - let mut cache = GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::replace(&mut *cache, config) - } - - fn replace_workspace_cache( - config: Option<(String, WorkspaceConfig)>, - ) -> Option<(String, WorkspaceConfig)> { - let mut cache = WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::replace(&mut *cache, config) - } - - fn set_config_root(path: &Path) { - #[cfg(target_os = "windows")] - { - std::env::set_var("APPDATA", path); - std::env::remove_var("USERPROFILE"); - } - #[cfg(not(target_os = "windows"))] - { - std::env::set_var("HOME", path); - } - } - - fn restore_env_var(key: &str, value: &Option) { - match value { - Some(previous) => std::env::set_var(key, previous), - None => std::env::remove_var(key), - } - } - - fn unique_label(name: &str) -> String { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - format!("workspace-test-{}-{}-{nanos}", std::process::id(), name) - } - - fn workspace_ref(name: &str, path: &Path) -> WorkspaceRef { - WorkspaceRef { - name: name.to_string(), - path: path.to_string_lossy().to_string(), - } - } - - #[serial] - #[test] - fn add_workspace_internal_creates_projects_dir_config_and_global_entry() { - let _guard = WorkspaceCommandStateGuard::with_global_config(GlobalConfig::default()); - let config_path = crate::config::get_global_config_path(); - let workspace = tempfile::tempdir().expect("create workspace"); - let workspace_path = workspace.path().to_string_lossy().to_string(); - - add_workspace_internal("Demo", &workspace_path).expect("add workspace"); - let global: GlobalConfig = serde_json::from_str( - &std::fs::read_to_string(config_path).expect("read saved global config"), - ) - .expect("parse saved global config"); - let config = crate::config::load_workspace_config(&workspace_path); - - assert!(workspace.path().join("projects").is_dir()); - assert_eq!(global.workspaces.len(), 1); - assert_eq!(global.workspaces[0].name, "Demo"); - assert_eq!(global.workspaces[0].path, workspace_path); - assert_eq!( - global.current_workspace.as_deref(), - Some(workspace_path.as_str()) - ); - assert_eq!(config.name, "Demo"); - } - - #[serial] - #[test] - fn add_workspace_internal_rejects_missing_path_and_duplicate_path() { - let _guard = WorkspaceCommandStateGuard::with_global_config(GlobalConfig::default()); - let workspace = tempfile::tempdir().expect("create workspace"); - let missing = workspace.path().join("missing"); - let existing = workspace.path().to_string_lossy().to_string(); - - let missing_err = add_workspace_internal("Missing", &missing.to_string_lossy()) - .expect_err("missing path"); - add_workspace_internal("First", &existing).expect("add first workspace"); - let duplicate_err = - add_workspace_internal("Duplicate", &existing).expect_err("duplicate path"); - - assert_eq!(missing_err, "Path does not exist"); - assert_eq!(duplicate_err, "Workspace with this path already exists"); - } - - #[serial] - #[test] - fn remove_workspace_internal_removes_current_and_selects_next_workspace() { - let first = tempfile::tempdir().expect("create first workspace"); - let second = tempfile::tempdir().expect("create second workspace"); - let first_ref = workspace_ref("First", first.path()); - let second_ref = workspace_ref("Second", second.path()); - let config = GlobalConfig { - workspaces: vec![first_ref.clone(), second_ref.clone()], - current_workspace: Some(first_ref.path.clone()), - ..GlobalConfig::default() - }; - let _guard = WorkspaceCommandStateGuard::with_global_config(config); - let config_path = crate::config::get_global_config_path(); - - remove_workspace_internal(&first_ref.path).expect("remove current workspace"); - let global: GlobalConfig = serde_json::from_str( - &std::fs::read_to_string(config_path).expect("read saved global config"), - ) - .expect("parse saved global config"); - - assert_eq!(global.workspaces.len(), 1); - assert_eq!(global.workspaces[0].path, second_ref.path); - assert_eq!( - global.current_workspace.as_deref(), - Some(second_ref.path.as_str()) - ); - } - - #[serial] - #[test] - fn workspace_config_impl_round_trips_for_bound_window() { - let mut guard = WorkspaceCommandStateGuard::with_global_config(GlobalConfig::default()); - let workspace = tempfile::tempdir().expect("create workspace"); - let workspace_path = workspace.path().to_string_lossy().to_string(); - let label = unique_label("round-trip"); - WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert(label.clone(), workspace_path.clone()); - guard.track_window(&label); - let config = WorkspaceConfig { - name: "Configured Workspace".to_string(), - worktrees_dir: "trees".to_string(), - projects: vec![ProjectConfig { - name: "api".to_string(), - base_branch: "main".to_string(), - test_branch: "uat".to_string(), - merge_strategy: "merge".to_string(), - linked_folders: vec!["target".to_string()], - commit_prefix_index: Some(0), - git_user_name: Some("Tester".to_string()), - git_user_email: Some("tester@example.com".to_string()), - tags: vec!["backend".to_string()], - }], - archived_worktrees: vec!["old".to_string()], - ..WorkspaceConfig::default() - }; - - save_workspace_config_impl(&label, config.clone()).expect("save workspace config"); - let _ = replace_workspace_cache(None); - let loaded = get_workspace_config_impl(&label).expect("load workspace config"); - - assert_eq!(loaded.name, "Configured Workspace"); - assert_eq!(loaded.worktrees_dir, "trees"); - assert_eq!(loaded.projects[0].name, "api"); - assert_eq!(loaded.projects[0].linked_folders, vec!["target"]); - assert_eq!(loaded.archived_worktrees, vec!["old"]); - } - - #[serial] - #[test] - fn switch_workspace_impl_binds_window_and_rejects_unknown_workspace() { - let mut guard = WorkspaceCommandStateGuard::with_global_config(GlobalConfig::default()); - let config_path = crate::config::get_global_config_path(); - let workspace = tempfile::tempdir().expect("create workspace"); - let workspace_ref = workspace_ref("Switch", workspace.path()); - let _ = replace_global_cache(Some(GlobalConfig { - workspaces: vec![workspace_ref.clone()], - current_workspace: None, - ..GlobalConfig::default() - })); - let label = unique_label("switch"); - guard.track_window(&label); - - switch_workspace_impl(&label, workspace_ref.path.clone()).expect("switch workspace"); - let bound = WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .get(&label) - .cloned() - .expect("window binding"); - let saved: GlobalConfig = serde_json::from_str( - &std::fs::read_to_string(config_path).expect("read saved global config"), - ) - .expect("parse saved global config"); - let missing_err = - switch_workspace_impl(&label, "/definitely/missing".to_string()).unwrap_err(); - - assert_eq!(bound, workspace_ref.path); - assert_eq!( - saved.current_workspace.as_deref(), - Some(workspace_ref.path.as_str()) - ); - assert_eq!(missing_err, "Workspace not found"); - } - - #[serial] - #[test] - fn get_config_path_info_uses_bound_workspace_before_global_config_path() { - let mut guard = WorkspaceCommandStateGuard::with_global_config(GlobalConfig::default()); - let workspace = tempfile::tempdir().expect("create workspace"); - let workspace_path = workspace.path().to_string_lossy().to_string(); - let label = unique_label("path-info"); - WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert(label.clone(), workspace_path.clone()); - guard.track_window(&label); - - let bound_path = get_config_path_info_impl(&label); - let fallback_path = get_config_path_info_impl("unbound-window-label"); - - assert_eq!( - bound_path, - normalize_path( - &crate::config::get_workspace_config_path(&workspace_path).to_string_lossy() - ) - ); - assert_eq!( - fallback_path, - normalize_path(&crate::config::get_global_config_path().to_string_lossy()) - ); - } -} - -#[cfg(test)] -mod coverage_completion_tests { - use super::*; - use crate::state::{GLOBAL_CONFIG_CACHE, WINDOW_WORKSPACES, WORKSPACE_CONFIG_CACHE}; - use crate::types::{GlobalConfig, WorkspaceConfig, WorkspaceRef}; - use serial_test::serial; - use std::path::{Path, PathBuf}; - use std::time::Duration; - - struct NamedTestLock { - path: PathBuf, - } - - impl NamedTestLock { - fn acquire() -> Self { - let path = std::env::temp_dir().join("worktree-manager-command-test-global-lock"); - loop { - match std::fs::create_dir(&path) { - Ok(()) => return Self { path }, - Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { - std::thread::sleep(Duration::from_millis(10)); - } - Err(err) => panic!("failed to acquire test lock {:?}: {}", path, err), - } - } - } - } - - impl Drop for NamedTestLock { - fn drop(&mut self) { - let _ = std::fs::remove_dir(&self.path); - } - } - - struct StateGuard { - _lock: NamedTestLock, - previous_global: Option, - previous_workspace_cache: Option<(String, WorkspaceConfig)>, - previous_windows: std::collections::HashMap, - previous_home: Option, - #[cfg(target_os = "windows")] - previous_appdata: Option, - #[cfg(target_os = "windows")] - previous_userprofile: Option, - _temp_home: tempfile::TempDir, - } - - impl StateGuard { - fn with_global(config: GlobalConfig) -> Self { - let lock = NamedTestLock::acquire(); - let temp_home = tempfile::tempdir().expect("create temp workspace config home"); - let previous_home = std::env::var("HOME").ok(); - #[cfg(target_os = "windows")] - let previous_appdata = std::env::var("APPDATA").ok(); - #[cfg(target_os = "windows")] - let previous_userprofile = std::env::var("USERPROFILE").ok(); - set_config_root(temp_home.path()); - - let previous_global = { - let mut cache = GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::replace(&mut *cache, Some(config)) - }; - let previous_workspace_cache = { - let mut cache = WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *cache) - }; - let previous_windows = { - let mut windows = WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *windows) - }; - - Self { - _lock: lock, - previous_global, - previous_workspace_cache, - previous_windows, - previous_home, - #[cfg(target_os = "windows")] - previous_appdata, - #[cfg(target_os = "windows")] - previous_userprofile, - _temp_home: temp_home, - } - } - } - - impl Drop for StateGuard { - fn drop(&mut self) { - let mut windows = WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *windows = std::mem::take(&mut self.previous_windows); - drop(windows); - - let mut workspace_cache = WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *workspace_cache = self.previous_workspace_cache.take(); - drop(workspace_cache); - - let mut global_cache = GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *global_cache = self.previous_global.take(); - - restore_env_var("HOME", &self.previous_home); - #[cfg(target_os = "windows")] - { - restore_env_var("APPDATA", &self.previous_appdata); - restore_env_var("USERPROFILE", &self.previous_userprofile); - } - } - } - - fn set_config_root(path: &Path) { - #[cfg(target_os = "windows")] - { - std::env::set_var("APPDATA", path); - std::env::remove_var("USERPROFILE"); - } - #[cfg(not(target_os = "windows"))] - { - std::env::set_var("HOME", path); - } - } - - fn restore_env_var(key: &str, value: &Option) { - match value { - Some(previous) => std::env::set_var(key, previous), - None => std::env::remove_var(key), - } - } - - fn workspace_ref(name: &str, path: &Path) -> WorkspaceRef { - WorkspaceRef { - name: name.to_string(), - path: path.to_string_lossy().to_string(), - } - } - - #[serial] - #[test] - fn workspace_commands_create_list_update_switch_and_delete_round_trip() { - let _guard = StateGuard::with_global(GlobalConfig::default()); - let temp = tempfile::tempdir().expect("create parent directory"); - let workspace_path = temp - .path() - .join("created-workspace") - .to_string_lossy() - .to_string(); - - create_workspace("Created".to_string(), workspace_path.clone()).expect("create workspace"); - let listed = list_workspaces(); - let loaded = load_workspace_config_by_path(workspace_path.clone()).expect("load config"); - - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].name, "Created"); - assert_eq!(listed[0].path, normalize_path(&workspace_path)); - assert!(PathBuf::from(&workspace_path).join("projects").is_dir()); - assert!(PathBuf::from(&workspace_path).join("worktrees").is_dir()); - assert_eq!(loaded.name, "Created"); - - let updated = WorkspaceConfig { - name: "Updated".to_string(), - worktrees_dir: "trees".to_string(), - ..WorkspaceConfig::default() - }; - save_workspace_config_by_path(workspace_path.clone(), updated).expect("save config"); - let reloaded = load_workspace_config_by_path(workspace_path.clone()).expect("reload"); - assert_eq!(reloaded.name, "Updated"); - assert_eq!(reloaded.worktrees_dir, "trees"); - - switch_workspace_impl("workspace-extra-window", workspace_path.clone()) - .expect("switch workspace"); - assert_eq!( - get_current_workspace_impl("workspace-extra-window") - .expect("current workspace") - .name, - "Created" - ); - - remove_workspace(workspace_path.clone()).expect("remove workspace"); - assert!(list_workspaces().is_empty()); - assert!(get_current_workspace_impl("workspace-extra-window").is_none()); - } - - #[serial] - #[test] - fn workspace_path_resolution_errors_are_specific() { - let _guard = StateGuard::with_global(GlobalConfig::default()); - let missing = tempfile::tempdir() - .expect("create parent") - .path() - .join("missing") - .to_string_lossy() - .to_string(); - - assert_eq!( - get_workspace_config_impl("unbound-window").unwrap_err(), - "No workspace selected" - ); - assert_eq!( - save_workspace_config_impl("unbound-window", WorkspaceConfig::default()).unwrap_err(), - "No workspace selected" - ); - assert_eq!( - add_workspace("Missing".to_string(), missing).unwrap_err(), - "Path does not exist" - ); - assert_eq!( - switch_workspace_impl("unbound-window", "/not/registered".to_string()).unwrap_err(), - "Workspace not found" - ); - } - - #[serial] - #[test] - fn current_workspace_impl_uses_bound_window_before_global_fallback() { - let first = tempfile::tempdir().expect("create first workspace"); - let second = tempfile::tempdir().expect("create second workspace"); - let first_ref = workspace_ref("First", first.path()); - let second_ref = workspace_ref("Second", second.path()); - let _guard = StateGuard::with_global(GlobalConfig { - workspaces: vec![first_ref.clone(), second_ref.clone()], - current_workspace: Some(first_ref.path.clone()), - ..GlobalConfig::default() - }); - - WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert("bound-window".to_string(), second_ref.path.clone()); - - let bound = get_current_workspace_impl("bound-window").expect("bound workspace"); - let fallback = get_current_workspace_impl("unbound-window").expect("fallback workspace"); - - assert_eq!(bound.name, "Second"); - assert_eq!(bound.path, normalize_path(&second_ref.path)); - assert_eq!(fallback.name, "First"); - assert_eq!(fallback.path, normalize_path(&first_ref.path)); - } -} diff --git a/src-tauri/src/commands/worktree.rs b/src-tauri/src/commands/worktree.rs deleted file mode 100644 index cb00b4c..0000000 --- a/src-tauri/src/commands/worktree.rs +++ /dev/null @@ -1,3893 +0,0 @@ -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; - -use crate::commands::window::broadcast_lock_state; -use crate::config::{ - clear_occupation_state, get_window_workspace_config, load_occupation_state, - save_occupation_state, save_workspace_config_internal, -}; -use crate::git_ops::{get_branch_status, get_worktree_info_for_branches}; -use crate::state::{PTY_MANAGER, WINDOW_WORKSPACES}; -use crate::types::{ - AddProjectToWorktreeRequest, CreateProjectRequest, CreateWorktreeRequest, DeployProjectError, - DeployToMainResult, LockedProcessInfo, MainProjectStatus, MainWorkspaceOccupation, - MainWorkspaceStatus, ProjectConfig, ProjectStatus, ScannedFolder, WorktreeArchiveStatus, - WorktreeListItem, -}; -use crate::utils::{ - friendly_fs_error, git_command, mask_url_credentials, normalize_path, - run_git_command_with_timeout, scan_dir_for_linkable_folders, validate_git_ref_name, -}; - -/// Cross-platform symlink creation. -/// On Unix: uses std::os::unix::fs::symlink. -/// On Windows: uses symlink_dir for directories, symlink_file for files. -/// Falls back to junction for directories if symlink fails (no admin/dev mode). -pub(crate) fn create_symlink(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { - #[cfg(unix)] - { - std::os::unix::fs::symlink(src, dst) - } - #[cfg(windows)] - { - if src.is_dir() { - // Try symlink_dir first (requires admin or developer mode) - match std::os::windows::fs::symlink_dir(src, dst) { - Ok(()) => Ok(()), - Err(_) => { - // Fallback: use junction (works without admin rights) - let status = std::process::Command::new("cmd") - .args(["/c", "mklink", "/J"]) - .arg(dst.as_os_str()) - .arg(src.as_os_str()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map_err(std::io::Error::other)?; - if status.success() { - Ok(()) - } else { - Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "Failed to create junction", - )) - } - } - } - } else { - std::os::windows::fs::symlink_file(src, dst) - } - } -} - -// ==================== Tauri 命令:Worktree 操作 ==================== - -#[cfg(target_os = "windows")] -const LOCK_CHECK_MAX_RESOURCES: usize = 4096; -#[cfg(target_os = "windows")] -const LOCK_CHECK_BATCH_SIZE: usize = 128; - -#[cfg(target_os = "windows")] -fn wide_string(value: &std::ffi::OsStr) -> Vec { - use std::os::windows::ffi::OsStrExt; - - value.encode_wide().chain(std::iter::once(0)).collect() -} - -#[cfg(target_os = "windows")] -fn fixed_wide_to_string(value: &[u16]) -> String { - let end = value.iter().position(|ch| *ch == 0).unwrap_or(value.len()); - String::from_utf16_lossy(&value[..end]) -} - -#[cfg(target_os = "windows")] -fn rm_app_type_name(value: i32) -> String { - use windows_sys::Win32::System::RestartManager::{ - RmConsole, RmCritical, RmExplorer, RmMainWindow, RmOtherWindow, RmService, - }; - - match value { - RmMainWindow => "main_window", - RmOtherWindow => "other_window", - RmService => "service", - RmExplorer => "explorer", - RmConsole => "console", - RmCritical => "critical", - _ => "unknown", - } - .to_string() -} - -#[cfg(target_os = "windows")] -fn collect_lock_check_resources(root: &Path) -> Vec { - let mut resources = Vec::new(); - let mut stack = vec![root.to_path_buf()]; - - while let Some(path) = stack.pop() { - if resources.len() >= LOCK_CHECK_MAX_RESOURCES { - break; - } - - resources.push(path.clone()); - let Ok(entries) = fs::read_dir(&path) else { - continue; - }; - - for entry in entries.flatten() { - if resources.len() >= LOCK_CHECK_MAX_RESOURCES { - break; - } - - let child = entry.path(); - let Ok(metadata) = fs::symlink_metadata(&child) else { - continue; - }; - use std::os::windows::fs::MetadataExt; - const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; - let is_reparse_point = metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0; - - resources.push(child.clone()); - if metadata.is_dir() && !is_reparse_point { - stack.push(child); - } - } - } - - resources -} - -#[cfg(target_os = "windows")] -fn filetime_to_string(value: windows_sys::Win32::Foundation::FILETIME) -> String { - (((value.dwHighDateTime as u64) << 32) | value.dwLowDateTime as u64).to_string() -} - -#[cfg(target_os = "windows")] -fn query_restart_manager(paths: &[PathBuf]) -> Result, String> { - use windows_sys::Win32::Foundation::{ERROR_ACCESS_DENIED, ERROR_MORE_DATA, ERROR_SUCCESS}; - use windows_sys::Win32::System::RestartManager::{ - RmEndSession, RmGetList, RmRegisterResources, RmStartSession, CCH_RM_SESSION_KEY, - RM_PROCESS_INFO, - }; - - let mut session = 0u32; - let mut session_key = vec![0u16; (CCH_RM_SESSION_KEY + 1) as usize]; - let start_result = unsafe { RmStartSession(&mut session, 0, session_key.as_mut_ptr()) }; - if start_result == ERROR_ACCESS_DENIED { - log::warn!("Restart Manager RmStartSession returned ACCESS_DENIED – skipping lock check"); - return Ok(Vec::new()); - } - if start_result != ERROR_SUCCESS { - return Err(format!("Restart Manager start failed: {}", start_result)); - } - - let result = (|| { - let wide_paths: Vec> = paths - .iter() - .map(|path| wide_string(path.as_os_str())) - .collect(); - let path_ptrs: Vec<*const u16> = wide_paths.iter().map(|path| path.as_ptr()).collect(); - - let register_result = unsafe { - RmRegisterResources( - session, - path_ptrs.len() as u32, - path_ptrs.as_ptr(), - 0, - std::ptr::null(), - 0, - std::ptr::null(), - ) - }; - if register_result == ERROR_ACCESS_DENIED { - log::warn!("Restart Manager RmRegisterResources returned ACCESS_DENIED – skipping"); - return Ok(Vec::new()); - } - if register_result != ERROR_SUCCESS { - return Err(format!( - "Restart Manager resource registration failed: {}", - register_result - )); - } - - let mut needed = 0u32; - let mut count = 0u32; - let mut reboot_reasons = 0u32; - let first_result = unsafe { - RmGetList( - session, - &mut needed, - &mut count, - std::ptr::null_mut(), - &mut reboot_reasons, - ) - }; - if first_result == ERROR_SUCCESS && needed == 0 { - return Ok(Vec::new()); - } - if first_result == ERROR_ACCESS_DENIED { - // Insufficient privileges to query locked processes (e.g. files held by - // elevated or system processes). Treat as "no known locks" so that - // archiving is not blocked by the check itself. - log::warn!( - "Restart Manager RmGetList returned ACCESS_DENIED – skipping lock check for this batch" - ); - return Ok(Vec::new()); - } - if first_result != ERROR_MORE_DATA && first_result != ERROR_SUCCESS { - return Err(format!( - "Restart Manager process query failed: {}", - first_result - )); - } - - // windows-sys does not derive Default for RM_PROCESS_INFO; use zeroed() instead. - let mut processes: Vec = (0..needed as usize) - .map(|_| unsafe { std::mem::zeroed() }) - .collect(); - count = needed; - let second_result = unsafe { - RmGetList( - session, - &mut needed, - &mut count, - processes.as_mut_ptr(), - &mut reboot_reasons, - ) - }; - if second_result == ERROR_ACCESS_DENIED { - log::warn!("Restart Manager RmGetList (2nd call) returned ACCESS_DENIED – skipping"); - return Ok(Vec::new()); - } - if second_result != ERROR_SUCCESS { - return Err(format!( - "Restart Manager process query failed: {}", - second_result - )); - } - - processes.truncate(count as usize); - Ok(processes - .into_iter() - .map(|process| { - let app_name = fixed_wide_to_string(&process.strAppName); - let service_name = fixed_wide_to_string(&process.strServiceShortName); - LockedProcessInfo { - pid: process.Process.dwProcessId, - process_start_time: filetime_to_string(process.Process.ProcessStartTime), - name: if app_name.is_empty() { - service_name - } else { - app_name - }, - application_type: rm_app_type_name(process.ApplicationType), - restartable: process.bRestartable != 0, - } - }) - .collect()) - })(); - - unsafe { - RmEndSession(session); - } - - result -} - -#[cfg(target_os = "windows")] -pub fn find_worktree_locking_processes(path: &Path) -> Result, String> { - let resources = collect_lock_check_resources(path); - let mut by_pid: HashMap = HashMap::new(); - - for batch in resources.chunks(LOCK_CHECK_BATCH_SIZE) { - let processes = query_restart_manager(batch)?; - for mut process in processes { - if process.name.is_empty() { - process.name = format!("PID {}", process.pid); - } - by_pid.entry(process.pid).or_insert(process); - } - } - - let current_pid = std::process::id(); - let mut result: Vec = by_pid - .into_values() - .filter(|process| process.pid != current_pid) - .collect(); - result.sort_by(|a, b| a.name.cmp(&b.name).then(a.pid.cmp(&b.pid))); - Ok(result) -} - -#[cfg(not(target_os = "windows"))] -pub fn find_worktree_locking_processes(_path: &Path) -> Result, String> { - Ok(Vec::new()) -} - -#[cfg(target_os = "windows")] -fn probe_windows_rename(path: &Path) -> Result<(), String> { - let parent = path - .parent() - .ok_or_else(|| "Worktree path has no parent".to_string())?; - let name = path - .file_name() - .and_then(|value| value.to_str()) - .ok_or_else(|| "Invalid worktree path".to_string())?; - let probe_path = parent.join(format!( - ".{}.archive-lock-check-{}", - name, - std::process::id() - )); - - match fs::remove_dir_all(&probe_path) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => { - return Err(friendly_fs_error("无法清理归档检查的临时目录", &e)); - } - } - - fs::rename(path, &probe_path).map_err(|e| { - friendly_fs_error("Worktree 正在被占用,无法归档。请关闭相关程序后重试", &e) - })?; - if let Err(e) = fs::rename(&probe_path, path) { - return Err(format!( - "归档检查后恢复目录失败:{}。\n请手动将 '{}' 重命名为 '{}'", - crate::utils::friendly_io_error(&e), - probe_path.display(), - path.display() - )); - } - Ok(()) -} - -#[cfg(target_os = "windows")] -fn ensure_windows_archive_file_usage_clear(path: &Path) -> Result<(), String> { - let locking_processes = find_worktree_locking_processes(path)?; - if !locking_processes.is_empty() { - let names = locking_processes - .iter() - .map(|process| format!("{} (PID {})", process.name, process.pid)) - .collect::>() - .join(", "); - return Err(format!( - "Worktree files are currently in use. End these processes before archiving: {}", - names - )); - } - - probe_windows_rename(path) -} - -#[cfg(not(target_os = "windows"))] -fn ensure_windows_archive_file_usage_clear(_path: &Path) -> Result<(), String> { - Ok(()) -} - -pub fn terminate_worktree_locking_process_impl( - window_label: &str, - name: String, - pid: u32, - process_start_time: String, -) -> Result<(), String> { - if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") { - return Err("Invalid worktree name".to_string()); - } - - let (workspace_path, config) = - get_window_workspace_config(window_label).ok_or("No workspace selected")?; - - let worktree_path = PathBuf::from(&workspace_path) - .join(&config.worktrees_dir) - .join(&name); - - if !worktree_path.exists() { - return Err("Worktree does not exist".to_string()); - } - - let locking_processes = find_worktree_locking_processes(&worktree_path)?; - let is_current_blocker = locking_processes - .iter() - .any(|process| process.pid == pid && process.process_start_time == process_start_time); - - if !is_current_blocker { - return Err("Process is no longer locking this worktree".to_string()); - } - - crate::commands::system::terminate_process_impl(pid) -} - -#[tauri::command] -pub(crate) async fn terminate_worktree_locking_process( - window: tauri::Window, - name: String, - pid: u32, - process_start_time: String, -) -> Result<(), String> { - let label = window.label().to_string(); - tokio::task::spawn_blocking(move || { - terminate_worktree_locking_process_impl(&label, name, pid, process_start_time) - }) - .await - .map_err(|e| format!("Task join error: {}", e))? -} - -pub fn list_worktrees_impl( - window_label: &str, - include_archived: bool, -) -> Result, String> { - let start = std::time::Instant::now(); - let (workspace_path, config) = - get_window_workspace_config(window_label).ok_or("No workspace selected")?; - - let worktrees_path = PathBuf::from(&workspace_path).join(&config.worktrees_dir); - - if !worktrees_path.exists() { - return Ok(vec![]); - } - - let result = scan_worktrees_dir(&worktrees_path, &config, include_archived); - log::info!("list_worktrees took {:?}", start.elapsed()); - result -} - -#[tauri::command] -pub(crate) async fn list_worktrees( - window: tauri::Window, - include_archived: bool, - workspace_path: Option, -) -> Result, String> { - if let Some(path) = workspace_path { - let config = crate::config::load_workspace_config(&path); - tokio::task::spawn_blocking(move || { - let worktrees_path = std::path::PathBuf::from(&path).join(&config.worktrees_dir); - if !worktrees_path.exists() { - return Ok(vec![]); - } - scan_worktrees_dir(&worktrees_path, &config, include_archived) - }) - .await - .map_err(|e| format!("Task join error: {}", e))? - } else { - let label = window.label().to_string(); - tokio::task::spawn_blocking(move || list_worktrees_impl(&label, include_archived)) - .await - .map_err(|e| format!("Task join error: {}", e))? - } -} - -pub fn update_worktree_color_impl( - window_label: &str, - worktree_name: String, - color: Option, -) -> Result<(), String> { - let (_workspace_path, mut config) = - crate::config::get_window_workspace_config(window_label).ok_or("No workspace selected")?; - - match color { - Some(c) => config.worktree_colors.insert(worktree_name, c), - None => config.worktree_colors.remove(&worktree_name), - }; - - crate::commands::workspace::save_workspace_config_impl(window_label, config) -} - -#[tauri::command] -pub(crate) async fn update_worktree_color( - window: tauri::Window, - worktree_name: String, - color: Option, - workspace_path: Option, -) -> Result<(), String> { - if let Some(path) = workspace_path { - let mut config = crate::config::load_workspace_config(&path); - match color { - Some(c) => config.worktree_colors.insert(worktree_name, c), - None => config.worktree_colors.remove(&worktree_name), - }; - crate::commands::workspace::save_workspace_config_by_path(path, config) - } else { - let label = window.label().to_string(); - tokio::task::spawn_blocking(move || { - update_worktree_color_impl(&label, worktree_name, color) - }) - .await - .map_err(|e| format!("Task join error: {}", e))? - } -} - -/// Load worktree folder-name → display-name mapping from mapping.json -fn load_worktree_mapping(mapping_path: &std::path::Path) -> HashMap { - if let Ok(content) = std::fs::read_to_string(mapping_path) { - serde_json::from_str(&content).unwrap_or_default() - } else { - HashMap::new() - } -} - -/// Save worktree folder-name → display-name mapping to mapping.json -fn save_worktree_mapping(mapping_path: &std::path::Path, mapping: &HashMap) { - if let Ok(json) = serde_json::to_string_pretty(mapping) { - if let Err(e) = std::fs::write(mapping_path, json) { - log::warn!("[worktree] Failed to save mapping.json: {}", e); - } - } -} - -fn scan_worktrees_dir( - dir: &PathBuf, - config: &crate::types::WorkspaceConfig, - include_archived: bool, -) -> Result, String> { - let mut result = vec![]; - - // Load display name mapping - let mapping_path = dir.join("mapping.json"); - let mapping = load_worktree_mapping(&mapping_path); - - let entries = std::fs::read_dir(dir).map_err(|e| friendly_fs_error("无法读取目录", &e))?; - - for entry in entries { - let entry = entry.map_err(|e| friendly_fs_error("无法读取目录项", &e))?; - let path = entry.path(); - - if !path.is_dir() { - continue; - } - - let name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_string(); - - if name.starts_with('.') { - continue; - } - - let is_archived = config.archived_worktrees.contains(&name); - - if is_archived && !include_archived { - continue; - } - - let projects_path = path.join("projects"); - let mut projects = vec![]; - - if !projects_path.exists() || !projects_path.is_dir() { - continue; - } - - if let Ok(proj_entries) = std::fs::read_dir(&projects_path) { - for proj_entry in proj_entries.flatten() { - let proj_path = proj_entry.path(); - if !proj_path.is_dir() { - continue; - } - - let proj_name = proj_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_string(); - - let proj_config = config - .projects - .iter() - .find(|p| p.name == proj_name) - .cloned() - .unwrap_or(ProjectConfig { - name: proj_name.clone(), - base_branch: "uat".to_string(), - test_branch: "test".to_string(), - merge_strategy: "merge".to_string(), - linked_folders: vec![], - commit_prefix_index: None, - git_user_name: None, - git_user_email: None, - tags: vec![], - }); - - let info = get_worktree_info_for_branches( - &proj_path, - &proj_config.base_branch, - &proj_config.test_branch, - ); - - projects.push(ProjectStatus { - name: proj_name, - path: normalize_path(&proj_path.to_string_lossy()), - current_branch: info.current_branch, - base_branch: proj_config.base_branch, - test_branch: proj_config.test_branch, - has_uncommitted: info.uncommitted_count > 0, - uncommitted_count: info.uncommitted_count, - is_merged_to_test: info.is_merged_to_test, - is_merged_to_base: info.is_merged_to_base, - ahead_of_base: info.ahead_of_base, - behind_base: info.behind_base, - ahead_of_test: info.ahead_of_test, - unpushed_commits: info.unpushed_commits, - remote_url: info.remote_url, - }); - } - } - - // Look up display name from mapping - let lookup_key = &name; - let display_name = mapping.get(lookup_key).cloned(); - - result.push(WorktreeListItem { - name: name.clone(), - display_name, - path: normalize_path(&path.to_string_lossy()), - is_archived, - color: config.worktree_colors.get(&name).cloned(), - projects, - }); - } - - Ok(result) -} - -fn get_main_workspace_status_by_path( - workspace_path: &str, - config: &crate::types::WorkspaceConfig, -) -> Result { - let start = std::time::Instant::now(); - let root_path = PathBuf::from(workspace_path); - let projects_path = root_path.join("projects"); - - let mut projects = vec![]; - - for proj_config in &config.projects { - let proj_path = projects_path.join(&proj_config.name); - if !proj_path.exists() { - continue; - } - - let info = get_worktree_info_for_branches( - &proj_path, - &proj_config.base_branch, - &proj_config.test_branch, - ); - - projects.push(MainProjectStatus { - name: proj_config.name.clone(), - path: normalize_path(&proj_path.to_string_lossy()), - current_branch: info.current_branch, - has_uncommitted: info.uncommitted_count > 0, - uncommitted_count: info.uncommitted_count, - is_merged_to_test: info.is_merged_to_test, - is_merged_to_base: info.is_merged_to_base, - ahead_of_base: info.ahead_of_base, - behind_base: info.behind_base, - ahead_of_test: info.ahead_of_test, - unpushed_commits: info.unpushed_commits, - base_branch: proj_config.base_branch.clone(), - test_branch: proj_config.test_branch.clone(), - linked_folders: proj_config.linked_folders.clone(), - }); - } - - let result = MainWorkspaceStatus { - path: normalize_path(&root_path.to_string_lossy()), - name: config.name.clone(), - projects, - }; - log::info!("get_main_workspace_status took {:?}", start.elapsed()); - Ok(result) -} - -pub fn get_main_workspace_status_impl(window_label: &str) -> Result { - let (workspace_path, config) = - get_window_workspace_config(window_label).ok_or("No workspace selected")?; - get_main_workspace_status_by_path(&workspace_path, &config) -} - -#[tauri::command] -pub(crate) async fn get_main_workspace_status( - window: tauri::Window, - workspace_path: Option, -) -> Result { - if let Some(path) = workspace_path { - tokio::task::spawn_blocking(move || { - let config = crate::config::load_workspace_config(&path); - get_main_workspace_status_by_path(&path, &config) - }) - .await - .map_err(|e| format!("Task join error: {}", e))? - } else { - let label = window.label().to_string(); - tokio::task::spawn_blocking(move || get_main_workspace_status_impl(&label)) - .await - .map_err(|e| format!("Task join error: {}", e))? - } -} - -/// Set up a single project inside a worktree: fetch → branch check → worktree add → symlink. -fn setup_project_worktree( - root: &std::path::Path, - worktree_path: &std::path::Path, - worktree_name: &str, - proj_req: &CreateProjectRequest, - proj_config: &ProjectConfig, -) -> Result<(), String> { - validate_git_ref_name(worktree_name)?; - validate_git_ref_name(&proj_req.base_branch)?; - - let main_proj_path = root.join("projects").join(&proj_req.name); - let wt_proj_path = worktree_path.join("projects").join(&proj_req.name); - - // Fetch origin first (with timeout) - log::info!("[worktree] Project '{}': git fetch origin", proj_req.name); - run_git_command_with_timeout( - &["fetch", "origin"], - main_proj_path.to_string_lossy().as_ref(), - )?; - - // Check if branch already exists - let branch_check = git_command() - .args([ - "-C", - main_proj_path.to_string_lossy().as_ref(), - "branch", - "--list", - worktree_name, - ]) - .output(); - - let branch_exists = branch_check - .as_ref() - .map(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty()) - .unwrap_or(false); - - // Check if the same-named remote branch already exists (after fetch, so tracking refs are current). - let remote_branch_exists = if !branch_exists { - crate::git_ops::check_remote_branch_exists(&main_proj_path, worktree_name).unwrap_or(false) - } else { - false - }; - - // Create worktree: use existing local branch, track existing remote branch, or create from base. - let output = if branch_exists { - log::info!( - "Branch '{}' already exists locally, using it for project {}", - worktree_name, - proj_req.name - ); - git_command() - .args([ - "-C", - main_proj_path.to_string_lossy().as_ref(), - "worktree", - "add", - wt_proj_path.to_string_lossy().as_ref(), - worktree_name, - ]) - .output() - .map_err(|e| friendly_fs_error("创建 Worktree 失败", &e))? - } else if remote_branch_exists { - log::info!( - "Remote branch 'origin/{}' already exists, tracking it for project {}", - worktree_name, - proj_req.name - ); - git_command() - .args([ - "-C", - main_proj_path.to_string_lossy().as_ref(), - "worktree", - "add", - "--track", - "-b", - worktree_name, - wt_proj_path.to_string_lossy().as_ref(), - &format!("origin/{}", worktree_name), - ]) - .output() - .map_err(|e| friendly_fs_error("创建 Worktree 失败", &e))? - } else { - log::info!( - "Creating new branch '{}' for project {} from origin/{}", - worktree_name, - proj_req.name, - proj_req.base_branch - ); - git_command() - .args([ - "-C", - main_proj_path.to_string_lossy().as_ref(), - "worktree", - "add", - wt_proj_path.to_string_lossy().as_ref(), - "-b", - worktree_name, - &format!("origin/{}", proj_req.base_branch), - ]) - .output() - .map_err(|e| friendly_fs_error("创建 Worktree 失败", &e))? - }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let stderr_for_log = mask_url_credentials(&stderr); - log::error!( - "[worktree] FAILED: git worktree add for project '{}': {}", - proj_req.name, - stderr_for_log - ); - return Err(format!( - "Failed to create worktree for {}: {}", - proj_req.name, stderr - )); - } - log::info!( - "[worktree] Project '{}': git worktree add succeeded", - proj_req.name - ); - - // When tracking an existing remote branch, --track already set the upstream. - // Only push (to create or update the remote branch) for new or locally-existing branches. - if !remote_branch_exists { - log::info!( - "[worktree] Project '{}': git push -u origin {}", - proj_req.name, - worktree_name - ); - let push_output = run_git_command_with_timeout( - &["push", "-u", "origin", worktree_name], - wt_proj_path.to_string_lossy().as_ref(), - ); - - match push_output { - Ok(output) if output.status.success() => { - log::info!( - "[worktree] Project '{}': git push -u origin {} succeeded", - proj_req.name, - worktree_name - ); - } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr); - let stderr_for_log = mask_url_credentials(&stderr); - log::warn!( - "[worktree] Project '{}': git push -u origin {} failed (worktree created successfully): {}", - proj_req.name, - worktree_name, - stderr_for_log - ); - } - Err(e) => { - log::warn!( - "[worktree] Project '{}': git push -u origin {} failed to execute (worktree created successfully): {}", - proj_req.name, - worktree_name, - e - ); - } - } - } else { - log::info!( - "[worktree] Project '{}': tracking origin/{}, skipping push", - proj_req.name, - worktree_name - ); - } - - // Link configured folders - log::info!( - "[worktree] Project '{}': Creating symlinks for {} linked folders", - proj_req.name, - proj_config.linked_folders.len() - ); - for folder_name in &proj_config.linked_folders { - let main_folder = main_proj_path.join(folder_name); - let wt_folder = wt_proj_path.join(folder_name); - - if main_folder.exists() && !wt_folder.exists() { - create_symlink(&main_folder, &wt_folder).ok(); - - // Remove from git index if it's tracked - git_command() - .args([ - "-C", - wt_proj_path.to_string_lossy().as_ref(), - "rm", - "--cached", - "-r", - folder_name, - ]) - .output() - .ok(); - } - } - - Ok(()) -} - -pub fn create_worktree_impl( - window_label: &str, - request: CreateWorktreeRequest, -) -> Result { - validate_git_ref_name(&request.name)?; - // 目录名同样必须校验:folder_name 会拼进磁盘路径,未校验则 `../` 可逃逸出 worktrees 目录。 - if let Some(folder) = &request.folder_name { - validate_git_ref_name(folder)?; - } - for project in &request.projects { - validate_git_ref_name(&project.base_branch)?; - } - - let workspace_path = - crate::config::get_window_workspace_path(window_label).ok_or("No workspace selected")?; - - // 串行化同一 workspace 的生命周期操作,防止并发同名创建留下半注册目录等竞态。 - let lifecycle_lock = crate::state::workspace_lifecycle_lock(&workspace_path); - let _lifecycle_guard = lifecycle_lock - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - // 锁内读取最新配置。 - let config = crate::config::load_workspace_config(&workspace_path); - - let root = PathBuf::from(&workspace_path); - - // Use folder_name for the directory if provided, otherwise use name - let actual_folder_name = request.folder_name.as_deref().unwrap_or(&request.name); - let worktree_path = root.join(&config.worktrees_dir).join(actual_folder_name); - - let project_count = request.projects.len(); - log::info!( - "[worktree] Creating worktree '{}' (folder: '{}') in workspace '{}' with {} projects (parallel)", - request.name, - actual_folder_name, - workspace_path, - project_count - ); - - // Step 1: Create worktree directory - log::info!( - "[worktree] Step 1: Creating directory structure at {}", - worktree_path.display() - ); - std::fs::create_dir_all(worktree_path.join("projects")) - .map_err(|e| friendly_fs_error("无法创建 Worktree 目录", &e))?; - - // Step 2: Create symlinks for workspace-level items (fast, sequential) - // Merge linked_workspace_items + vault_linked_workspace_items, deduplicated - let mut all_linked: Vec = config.linked_workspace_items.clone(); - for item in &config.vault_linked_workspace_items { - if !all_linked.contains(item) { - all_linked.push(item.clone()); - } - } - log::info!( - "[worktree] Step 2: Creating workspace-level symlinks ({} items)", - all_linked.len() - ); - for name in &all_linked { - let src = root.join(name); - let dst = worktree_path.join(name); - if src.exists() && !dst.exists() { - #[allow(unused_variables)] - let link_result = create_symlink(&src, &dst); - log::debug!( - "[worktree] Linked workspace item: {} (result: {:?})", - name, - link_result - ); - } - } - - // Step 3: Set up each project in parallel - log::info!( - "[worktree] Step 3: Setting up {} projects in parallel", - project_count - ); - - // Pre-resolve project configs - let proj_configs: Vec<_> = request - .projects - .iter() - .map(|proj_req| { - config - .projects - .iter() - .find(|p| p.name == proj_req.name) - .cloned() - .unwrap_or(ProjectConfig { - name: proj_req.name.clone(), - base_branch: proj_req.base_branch.clone(), - test_branch: "test".to_string(), - merge_strategy: "merge".to_string(), - linked_folders: vec![], - commit_prefix_index: None, - git_user_name: None, - git_user_email: None, - tags: vec![], - }) - }) - .collect(); - - // Use scoped threads for parallel execution - let errors: Vec = std::thread::scope(|s| { - let handles: Vec<_> = request - .projects - .iter() - .zip(proj_configs.iter()) - .map(|(proj_req, proj_config)| { - s.spawn(|| { - setup_project_worktree( - &root, - &worktree_path, - &request.name, - proj_req, - proj_config, - ) - }) - }) - .collect(); - - handles - .into_iter() - .filter_map(|h| h.join().ok().and_then(|r| r.err())) - .collect() - }); - - if !errors.is_empty() { - return Err(errors.join("\n")); - } - - // Save display name mapping if folder_name differs from name - if request.folder_name.is_some() { - let mapping_path = root.join(&config.worktrees_dir).join("mapping.json"); - let mut mapping = load_worktree_mapping(&mapping_path); - mapping.insert(actual_folder_name.to_string(), request.name.clone()); - save_worktree_mapping(&mapping_path, &mapping); - log::info!( - "[worktree] Saved folder alias mapping: '{}' → '{}'", - actual_folder_name, - request.name - ); - } - - log::info!( - "[worktree] Successfully created worktree '{}' (folder: '{}') with {} projects", - request.name, - actual_folder_name, - project_count - ); - Ok(normalize_path(&worktree_path.to_string_lossy())) -} - -/// Worktree creation timeout: 10 minutes (for large repos with slow fetch) -const CREATE_WORKTREE_TIMEOUT_SECS: u64 = 600; - -#[tauri::command] -pub(crate) async fn create_worktree( - window: tauri::Window, - request: CreateWorktreeRequest, -) -> Result { - let label = window.label().to_string(); - match tokio::time::timeout( - std::time::Duration::from_secs(CREATE_WORKTREE_TIMEOUT_SECS), - tokio::task::spawn_blocking(move || create_worktree_impl(&label, request)), - ) - .await - { - Ok(join_result) => join_result.map_err(|e| format!("Task join error: {}", e))?, - Err(_) => Err(format!( - "Worktree creation timed out after {} minutes", - CREATE_WORKTREE_TIMEOUT_SECS / 60 - )), - } -} - -pub fn archive_worktree_impl(window_label: &str, name: String) -> Result<(), String> { - let workspace_path = - crate::config::get_window_workspace_path(window_label).ok_or("No workspace selected")?; - - // 串行化同一 workspace 的生命周期操作,防止并发竞态破坏配置/git 状态。 - let lifecycle_lock = crate::state::workspace_lifecycle_lock(&workspace_path); - let _lifecycle_guard = lifecycle_lock - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - // 锁内读取最新配置,防止并发生命周期操作之间丢更新。 - let config = crate::config::load_workspace_config(&workspace_path); - - let root = PathBuf::from(&workspace_path); - let worktree_path = root.join(&config.worktrees_dir).join(&name); - - if !worktree_path.exists() { - return Err("Worktree does not exist".to_string()); - } - - log::info!( - "[worktree] Archiving worktree '{}' in workspace '{}'", - name, - workspace_path - ); - - // Step 1: Close all PTY sessions associated with this worktree - log::info!( - "[worktree] Step 1/4: Closing PTY sessions for worktree '{}'", - name - ); - { - let worktree_path_str = worktree_path.to_string_lossy().to_string(); - if let Ok(mut manager) = PTY_MANAGER.lock() { - let closed = - manager.close_sessions_by_path_prefix(&worktree_path_str, "archive_worktree"); - if !closed.is_empty() { - log::info!( - "[worktree] Closed {} PTY sessions for archived worktree: {:?}", - closed.len(), - closed - ); - } else { - log::info!("[worktree] No PTY sessions to close"); - } - } - } - - // Step 2: On Windows, fail before mutating git worktree registrations if files are in use. - log::info!("[worktree] Step 2/4: Checking file usage for '{}'", name); - ensure_windows_archive_file_usage_clear(&worktree_path)?; - - // Step 3: Remove git worktrees first - log::info!( - "[worktree] Step 3/4: Removing git worktree registrations for '{}'", - name - ); - let projects_path = worktree_path.join("projects"); - if projects_path.exists() { - if let Ok(entries) = std::fs::read_dir(&projects_path) { - for entry in entries.flatten() { - let proj_path = entry.path(); - let proj_name = proj_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - - let main_proj_path = root.join("projects").join(proj_name); - - log::info!( - "[worktree] Removing git worktree for project '{}'", - proj_name - ); - let output = git_command() - .args([ - "-C", - main_proj_path.to_string_lossy().as_ref(), - "worktree", - "remove", - proj_path.to_string_lossy().as_ref(), - "--force", - ]) - .output(); - - match &output { - Ok(o) if o.status.success() => { - log::info!( - "[worktree] Successfully removed git worktree for '{}'", - proj_name - ); - } - Ok(o) => { - let stderr_for_log = - mask_url_credentials(&String::from_utf8_lossy(&o.stderr)); - log::warn!( - "[worktree] git worktree remove for '{}' returned non-zero: {}", - proj_name, - stderr_for_log - ); - } - Err(e) => { - log::warn!( - "[worktree] Failed to execute git worktree remove for '{}': {}", - proj_name, - e - ); - } - } - } - } - } - - // Step 4: Mark as archived in config (no folder rename) - log::info!("[worktree] Step 4/4: Marking worktree as archived in config"); - let mut config = config; - if !config.archived_worktrees.contains(&name) { - config.archived_worktrees.push(name.clone()); - } - save_workspace_config_internal(&workspace_path, &config)?; - log::info!( - "[worktree] Marked worktree '{}' as archived in config", - name - ); - - log::info!("[worktree] Successfully archived worktree '{}'", name); - Ok(()) -} - -#[tauri::command] -pub(crate) async fn archive_worktree(window: tauri::Window, name: String) -> Result<(), String> { - let label = window.label().to_string(); - tokio::task::spawn_blocking(move || archive_worktree_impl(&label, name)) - .await - .map_err(|e| format!("Task join error: {}", e))? -} - -pub fn check_worktree_status_impl( - window_label: &str, - name: String, -) -> Result { - let (workspace_path, config) = - get_window_workspace_config(window_label).ok_or("No workspace selected")?; - - let root = PathBuf::from(&workspace_path); - let worktree_path = root.join(&config.worktrees_dir).join(&name); - - if !worktree_path.exists() { - return Err("Worktree does not exist".to_string()); - } - - let mut status = WorktreeArchiveStatus { - name: name.clone(), - can_archive: true, - warnings: vec![], - errors: vec![], - projects: vec![], - locked_processes: vec![], - lock_check_supported: cfg!(target_os = "windows"), - lock_check_error: None, - }; - - #[cfg(target_os = "windows")] - { - match find_worktree_locking_processes(&worktree_path) { - Ok(processes) => { - if !processes.is_empty() { - status.can_archive = false; - status.errors.push(format!( - "Worktree files are currently in use by {} process(es)", - processes.len() - )); - status.locked_processes = processes; - } - } - Err(e) => { - status.can_archive = false; - status - .errors - .push(format!("File usage check failed: {}", e)); - status.lock_check_error = Some(e); - } - } - } - - let projects_path = worktree_path.join("projects"); - if !projects_path.exists() { - return Ok(status); - } - - if let Ok(entries) = std::fs::read_dir(&projects_path) { - for entry in entries.flatten() { - let proj_path = entry.path(); - if !proj_path.is_dir() { - continue; - } - - let proj_name = proj_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_string(); - - let base_branch = config - .projects - .iter() - .find(|p| p.name == proj_name) - .map(|p| p.base_branch.as_str()) - .unwrap_or("uat"); - - let branch_status = get_branch_status(&proj_path, &proj_name, base_branch); - - if branch_status.has_uncommitted { - status.errors.push(format!( - "{}: {} 个未提交的更改", - proj_name, branch_status.uncommitted_count - )); - status.can_archive = false; - } - - if !branch_status.is_pushed { - if branch_status.unpushed_commits > 0 { - status.errors.push(format!( - "{}: {} 个未推送的提交", - proj_name, branch_status.unpushed_commits - )); - status.can_archive = false; - } else { - status - .warnings - .push(format!("{}: 分支未推送到远端", proj_name)); - } - } - - if !branch_status.has_merge_request && branch_status.is_pushed { - status - .warnings - .push(format!("{}: 请确认是否已创建 Merge Request", proj_name)); - } - - status.projects.push(branch_status); - } - } - - Ok(status) -} - -#[tauri::command] -pub(crate) async fn check_worktree_status( - window: tauri::Window, - name: String, -) -> Result { - let label = window.label().to_string(); - tokio::task::spawn_blocking(move || check_worktree_status_impl(&label, name)) - .await - .map_err(|e| format!("Task join error: {}", e))? -} - -pub fn restore_worktree_impl(window_label: &str, name: String) -> Result<(), String> { - validate_git_ref_name(&name)?; - - let workspace_path = - crate::config::get_window_workspace_path(window_label).ok_or("No workspace selected")?; - - // 串行化同一 workspace 的生命周期操作,防止 restore vs delete TOCTOU 竞态。 - let lifecycle_lock = crate::state::workspace_lifecycle_lock(&workspace_path); - let _lifecycle_guard = lifecycle_lock - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - // 锁内读取最新配置,防止并发丢更新。 - let config = crate::config::load_workspace_config(&workspace_path); - - let root = PathBuf::from(&workspace_path); - let worktree_path = root.join(&config.worktrees_dir).join(&name); - - if !worktree_path.exists() { - return Err("Archived worktree does not exist".to_string()); - } - - log::info!( - "[worktree] Restoring worktree '{}' from archive in workspace '{}'", - name, - workspace_path - ); - - // Step 1: Re-register git worktrees for each project - log::info!( - "[worktree] Step 1/2: Re-registering git worktrees for '{}'", - name - ); - let projects_path = worktree_path.join("projects"); - if projects_path.exists() { - if let Ok(entries) = std::fs::read_dir(&projects_path) { - for entry in entries.flatten() { - let proj_path = entry.path(); - if !proj_path.is_dir() { - continue; - } - - let proj_name = proj_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_string(); - - let main_proj_path = root.join("projects").join(&proj_name); - if !main_proj_path.exists() { - log::warn!( - "Main project path does not exist for {}, skipping", - proj_name - ); - continue; - } - - // Remove the old project directory content (it was archived without git worktree registration) - // We need to remove it and re-add via git worktree add - let wt_proj_path = projects_path.join(&proj_name); - - // Check if branch exists - let branch_name = &name; - let branch_check = git_command() - .args([ - "-C", - main_proj_path.to_string_lossy().as_ref(), - "branch", - "--list", - branch_name, - ]) - .output(); - - let branch_exists = branch_check - .as_ref() - .map(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty()) - .unwrap_or(false); - - // Remove the directory so git worktree add can recreate it - if wt_proj_path.exists() { - fs::remove_dir_all(&wt_proj_path).ok(); - } - - // Prune stale worktrees first - git_command() - .args([ - "-C", - main_proj_path.to_string_lossy().as_ref(), - "worktree", - "prune", - ]) - .output() - .ok(); - - // Re-add worktree - let output = if branch_exists { - log::info!( - "Re-adding worktree for {} with existing branch {}", - proj_name, - branch_name - ); - git_command() - .args([ - "-C", - main_proj_path.to_string_lossy().as_ref(), - "worktree", - "add", - wt_proj_path.to_string_lossy().as_ref(), - branch_name, - ]) - .output() - } else { - // Find appropriate base branch from project config - let base_branch = config - .projects - .iter() - .find(|p| p.name == proj_name) - .map(|p| p.base_branch.clone()) - .unwrap_or_else(|| "uat".to_string()); - if let Err(e) = validate_git_ref_name(&base_branch) { - log::error!( - "[worktree] Invalid base branch '{}' for project '{}': {}", - base_branch, - proj_name, - e - ); - continue; - } - - log::info!( - "Re-adding worktree for {} with new branch {} from origin/{}", - proj_name, - branch_name, - base_branch - ); - git_command() - .args([ - "-C", - main_proj_path.to_string_lossy().as_ref(), - "worktree", - "add", - wt_proj_path.to_string_lossy().as_ref(), - "-b", - branch_name, - &format!("origin/{}", base_branch), - ]) - .output() - }; - - match output { - Ok(o) if o.status.success() => { - log::info!("Successfully re-added worktree for {}", proj_name); - // Set upstream for the branch so git push works without -u flag - let push_output = run_git_command_with_timeout( - &["push", "-u", "origin", branch_name], - wt_proj_path.to_string_lossy().as_ref(), - ); - match push_output { - Ok(p) if p.status.success() => { - log::info!( - "[worktree] Project '{}': git push -u origin {} succeeded", - proj_name, - branch_name - ); - } - Ok(p) => { - let stderr = String::from_utf8_lossy(&p.stderr); - let stderr_for_log = mask_url_credentials(&stderr); - log::warn!( - "[worktree] Project '{}': git push -u origin {} failed (worktree restored successfully): {}", - proj_name, - branch_name, - stderr_for_log - ); - } - Err(e) => { - log::warn!( - "[worktree] Project '{}': git push -u origin {} failed to execute (worktree restored successfully): {}", - proj_name, - branch_name, - e - ); - } - } - } - Ok(o) => { - let stderr = String::from_utf8_lossy(&o.stderr); - let stderr_for_log = mask_url_credentials(&stderr); - log::error!( - "Failed to re-add worktree for {}: {}", - proj_name, - stderr_for_log - ); - } - Err(e) => { - log::error!( - "Failed to execute git worktree add for {}: {}", - proj_name, - e - ); - } - } - - // Restore project-level symlinks (linked_folders) - let proj_config = config.projects.iter().find(|p| p.name == proj_name); - if let Some(pc) = proj_config { - for folder_name in &pc.linked_folders { - let main_folder = main_proj_path.join(folder_name); - let wt_folder = wt_proj_path.join(folder_name); - - if main_folder.exists() && !wt_folder.exists() { - create_symlink(&main_folder, &wt_folder).ok(); - } - } - } - } - } - } - - // Step 2: Restore workspace-level symlinks - // Merge linked_workspace_items + vault_linked_workspace_items, deduplicated - let mut all_linked: Vec = config.linked_workspace_items.clone(); - for item in &config.vault_linked_workspace_items { - if !all_linked.contains(item) { - all_linked.push(item.clone()); - } - } - log::info!( - "[worktree] Step 2/2: Restoring workspace-level symlinks ({} items)", - all_linked.len() - ); - for item_name in &all_linked { - let src = root.join(item_name); - let dst = worktree_path.join(item_name); - if src.exists() && !dst.exists() { - create_symlink(&src, &dst).ok(); - } - } - - // Step 3: Remove from archived list in config - let mut config = config; - config.archived_worktrees.retain(|n| n != &name); - save_workspace_config_internal(&workspace_path, &config)?; - log::info!( - "[worktree] Removed worktree '{}' from archived list in config", - name - ); - - log::info!("Successfully restored worktree '{}'", name); - Ok(()) -} - -#[tauri::command] -pub(crate) async fn restore_worktree(window: tauri::Window, name: String) -> Result<(), String> { - let label = window.label().to_string(); - tokio::task::spawn_blocking(move || restore_worktree_impl(&label, name)) - .await - .map_err(|e| format!("Task join error: {}", e))? -} - -pub fn delete_archived_worktree_impl(window_label: &str, name: String) -> Result<(), String> { - let workspace_path = - crate::config::get_window_workspace_path(window_label).ok_or("No workspace selected")?; - - // 串行化同一 workspace 的生命周期操作,防止 delete vs restore TOCTOU、double-delete 竞态。 - let lifecycle_lock = crate::state::workspace_lifecycle_lock(&workspace_path); - let _lifecycle_guard = lifecycle_lock - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - // 锁内读取最新配置,防止并发丢更新。 - let config = crate::config::load_workspace_config(&workspace_path); - - let root = PathBuf::from(&workspace_path); - let worktree_path = root.join(&config.worktrees_dir).join(&name); - - // Validate it's an archived worktree - if !config.archived_worktrees.contains(&name) { - return Err("Can only delete archived worktrees".to_string()); - } - - if !worktree_path.exists() { - return Err("Archived worktree does not exist".to_string()); - } - - let folder_key = &name; - // Check mapping for the actual branch name (may differ from folder name if aliased) - let mapping_path = root.join(&config.worktrees_dir).join("mapping.json"); - let mapping = load_worktree_mapping(&mapping_path); - let branch_name = mapping - .get(folder_key) - .map(|s| s.as_str()) - .unwrap_or(folder_key); - validate_git_ref_name(branch_name)?; - log::info!( - "[worktree] Deleting archived worktree '{}' (branch: {}) in workspace '{}'", - name, - branch_name, - workspace_path - ); - - // Step 1: Close any related PTY sessions - log::info!( - "[worktree] Step 1/3: Closing PTY sessions for archived worktree '{}'", - name - ); - { - let worktree_path_str = worktree_path.to_string_lossy().to_string(); - if let Ok(mut manager) = PTY_MANAGER.lock() { - let closed = manager - .close_sessions_by_path_prefix(&worktree_path_str, "delete_archived_worktree"); - if !closed.is_empty() { - log::info!( - "[worktree] Closed {} PTY sessions for deleted worktree", - closed.len() - ); - } else { - log::info!("[worktree] No PTY sessions to close"); - } - } - } - - // Step 2: 先删除目录(原子性关键)。worktree 工作树可从分支重建,是可逆性更高的一步; - // 若 remove_dir_all 失败则直接返回——此时分支尚未删除、配置未改,worktree 仍标记 archived - // 可恢复,彻底避免“分支已删(丢 commits)但目录删除失败”的数据丢失。 - log::info!( - "[worktree] Step 2/3: Removing directory {}", - worktree_path.display() - ); - fs::remove_dir_all(&worktree_path) - .map_err(|e| friendly_fs_error("删除归档 Worktree 失败", &e))?; - - // Step 3: 仅在目录删除确认成功后才删除分支(不可逆操作放最后)。 - // 个别项目分支删除失败不影响整体一致性(worktree 目录已不存在,后续会清理 archived 标记)。 - log::info!( - "[worktree] Step 3/3: Deleting local branch '{}' from projects", - branch_name - ); - let projects_path = root.join("projects"); - if projects_path.exists() { - if let Ok(entries) = std::fs::read_dir(&projects_path) { - for entry in entries.flatten() { - let proj_path = entry.path(); - if !proj_path.is_dir() { - continue; - } - - // Try to delete the branch (it may not exist in all projects) - let output = git_command() - .args([ - "-C", - proj_path.to_string_lossy().as_ref(), - "branch", - "-D", - branch_name, - ]) - .output(); - - match output { - Ok(o) if o.status.success() => { - let proj_name = - proj_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - log::info!( - "Deleted branch '{}' from project '{}'", - branch_name, - proj_name - ); - } - _ => {} // Branch might not exist in this project, that's fine - } - } - } - } - - // Clean up mapping entry if exists - if mapping.contains_key(folder_key) { - let mut mapping = mapping; - mapping.remove(folder_key); - save_worktree_mapping(&mapping_path, &mapping); - log::info!("[worktree] Removed mapping entry for '{}'", folder_key); - } - - // Step 4: Remove from archived list in config - let mut config = config; - config.archived_worktrees.retain(|n| n != &name); - save_workspace_config_internal(&workspace_path, &config)?; - log::info!( - "[worktree] Removed worktree '{}' from archived list in config", - name - ); - - log::info!( - "[worktree] Successfully deleted archived worktree '{}'", - name - ); - Ok(()) -} - -#[tauri::command] -pub(crate) async fn delete_archived_worktree( - window: tauri::Window, - name: String, -) -> Result<(), String> { - let label = window.label().to_string(); - tokio::task::spawn_blocking(move || delete_archived_worktree_impl(&label, name)) - .await - .map_err(|e| format!("Task join error: {}", e))? -} - -// ==================== 向已有 Worktree 添加项目 ==================== - -pub fn add_project_to_worktree_impl( - window_label: &str, - request: AddProjectToWorktreeRequest, -) -> Result<(), String> { - validate_git_ref_name(&request.base_branch)?; - - let workspace_path = - crate::config::get_window_workspace_path(window_label).ok_or("No workspace selected")?; - - // 串行化同一 workspace 的生命周期操作,避免与 archive/delete 同一 worktree 交叉竞态。 - let lifecycle_lock = crate::state::workspace_lifecycle_lock(&workspace_path); - let _lifecycle_guard = lifecycle_lock - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - // 锁内读取最新配置,防止并发丢更新。 - let config = crate::config::load_workspace_config(&workspace_path); - - let root = PathBuf::from(&workspace_path); - let worktree_path = root - .join(&config.worktrees_dir) - .join(&request.worktree_name); - - if !worktree_path.exists() { - return Err(format!( - "Worktree '{}' does not exist", - request.worktree_name - )); - } - - let main_proj_path = root.join("projects").join(&request.project_name); - if !main_proj_path.exists() { - return Err(format!( - "Project '{}' does not exist in main workspace", - request.project_name - )); - } - - let wt_proj_path = worktree_path.join("projects").join(&request.project_name); - if wt_proj_path.exists() { - return Err(format!( - "Project '{}' already exists in worktree '{}'", - request.project_name, request.worktree_name - )); - } - - // Ensure the projects directory exists in the worktree - let projects_dir = worktree_path.join("projects"); - if !projects_dir.exists() { - std::fs::create_dir_all(&projects_dir) - .map_err(|e| friendly_fs_error("无法创建 projects 目录", &e))?; - } - - let proj_config = config - .projects - .iter() - .find(|p| p.name == request.project_name) - .cloned() - .unwrap_or(ProjectConfig { - name: request.project_name.clone(), - base_branch: request.base_branch.clone(), - test_branch: "test".to_string(), - merge_strategy: "merge".to_string(), - linked_folders: vec![], - commit_prefix_index: None, - git_user_name: None, - git_user_email: None, - tags: vec![], - }); - - // Resolve display name from mapping (used as branch name) - let mapping_path = root.join(&config.worktrees_dir).join("mapping.json"); - let mapping = load_worktree_mapping(&mapping_path); - let branch_name = mapping - .get(&request.worktree_name) - .cloned() - .unwrap_or_else(|| request.worktree_name.clone()); - validate_git_ref_name(&branch_name)?; - - log::info!( - "[worktree] Adding project '{}' to worktree '{}' (branch: '{}', base_branch: {})", - request.project_name, - request.worktree_name, - branch_name, - request.base_branch - ); - - // Step 1: Fetch origin first - log::info!( - "[worktree] Step 1/3: git fetch origin for project '{}'", - request.project_name - ); - run_git_command_with_timeout( - &["fetch", "origin"], - main_proj_path.to_string_lossy().as_ref(), - )?; - - // Check if branch already exists - let branch_check = git_command() - .args([ - "-C", - main_proj_path.to_string_lossy().as_ref(), - "branch", - "--list", - &branch_name, - ]) - .output(); - - let branch_exists = branch_check - .as_ref() - .map(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty()) - .unwrap_or(false); - - let remote_branch_exists = if !branch_exists { - crate::git_ops::check_remote_branch_exists(&main_proj_path, &branch_name).unwrap_or(false) - } else { - false - }; - - // Step 2: Create worktree - use existing local branch, track remote, or create from base. - log::info!( - "[worktree] Step 2/3: git worktree add for project '{}'", - request.project_name - ); - let output = if branch_exists { - log::info!( - "[worktree] Branch '{}' already exists locally, using it for project '{}'", - branch_name, - request.project_name - ); - git_command() - .args([ - "-C", - main_proj_path.to_string_lossy().as_ref(), - "worktree", - "add", - wt_proj_path.to_string_lossy().as_ref(), - &branch_name, - ]) - .output() - .map_err(|e| friendly_fs_error("创建 Worktree 失败", &e))? - } else if remote_branch_exists { - log::info!( - "[worktree] Remote branch 'origin/{}' already exists, tracking it for project '{}'", - branch_name, - request.project_name - ); - git_command() - .args([ - "-C", - main_proj_path.to_string_lossy().as_ref(), - "worktree", - "add", - "--track", - "-b", - &branch_name, - wt_proj_path.to_string_lossy().as_ref(), - &format!("origin/{}", branch_name), - ]) - .output() - .map_err(|e| friendly_fs_error("创建 Worktree 失败", &e))? - } else { - log::info!( - "[worktree] Creating new branch '{}' for project '{}' from origin/{}", - branch_name, - request.project_name, - request.base_branch - ); - git_command() - .args([ - "-C", - main_proj_path.to_string_lossy().as_ref(), - "worktree", - "add", - wt_proj_path.to_string_lossy().as_ref(), - "-b", - &branch_name, - &format!("origin/{}", request.base_branch), - ]) - .output() - .map_err(|e| friendly_fs_error("创建 Worktree 失败", &e))? - }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let stderr_for_log = mask_url_credentials(&stderr); - log::error!( - "[worktree] FAILED: git worktree add for project '{}': {}", - request.project_name, - stderr_for_log - ); - return Err(format!( - "Failed to add project {} to worktree: {}", - request.project_name, stderr - )); - } - log::info!( - "[worktree] Project '{}': git worktree add succeeded", - request.project_name - ); - - // When tracking an existing remote branch, --track already set the upstream. - // Only push (to create the remote branch) for new or locally-existing branches. - if !remote_branch_exists { - // Push to create remote branch and set upstream tracking. - // Even though there are no user commits yet, this is necessary to prevent - // IDEs from defaulting to push to the base branch (uat/main). - let push_output = run_git_command_with_timeout( - &["push", "-u", "origin", &branch_name], - wt_proj_path.to_string_lossy().as_ref(), - ); - match push_output { - Ok(p) if p.status.success() => { - log::info!( - "[worktree] Project '{}': git push -u origin {} succeeded", - request.project_name, - branch_name - ); - } - Ok(p) => { - let stderr = String::from_utf8_lossy(&p.stderr); - let stderr_for_log = mask_url_credentials(&stderr); - log::warn!( - "[worktree] Project '{}': git push -u origin {} failed (project added successfully): {}", - request.project_name, - branch_name, - stderr_for_log - ); - } - Err(e) => { - log::warn!( - "[worktree] Project '{}': git push -u origin {} failed to execute: {}", - request.project_name, - branch_name, - e - ); - } - } - } else { - log::info!( - "[worktree] Project '{}': tracking origin/{}, skipping push", - request.project_name, - branch_name - ); - } - - // Step 3: Link configured folders - log::info!( - "[worktree] Step 3/3: Creating symlinks for {} linked folders", - proj_config.linked_folders.len() - ); - for folder_name in &proj_config.linked_folders { - let main_folder = main_proj_path.join(folder_name); - let wt_folder = wt_proj_path.join(folder_name); - - if main_folder.exists() && !wt_folder.exists() { - create_symlink(&main_folder, &wt_folder).ok(); - - // Remove from git index if it's tracked - git_command() - .args([ - "-C", - wt_proj_path.to_string_lossy().as_ref(), - "rm", - "--cached", - "-r", - folder_name, - ]) - .output() - .ok(); - } - } - - log::info!( - "Successfully added project '{}' to worktree '{}'", - request.project_name, - request.worktree_name - ); - Ok(()) -} - -#[tauri::command] -pub(crate) async fn add_project_to_worktree( - window: tauri::Window, - request: AddProjectToWorktreeRequest, -) -> Result<(), String> { - let label = window.label().to_string(); - tokio::task::spawn_blocking(move || add_project_to_worktree_impl(&label, request)) - .await - .map_err(|e| format!("Task join error: {}", e))? -} - -// ==================== 智能扫描 ==================== - -#[tauri::command] -pub(crate) async fn scan_linked_folders( - project_path: String, -) -> Result, String> { - scan_linked_folders_sync(&project_path) -} - -pub fn scan_linked_folders_internal(project_path: &str) -> Result, String> { - scan_linked_folders_sync(project_path) -} - -fn scan_linked_folders_sync(project_path: &str) -> Result, String> { - let path = PathBuf::from(project_path); - if !path.exists() { - return Err(format!("Path does not exist: {}", project_path)); - } - let mut results = Vec::new(); - scan_dir_for_linkable_folders(&path, &path, &mut results); - results.sort_by(|a, b| { - b.is_recommended - .cmp(&a.is_recommended) - .then_with(|| b.size_bytes.cmp(&a.size_bytes)) - }); - Ok(results) -} - -// ==================== 部署到主工作区 ==================== - -pub fn deploy_to_main_impl( - window_label: &str, - worktree_name: String, -) -> Result { - let workspace_path = - crate::config::get_window_workspace_path(window_label).ok_or("No workspace selected")?; - - // 串行化同一 workspace 的生命周期操作。 - let lifecycle_lock = crate::state::workspace_lifecycle_lock(&workspace_path); - let _lifecycle_guard = lifecycle_lock - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - // 锁内读取最新配置,防止并发丢更新。 - let config = crate::config::load_workspace_config(&workspace_path); - - // Check not already occupied - if let Some(existing) = load_occupation_state(&workspace_path) { - return Err(format!( - "Main workspace is already occupied by worktree '{}'", - existing.worktree_name - )); - } - - let root = PathBuf::from(&workspace_path); - let worktree_path = root.join(&config.worktrees_dir).join(&worktree_name); - - if !worktree_path.exists() { - return Err(format!("Worktree '{}' does not exist", worktree_name)); - } - - let wt_projects_path = worktree_path.join("projects"); - if !wt_projects_path.exists() { - return Err("Worktree has no projects directory".to_string()); - } - - // Collect worktree project branches - let mut wt_branches: HashMap = HashMap::new(); - if let Ok(entries) = std::fs::read_dir(&wt_projects_path) { - for entry in entries.flatten() { - let proj_path = entry.path(); - if !proj_path.is_dir() { - continue; - } - let proj_name = proj_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_string(); - - let info = crate::git_ops::get_worktree_info(&proj_path); - wt_branches.insert(proj_name, info.current_branch); - } - } - - if wt_branches.is_empty() { - return Err("No projects found in worktree".to_string()); - } - - // Check main workspace projects for uncommitted changes - let main_projects_path = root.join("projects"); - let mut original_branches: HashMap = HashMap::new(); - - for proj_name in wt_branches.keys() { - let main_proj_path = main_projects_path.join(proj_name); - if !main_proj_path.exists() { - continue; - } - - let info = crate::git_ops::get_worktree_info(&main_proj_path); - if info.uncommitted_count > 0 { - return Err(format!( - "Project '{}' in main workspace has {} uncommitted changes. Please commit or stash them first.", - proj_name, info.uncommitted_count - )); - } - original_branches.insert(proj_name.clone(), info.current_branch); - } - - let occupation = MainWorkspaceOccupation { - worktree_name: worktree_name.clone(), - original_branches: original_branches.clone(), - worktree_branches: wt_branches.clone(), - deployed_at: chrono::Utc::now().to_rfc3339(), - }; - - let mut switched_projects = Vec::new(); - let mut failed_projects = Vec::new(); - - // Detach worktree project HEADs and switch main workspace branches - for (proj_name, wt_branch) in &wt_branches { - let wt_proj_path = wt_projects_path.join(proj_name); - let main_proj_path = main_projects_path.join(proj_name); - - if !main_proj_path.exists() { - continue; - } - - // Step 1: Detach worktree HEAD - log::info!( - "[deploy] Detaching HEAD in worktree project '{}'", - proj_name - ); - let detach_output = git_command() - .args([ - "-C", - wt_proj_path.to_string_lossy().as_ref(), - "checkout", - "--detach", - ]) - .output(); - - match &detach_output { - Ok(o) if o.status.success() => { - log::info!("[deploy] Detached HEAD in worktree project '{}'", proj_name); - } - Ok(o) => { - let stderr = String::from_utf8_lossy(&o.stderr); - let stderr_for_log = mask_url_credentials(&stderr); - log::error!( - "[deploy] Failed to detach HEAD in '{}': {}", - proj_name, - stderr_for_log - ); - failed_projects.push(DeployProjectError { - project_name: proj_name.clone(), - error: format!("Failed to detach worktree HEAD: {}", stderr), - }); - continue; - } - Err(e) => { - log::error!( - "[deploy] Failed to run git detach in '{}': {}", - proj_name, - e - ); - failed_projects.push(DeployProjectError { - project_name: proj_name.clone(), - error: format!("Failed to run git: {}", e), - }); - continue; - } - } - - // Step 2: Switch main workspace project to worktree branch - log::info!( - "[deploy] Switching main project '{}' to branch '{}'", - proj_name, - wt_branch - ); - let switch_output = git_command() - .args([ - "-C", - main_proj_path.to_string_lossy().as_ref(), - "checkout", - wt_branch, - ]) - .output(); - - match switch_output { - Ok(o) if o.status.success() => { - log::info!( - "[deploy] Switched main project '{}' to '{}'", - proj_name, - wt_branch - ); - switched_projects.push(proj_name.clone()); - } - Ok(o) => { - let stderr = String::from_utf8_lossy(&o.stderr); - let stderr_for_log = mask_url_credentials(&stderr); - log::error!( - "[deploy] Failed to switch main '{}' to '{}': {}", - proj_name, - wt_branch, - stderr_for_log - ); - failed_projects.push(DeployProjectError { - project_name: proj_name.clone(), - error: format!("Failed to switch branch: {}", stderr), - }); - } - Err(e) => { - log::error!( - "[deploy] Failed to run git checkout in main '{}': {}", - proj_name, - e - ); - failed_projects.push(DeployProjectError { - project_name: proj_name.clone(), - error: format!("Failed to run git: {}", e), - }); - } - } - } - - // Only persist occupation state if at least one project deployed successfully - if !switched_projects.is_empty() { - save_occupation_state(&workspace_path, &occupation)?; - - // Clean up PTY sessions for switched main workspace projects - if let Ok(mut manager) = PTY_MANAGER.lock() { - for proj_name in &switched_projects { - let proj_path = main_projects_path.join(proj_name); - if let Some(path_str) = proj_path.to_str() { - let closed = manager.close_sessions_by_path_prefix( - path_str, - "deploy_to_main: project switched to different branch", - ); - if !closed.is_empty() { - log::info!( - "[deploy] Closed {} PTY sessions for project '{}'", - closed.len(), - proj_name - ); - } - } - } - } - } - - log::info!( - "[deploy] Deploy complete: {} switched, {} failed", - switched_projects.len(), - failed_projects.len() - ); - - broadcast_lock_state(&workspace_path); - - Ok(DeployToMainResult { - success: failed_projects.is_empty(), - switched_projects, - failed_projects, - }) -} - -#[tauri::command] -pub(crate) async fn deploy_to_main( - window: tauri::Window, - worktree_name: String, -) -> Result { - let label = window.label().to_string(); - tokio::task::spawn_blocking(move || deploy_to_main_impl(&label, worktree_name)) - .await - .map_err(|e| format!("Task join error: {}", e))? -} - -pub fn exit_main_occupation_impl(window_label: &str, force: bool) -> Result<(), String> { - let (workspace_path, config) = - get_window_workspace_config(window_label).ok_or("No workspace selected")?; - - let occupation = - load_occupation_state(&workspace_path).ok_or("Main workspace is not currently occupied")?; - - let root = PathBuf::from(&workspace_path); - let main_projects_path = root.join("projects"); - let worktree_path = root - .join(&config.worktrees_dir) - .join(&occupation.worktree_name); - let wt_projects_path = worktree_path.join("projects"); - - // If not force, check for uncommitted changes in main workspace - if !force { - for proj_name in occupation.original_branches.keys() { - let main_proj_path = main_projects_path.join(proj_name); - if !main_proj_path.exists() { - continue; - } - - let info = crate::git_ops::get_worktree_info(&main_proj_path); - if info.uncommitted_count > 0 { - return Err(format!( - "Project '{}' in main workspace has {} uncommitted changes. Use force to discard them.", - proj_name, info.uncommitted_count - )); - } - } - } - - // Switch main workspace projects back to original branches - for (proj_name, original_branch) in &occupation.original_branches { - let main_proj_path = main_projects_path.join(proj_name); - if !main_proj_path.exists() { - continue; - } - - log::info!( - "[deploy] Switching main project '{}' back to '{}'", - proj_name, - original_branch - ); - - // If force, fully discard all changes (staged, tracked, and untracked) - if force { - git_command() - .args([ - "-C", - main_proj_path.to_string_lossy().as_ref(), - "reset", - "HEAD", - ]) - .output() - .ok(); - git_command() - .args([ - "-C", - main_proj_path.to_string_lossy().as_ref(), - "checkout", - "--", - ".", - ]) - .output() - .ok(); - git_command() - .args([ - "-C", - main_proj_path.to_string_lossy().as_ref(), - "clean", - "-fd", - ]) - .output() - .ok(); - } - - let output = git_command() - .args([ - "-C", - main_proj_path.to_string_lossy().as_ref(), - "checkout", - original_branch, - ]) - .output() - .map_err(|e| format!("Failed to switch project '{}': {}", proj_name, e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "Failed to switch project '{}' back to '{}': {}", - proj_name, original_branch, stderr - )); - } - } - - // Re-attach worktree project branches - for proj_name in occupation.original_branches.keys() { - let wt_proj_path = wt_projects_path.join(proj_name); - if !wt_proj_path.exists() { - continue; - } - - // Use the saved worktree branch, fall back to worktree name (old state files) - let branch = occupation - .worktree_branches - .get(proj_name) - .unwrap_or(&occupation.worktree_name); - log::info!( - "[deploy] Re-attaching worktree project '{}' to branch '{}'", - proj_name, - branch - ); - - let output = git_command() - .args([ - "-C", - wt_proj_path.to_string_lossy().as_ref(), - "checkout", - branch, - ]) - .output(); - - match output { - Ok(o) if o.status.success() => { - log::info!("[deploy] Re-attached worktree project '{}'", proj_name); - } - Ok(o) => { - let stderr = String::from_utf8_lossy(&o.stderr); - let stderr_for_log = mask_url_credentials(&stderr); - log::warn!( - "[deploy] Failed to re-attach worktree '{}': {}", - proj_name, - stderr_for_log - ); - } - Err(e) => { - log::warn!( - "[deploy] Failed to run git checkout in worktree '{}': {}", - proj_name, - e - ); - } - } - } - - // Clear occupation state - clear_occupation_state(&workspace_path)?; - - // Clean up PTY sessions for main workspace projects - if let Ok(mut manager) = PTY_MANAGER.lock() { - for proj_name in occupation.original_branches.keys() { - let proj_path = main_projects_path.join(proj_name); - if let Some(path_str) = proj_path.to_str() { - let closed = manager.close_sessions_by_path_prefix( - path_str, - "exit_occupation: project switched back to original branch", - ); - if !closed.is_empty() { - log::info!( - "[deploy] Closed {} PTY sessions for project '{}'", - closed.len(), - proj_name - ); - } - } - } - } - - log::info!( - "[deploy] Exited occupation from worktree '{}'", - occupation.worktree_name - ); - - broadcast_lock_state(&workspace_path); - - Ok(()) -} - -#[tauri::command] -pub(crate) async fn exit_main_occupation(window: tauri::Window, force: bool) -> Result<(), String> { - let label = window.label().to_string(); - tokio::task::spawn_blocking(move || exit_main_occupation_impl(&label, force)) - .await - .map_err(|e| format!("Task join error: {}", e))? -} - -pub fn get_main_occupation_impl( - window_label: &str, -) -> Result, String> { - let (workspace_path, _config) = - get_window_workspace_config(window_label).ok_or("No workspace selected")?; - - let occupation = load_occupation_state(&workspace_path); - - // Auto-cleanup: if occupation exists but no window is using this workspace, clear it - if occupation.is_some() { - let windows = WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let is_in_use = windows.values().any(|p| *p == workspace_path); - drop(windows); - - if !is_in_use { - log::info!( - "[worktree] Auto-clearing stale occupation state for workspace '{}' (no window using it)", - workspace_path - ); - let _ = clear_occupation_state(&workspace_path); - return Ok(None); - } - } - - Ok(occupation) -} - -#[tauri::command] -pub(crate) async fn get_main_occupation( - window: tauri::Window, -) -> Result, String> { - let label = window.label().to_string(); - tokio::task::spawn_blocking(move || get_main_occupation_impl(&label)) - .await - .map_err(|e| format!("Task join error: {}", e))? -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::{load_workspace_config, save_workspace_config_internal}; - use crate::state::WINDOW_WORKSPACES; - use crate::types::WorkspaceConfig; - use serial_test::serial; - use std::process::Command; - use std::sync::atomic::{AtomicUsize, Ordering}; - use tempfile::TempDir; - - static NEXT_WINDOW_ID: AtomicUsize = AtomicUsize::new(0); - - fn run_git(repo: &Path, args: &[&str]) { - let output = Command::new("git") - .args(args) - .current_dir(repo) - .output() - .expect("run git command"); - assert!( - output.status.success(), - "git {:?} failed in {}\nstdout:\n{}\nstderr:\n{}", - args, - repo.display(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } - - fn git_output(repo: &Path, args: &[&str]) -> String { - let output = Command::new("git") - .args(args) - .current_dir(repo) - .output() - .expect("run git command"); - assert!( - output.status.success(), - "git {:?} failed in {}\nstdout:\n{}\nstderr:\n{}", - args, - repo.display(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8_lossy(&output.stdout).trim().to_string() - } - - fn make_test_repo() -> TempDir { - let temp = tempfile::tempdir().expect("create temp repo"); - let repo = temp.path(); - - run_git(repo, &["init"]); - run_git(repo, &["checkout", "-b", "main"]); - run_git(repo, &["config", "user.email", "test@example.com"]); - run_git(repo, &["config", "user.name", "Test User"]); - std::fs::write(repo.join("README.md"), "initial\n").expect("write initial file"); - run_git(repo, &["add", "README.md"]); - run_git(repo, &["commit", "-m", "initial commit"]); - run_git(repo, &["branch", "test"]); - - temp - } - - fn init_bare_repo(origin_path: &Path) { - let output = Command::new("git") - .args(["init", "--bare"]) - .arg(origin_path) - .output() - .expect("init bare origin"); - assert!( - output.status.success(), - "git init --bare {} failed\nstdout:\n{}\nstderr:\n{}", - origin_path.display(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } - - fn clone_repo(origin_path: &Path, clone_path: &Path) { - let output = Command::new("git") - .arg("clone") - .arg(origin_path) - .arg(clone_path) - .output() - .expect("clone repo"); - assert!( - output.status.success(), - "git clone {} {} failed\nstdout:\n{}\nstderr:\n{}", - origin_path.display(), - clone_path.display(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } - - fn make_origin_backed_project(workspace: &Path, name: &str) -> PathBuf { - let seed = make_test_repo(); - let origins_dir = workspace.join("origins"); - std::fs::create_dir_all(&origins_dir).expect("create origins dir"); - let origin_path = origins_dir.join(format!("{name}.git")); - init_bare_repo(&origin_path); - - run_git( - seed.path(), - &["remote", "add", "origin", origin_path.to_str().unwrap()], - ); - run_git(seed.path(), &["push", "origin", "main"]); - run_git(seed.path(), &["push", "origin", "test"]); - run_git(&origin_path, &["symbolic-ref", "HEAD", "refs/heads/main"]); - - let projects_dir = workspace.join("projects"); - std::fs::create_dir_all(&projects_dir).expect("create projects dir"); - let project_path = projects_dir.join(name); - clone_repo(&origin_path, &project_path); - run_git(&project_path, &["config", "user.email", "test@example.com"]); - run_git(&project_path, &["config", "user.name", "Test User"]); - run_git(&project_path, &["fetch", "origin"]); - project_path - } - - fn project_config(name: &str) -> ProjectConfig { - ProjectConfig { - name: name.to_string(), - base_branch: "main".to_string(), - test_branch: "test".to_string(), - merge_strategy: "merge".to_string(), - linked_folders: vec![], - commit_prefix_index: None, - git_user_name: None, - git_user_email: None, - tags: vec![], - } - } - - fn workspace_config(projects: Vec) -> WorkspaceConfig { - WorkspaceConfig { - name: "Test Workspace".to_string(), - worktrees_dir: "worktrees".to_string(), - projects, - ..WorkspaceConfig::default() - } - } - - fn bind_workspace(workspace: &Path, config: &WorkspaceConfig) -> String { - let label = format!( - "worktree-test-window-{}", - NEXT_WINDOW_ID.fetch_add(1, Ordering::SeqCst) - ); - let workspace_path = workspace.to_string_lossy().to_string(); - save_workspace_config_internal(&workspace_path, config).expect("save workspace config"); - WINDOW_WORKSPACES - .lock() - .expect("lock window workspaces") - .insert(label.clone(), workspace_path); - label - } - - #[serial] - #[test] - fn create_list_status_and_archive_worktree_with_local_git_fixture() { - let workspace = tempfile::tempdir().expect("create workspace"); - let project_path = make_origin_backed_project(workspace.path(), "demo"); - assert!(project_path.join(".git").exists()); - let label = bind_workspace( - workspace.path(), - &workspace_config(vec![project_config("demo")]), - ); - - let created_path = create_worktree_impl( - &label, - CreateWorktreeRequest { - name: "feature_roundtrip".to_string(), - folder_name: None, - projects: vec![CreateProjectRequest { - name: "demo".to_string(), - base_branch: "main".to_string(), - }], - }, - ) - .expect("create worktree"); - let worktree_path = PathBuf::from(&created_path); - let wt_project_path = worktree_path.join("projects").join("demo"); - - assert!(wt_project_path.exists()); - assert_eq!( - git_output(&wt_project_path, &["branch", "--show-current"]), - "feature_roundtrip" - ); - - let active = list_worktrees_impl(&label, false).expect("list active worktrees"); - assert_eq!(active.len(), 1); - assert_eq!(active[0].name, "feature_roundtrip"); - assert!(!active[0].is_archived); - assert_eq!(active[0].projects.len(), 1); - assert_eq!(active[0].projects[0].name, "demo"); - assert_eq!(active[0].projects[0].current_branch, "feature_roundtrip"); - assert_eq!(active[0].projects[0].base_branch, "main"); - - let archive_status = check_worktree_status_impl(&label, "feature_roundtrip".to_string()) - .expect("check archive status"); - assert_eq!(archive_status.name, "feature_roundtrip"); - assert!(archive_status.can_archive, "{archive_status:?}"); - assert!(archive_status.errors.is_empty(), "{archive_status:?}"); - assert_eq!(archive_status.projects.len(), 1); - assert_eq!(archive_status.projects[0].branch_name, "feature_roundtrip"); - - archive_worktree_impl(&label, "feature_roundtrip".to_string()).expect("archive worktree"); - - let workspace_path = workspace.path().to_string_lossy().to_string(); - let saved = load_workspace_config(&workspace_path); - assert!(saved - .archived_worktrees - .contains(&"feature_roundtrip".to_string())); - - let visible_after_archive = - list_worktrees_impl(&label, false).expect("list non-archived worktrees"); - assert!(visible_after_archive.is_empty()); - - let archived = list_worktrees_impl(&label, true).expect("list archived worktrees"); - assert_eq!(archived.len(), 1); - assert_eq!(archived[0].name, "feature_roundtrip"); - assert!(archived[0].is_archived); - assert!(archived[0].projects.is_empty()); - } - - #[serial] - #[test] - fn restore_worktree_reregisters_existing_archived_project_and_clears_archive_flag() { - let workspace = tempfile::tempdir().expect("create workspace"); - let project_path = make_origin_backed_project(workspace.path(), "demo"); - run_git(&project_path, &["branch", "restore_feature"]); - - let mut config = workspace_config(vec![project_config("demo")]); - config.archived_worktrees = vec!["restore_feature".to_string()]; - let label = bind_workspace(workspace.path(), &config); - - let placeholder_project = workspace - .path() - .join("worktrees") - .join("restore_feature") - .join("projects") - .join("demo"); - std::fs::create_dir_all(&placeholder_project).expect("create archived placeholder"); - std::fs::write(placeholder_project.join("placeholder.txt"), "archived\n") - .expect("write placeholder file"); - - restore_worktree_impl(&label, "restore_feature".to_string()).expect("restore worktree"); - - assert_eq!( - git_output(&placeholder_project, &["branch", "--show-current"]), - "restore_feature" - ); - assert!(placeholder_project.join(".git").exists()); - - let workspace_path = workspace.path().to_string_lossy().to_string(); - let saved = load_workspace_config(&workspace_path); - assert!(!saved - .archived_worktrees - .contains(&"restore_feature".to_string())); - } - - #[serial] - #[test] - fn create_worktree_rejects_invalid_name_before_workspace_lookup() { - let err = create_worktree_impl( - "unbound-window", - CreateWorktreeRequest { - name: "-upload-pack=sh".to_string(), - folder_name: None, - projects: vec![], - }, - ) - .unwrap_err(); - - assert_eq!(err, "无效的分支名"); - } - - #[serial] - #[test] - fn list_worktrees_returns_empty_when_worktrees_dir_is_missing() { - let workspace = tempfile::tempdir().expect("create workspace"); - let label = bind_workspace(workspace.path(), &workspace_config(vec![])); - - let items = list_worktrees_impl(&label, false).expect("list worktrees"); - - assert!(items.is_empty()); - } - - #[serial] - #[test] - fn archive_worktree_reports_missing_worktree() { - let workspace = tempfile::tempdir().expect("create workspace"); - let label = bind_workspace(workspace.path(), &workspace_config(vec![])); - - let err = archive_worktree_impl(&label, "missing_feature".to_string()).unwrap_err(); - - assert_eq!(err, "Worktree does not exist"); - } - - #[serial] - #[test] - fn restore_worktree_rejects_invalid_name_before_workspace_lookup() { - let err = restore_worktree_impl("unbound-window", "bad..name".to_string()).unwrap_err(); - - assert_eq!(err, "无效的分支名"); - } - - #[serial] - #[test] - fn terminate_worktree_locking_process_rejects_path_traversal_name() { - let err = terminate_worktree_locking_process_impl( - "unbound-window", - "../feature".to_string(), - 123, - "start".to_string(), - ) - .unwrap_err(); - - assert_eq!(err, "Invalid worktree name"); - } - - #[serial] - #[test] - fn scan_linked_folders_internal_reports_missing_path() { - let missing = tempfile::tempdir() - .expect("create temp dir") - .path() - .join("missing-project"); - - let err = scan_linked_folders_internal(&missing.to_string_lossy()).unwrap_err(); - - assert_eq!(err, format!("Path does not exist: {}", missing.display())); - } - - #[serial] - #[test] - fn load_and_save_worktree_mapping_round_trip_display_name() { - let temp = tempfile::tempdir().expect("create temp dir"); - let mapping_path = temp.path().join("mapping.json"); - let mut mapping = HashMap::new(); - mapping.insert("folder_alias".to_string(), "Display Name".to_string()); - - save_worktree_mapping(&mapping_path, &mapping); - let loaded = load_worktree_mapping(&mapping_path); - - assert_eq!( - loaded.get("folder_alias").map(String::as_str), - Some("Display Name") - ); - } - - #[serial] - #[test] - fn create_worktree_tracks_remote_branch_with_alias_links_color_and_scanning() { - let workspace = tempfile::tempdir().expect("create workspace"); - let project_path = make_origin_backed_project(workspace.path(), "demo"); - run_git(&project_path, &["checkout", "-b", "remote_feature"]); - std::fs::write(project_path.join("remote.txt"), "remote branch\n") - .expect("write remote branch file"); - run_git(&project_path, &["add", "remote.txt"]); - run_git(&project_path, &["commit", "-m", "remote branch commit"]); - run_git(&project_path, &["push", "-u", "origin", "remote_feature"]); - run_git(&project_path, &["checkout", "main"]); - run_git(&project_path, &["branch", "-D", "remote_feature"]); - run_git(&project_path, &["fetch", "origin"]); - - std::fs::create_dir(project_path.join("node_modules")).expect("create linked folder"); - std::fs::write( - project_path.join("node_modules").join("cache.txt"), - "cache\n", - ) - .expect("write linked folder file"); - std::fs::write(workspace.path().join(".env"), "A=1\n").expect("write workspace file"); - std::fs::create_dir(workspace.path().join("vault_shared")).expect("create vault item"); - - let mut config = workspace_config(vec![project_config("demo")]); - config.projects[0].linked_folders = vec!["node_modules".to_string()]; - config.linked_workspace_items = vec![".env".to_string()]; - config.vault_linked_workspace_items = vec!["vault_shared".to_string(), ".env".to_string()]; - let label = bind_workspace(workspace.path(), &config); - - let created_path = create_worktree_impl( - &label, - CreateWorktreeRequest { - name: "remote_feature".to_string(), - folder_name: Some("remote_folder".to_string()), - projects: vec![CreateProjectRequest { - name: "demo".to_string(), - base_branch: "main".to_string(), - }], - }, - ) - .expect("create aliased worktree from remote branch"); - let worktree_path = PathBuf::from(created_path); - let wt_project_path = worktree_path.join("projects").join("demo"); - - assert_eq!( - git_output(&wt_project_path, &["branch", "--show-current"]), - "remote_feature" - ); - assert!(wt_project_path.join("remote.txt").exists()); - assert!(worktree_path.join(".env").symlink_metadata().is_ok()); - assert!(worktree_path - .join("vault_shared") - .symlink_metadata() - .is_ok()); - assert!(wt_project_path - .join("node_modules") - .symlink_metadata() - .is_ok()); - - let main_status = get_main_workspace_status_impl(&label).expect("main status"); - assert_eq!(main_status.name, "Test Workspace"); - assert_eq!(main_status.projects.len(), 1); - assert_eq!(main_status.projects[0].current_branch, "main"); - assert_eq!( - main_status.projects[0].linked_folders, - vec!["node_modules".to_string()] - ); - - let scanned = - scan_linked_folders_internal(&project_path.to_string_lossy()).expect("scan folders"); - let node_modules = scanned - .iter() - .find(|folder| folder.relative_path == "node_modules") - .expect("node_modules scan result"); - assert_eq!(node_modules.display_name, "node_modules"); - assert!(node_modules.is_recommended); - - let listed = list_worktrees_impl(&label, false).expect("list worktrees"); - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].name, "remote_folder"); - assert_eq!(listed[0].display_name.as_deref(), Some("remote_feature")); - assert!(listed[0].color.is_none()); - - update_worktree_color_impl( - &label, - "remote_folder".to_string(), - Some(crate::types::WorktreeColor::Blue), - ) - .expect("set color"); - let listed = list_worktrees_impl(&label, false).expect("list colored worktree"); - assert_eq!(listed[0].color, Some(crate::types::WorktreeColor::Blue)); - - update_worktree_color_impl(&label, "remote_folder".to_string(), None) - .expect("remove color"); - let listed = list_worktrees_impl(&label, false).expect("list uncolored worktree"); - assert!(listed[0].color.is_none()); - } - - #[serial] - #[test] - fn archive_and_delete_aliased_worktree_removes_directory_mapping_and_local_branch() { - let workspace = tempfile::tempdir().expect("create workspace"); - let project_path = make_origin_backed_project(workspace.path(), "demo"); - let label = bind_workspace( - workspace.path(), - &workspace_config(vec![project_config("demo")]), - ); - - let created_path = create_worktree_impl( - &label, - CreateWorktreeRequest { - name: "delete_feature".to_string(), - folder_name: Some("delete_folder".to_string()), - projects: vec![CreateProjectRequest { - name: "demo".to_string(), - base_branch: "main".to_string(), - }], - }, - ) - .expect("create worktree to delete"); - let worktree_path = PathBuf::from(created_path); - assert!(worktree_path.exists()); - assert!(!git_output(&project_path, &["branch", "--list", "delete_feature"]).is_empty()); - - archive_worktree_impl(&label, "delete_folder".to_string()).expect("archive worktree"); - let workspace_path = workspace.path().to_string_lossy().to_string(); - let archived = load_workspace_config(&workspace_path); - assert!(archived - .archived_worktrees - .contains(&"delete_folder".to_string())); - assert!(worktree_path.exists()); - - delete_archived_worktree_impl(&label, "delete_folder".to_string()) - .expect("delete archived worktree"); - - assert!(!worktree_path.exists()); - let saved = load_workspace_config(&workspace_path); - assert!(!saved - .archived_worktrees - .contains(&"delete_folder".to_string())); - assert!(git_output(&project_path, &["branch", "--list", "delete_feature"]).is_empty()); - let mapping = - load_worktree_mapping(&workspace.path().join("worktrees").join("mapping.json")); - assert!(!mapping.contains_key("delete_folder")); - } - - #[serial] - #[test] - fn add_project_to_aliased_worktree_uses_mapped_branch_and_reports_conflicts() { - let workspace = tempfile::tempdir().expect("create workspace"); - make_origin_backed_project(workspace.path(), "demo"); - let api_path = make_origin_backed_project(workspace.path(), "api"); - let label = bind_workspace( - workspace.path(), - &workspace_config(vec![project_config("demo"), project_config("api")]), - ); - - create_worktree_impl( - &label, - CreateWorktreeRequest { - name: "mapped_feature".to_string(), - folder_name: Some("mapped_folder".to_string()), - projects: vec![CreateProjectRequest { - name: "demo".to_string(), - base_branch: "main".to_string(), - }], - }, - ) - .expect("create initial worktree"); - - add_project_to_worktree_impl( - &label, - AddProjectToWorktreeRequest { - worktree_name: "mapped_folder".to_string(), - project_name: "api".to_string(), - base_branch: "main".to_string(), - }, - ) - .expect("add api project to worktree"); - - let api_in_worktree = workspace - .path() - .join("worktrees") - .join("mapped_folder") - .join("projects") - .join("api"); - assert_eq!( - git_output(&api_in_worktree, &["branch", "--show-current"]), - "mapped_feature" - ); - assert!(!git_output(&api_path, &["branch", "--list", "mapped_feature"]).is_empty()); - - let duplicate = add_project_to_worktree_impl( - &label, - AddProjectToWorktreeRequest { - worktree_name: "mapped_folder".to_string(), - project_name: "api".to_string(), - base_branch: "main".to_string(), - }, - ) - .unwrap_err(); - assert!( - duplicate.contains("already exists in worktree"), - "{duplicate}" - ); - - let missing = add_project_to_worktree_impl( - &label, - AddProjectToWorktreeRequest { - worktree_name: "mapped_folder".to_string(), - project_name: "missing".to_string(), - base_branch: "main".to_string(), - }, - ) - .unwrap_err(); - assert!( - missing.contains("does not exist in main workspace"), - "{missing}" - ); - } - - #[serial] - #[test] - fn worktree_status_reports_dirty_project_before_archive() { - let workspace = tempfile::tempdir().expect("create workspace"); - make_origin_backed_project(workspace.path(), "demo"); - let label = bind_workspace( - workspace.path(), - &workspace_config(vec![project_config("demo")]), - ); - let created_path = create_worktree_impl( - &label, - CreateWorktreeRequest { - name: "dirty_feature".to_string(), - folder_name: None, - projects: vec![CreateProjectRequest { - name: "demo".to_string(), - base_branch: "main".to_string(), - }], - }, - ) - .expect("create worktree"); - let wt_project_path = PathBuf::from(created_path).join("projects").join("demo"); - std::fs::write(wt_project_path.join("README.md"), "dirty\n").expect("dirty readme"); - - let status = check_worktree_status_impl(&label, "dirty_feature".to_string()) - .expect("check dirty worktree status"); - - assert_eq!(status.name, "dirty_feature"); - assert!(!status.can_archive, "{status:?}"); - assert_eq!(status.projects.len(), 1); - assert!(status.projects[0].has_uncommitted); - assert!( - status.errors.iter().any(|error| error.contains("demo")), - "{status:?}" - ); - } - - #[serial] - #[test] - fn deploy_to_main_and_exit_occupation_round_trip_with_force_cleanup() { - let workspace = tempfile::tempdir().expect("create workspace"); - make_origin_backed_project(workspace.path(), "demo"); - let label = bind_workspace( - workspace.path(), - &workspace_config(vec![project_config("demo")]), - ); - let created_path = create_worktree_impl( - &label, - CreateWorktreeRequest { - name: "occupy_feature".to_string(), - folder_name: None, - projects: vec![CreateProjectRequest { - name: "demo".to_string(), - base_branch: "main".to_string(), - }], - }, - ) - .expect("create worktree"); - let workspace_path = workspace.path().to_string_lossy().to_string(); - let main_project_path = workspace.path().join("projects").join("demo"); - let wt_project_path = PathBuf::from(created_path).join("projects").join("demo"); - - let deployed = - deploy_to_main_impl(&label, "occupy_feature".to_string()).expect("deploy to main"); - - assert!(deployed.success, "{deployed:?}"); - assert_eq!(deployed.switched_projects, vec!["demo".to_string()]); - let occupation = load_occupation_state(&workspace_path).expect("occupation state saved"); - assert_eq!(occupation.worktree_name, "occupy_feature"); - assert_eq!( - occupation.original_branches.get("demo").map(String::as_str), - Some("main") - ); - assert_eq!( - occupation.worktree_branches.get("demo").map(String::as_str), - Some("occupy_feature") - ); - assert_eq!( - git_output(&main_project_path, &["branch", "--show-current"]), - "occupy_feature" - ); - assert_eq!( - git_output(&wt_project_path, &["rev-parse", "--abbrev-ref", "HEAD"]), - "HEAD" - ); - assert!(get_main_occupation_impl(&label) - .expect("get occupation") - .is_some()); - - std::fs::write(main_project_path.join("dirty.txt"), "dirty\n").expect("dirty main"); - let err = exit_main_occupation_impl(&label, false).unwrap_err(); - assert!(err.contains("uncommitted changes"), "{err}"); - assert!(load_occupation_state(&workspace_path).is_some()); - - exit_main_occupation_impl(&label, true).expect("force exit occupation"); - - assert!(load_occupation_state(&workspace_path).is_none()); - assert_eq!( - git_output(&main_project_path, &["branch", "--show-current"]), - "main" - ); - assert_eq!( - git_output(&wt_project_path, &["branch", "--show-current"]), - "occupy_feature" - ); - assert!(!main_project_path.join("dirty.txt").exists()); - assert!(get_main_occupation_impl(&label) - .expect("get cleared occupation") - .is_none()); - } - - #[serial] - #[test] - fn create_worktree_reports_nonexistent_base_branch_and_existing_directory_conflict() { - let workspace = tempfile::tempdir().expect("create workspace"); - make_origin_backed_project(workspace.path(), "demo"); - let label = bind_workspace( - workspace.path(), - &workspace_config(vec![project_config("demo")]), - ); - - let missing_base = create_worktree_impl( - &label, - CreateWorktreeRequest { - name: "missing_base_feature".to_string(), - folder_name: None, - projects: vec![CreateProjectRequest { - name: "demo".to_string(), - base_branch: "does-not-exist".to_string(), - }], - }, - ) - .unwrap_err(); - assert!( - missing_base.contains("Failed to create worktree for demo"), - "{missing_base}" - ); - assert!( - missing_base.contains("origin/does-not-exist") - || missing_base.contains("invalid reference"), - "{missing_base}" - ); - - let conflict_project_path = workspace - .path() - .join("worktrees") - .join("conflict_feature") - .join("projects") - .join("demo"); - std::fs::create_dir_all(&conflict_project_path).expect("create conflicting dir"); - std::fs::write(conflict_project_path.join("occupied.txt"), "occupied\n") - .expect("write conflict marker"); - - let path_conflict = create_worktree_impl( - &label, - CreateWorktreeRequest { - name: "conflict_feature".to_string(), - folder_name: None, - projects: vec![CreateProjectRequest { - name: "demo".to_string(), - base_branch: "main".to_string(), - }], - }, - ) - .unwrap_err(); - - assert!( - path_conflict.contains("Failed to create worktree for demo"), - "{path_conflict}" - ); - assert!( - path_conflict.contains("already exists") || path_conflict.contains("not empty"), - "{path_conflict}" - ); - } - - #[serial] - #[test] - fn list_worktrees_filters_hidden_non_project_entries_and_archived_items() { - let workspace = tempfile::tempdir().expect("create workspace"); - let mut config = workspace_config(vec![]); - config - .archived_worktrees - .push("archived_folder".to_string()); - let label = bind_workspace(workspace.path(), &config); - let worktrees_dir = workspace.path().join("worktrees"); - - std::fs::create_dir_all(worktrees_dir.join(".hidden").join("projects")) - .expect("create hidden worktree"); - std::fs::create_dir_all(worktrees_dir.join("no_projects")).expect("create non-project dir"); - std::fs::create_dir_all( - worktrees_dir - .join("active_folder") - .join("projects") - .join("external"), - ) - .expect("create active project dir"); - std::fs::write( - worktrees_dir - .join("active_folder") - .join("projects") - .join("README.md"), - "not a project dir\n", - ) - .expect("write non-dir entry"); - std::fs::create_dir_all( - worktrees_dir - .join("archived_folder") - .join("projects") - .join("archived_project"), - ) - .expect("create archived project dir"); - save_worktree_mapping( - &worktrees_dir.join("mapping.json"), - &HashMap::from([("active_folder".to_string(), "Active Display".to_string())]), - ); - - let active = list_worktrees_impl(&label, false).expect("list active only"); - assert_eq!( - active - .iter() - .map(|item| item.name.as_str()) - .collect::>(), - vec!["active_folder"] - ); - assert_eq!(active[0].display_name.as_deref(), Some("Active Display")); - assert_eq!(active[0].projects.len(), 1); - assert_eq!(active[0].projects[0].name, "external"); - assert!(!active[0].is_archived); - - let all = list_worktrees_impl(&label, true).expect("list including archived"); - let names = all - .iter() - .map(|item| item.name.as_str()) - .collect::>(); - assert!(names.contains(&"active_folder"), "{names:?}"); - assert!(names.contains(&"archived_folder"), "{names:?}"); - assert!(!names.contains(&".hidden"), "{names:?}"); - assert!(!names.contains(&"no_projects"), "{names:?}"); - assert!( - all.iter() - .find(|item| item.name == "archived_folder") - .expect("archived item listed") - .is_archived - ); - } - - #[serial] - #[test] - fn archived_delete_and_deploy_preconditions_report_specific_errors() { - let workspace = tempfile::tempdir().expect("create workspace"); - let config = workspace_config(vec![]); - let label = bind_workspace(workspace.path(), &config); - let workspace_path = workspace.path().to_string_lossy().to_string(); - - let delete_active = - delete_archived_worktree_impl(&label, "not_archived".to_string()).unwrap_err(); - assert!( - delete_active.contains("Can only delete archived worktrees"), - "{delete_active}" - ); - - let mut archived_config = load_workspace_config(&workspace_path); - archived_config - .archived_worktrees - .push("ghost_archive".to_string()); - save_workspace_config_internal(&workspace_path, &archived_config) - .expect("save archived marker"); - let delete_missing = - delete_archived_worktree_impl(&label, "ghost_archive".to_string()).unwrap_err(); - assert!( - delete_missing.contains("Archived worktree does not exist"), - "{delete_missing}" - ); - - let missing_worktree = - deploy_to_main_impl(&label, "missing_worktree".to_string()).unwrap_err(); - assert!( - missing_worktree.contains("Worktree 'missing_worktree' does not exist"), - "{missing_worktree}" - ); - - let worktrees_dir = workspace.path().join("worktrees"); - std::fs::create_dir_all(worktrees_dir.join("without_projects")) - .expect("create worktree shell"); - let no_projects_dir = - deploy_to_main_impl(&label, "without_projects".to_string()).unwrap_err(); - assert!( - no_projects_dir.contains("Worktree has no projects directory"), - "{no_projects_dir}" - ); - - std::fs::create_dir_all(worktrees_dir.join("empty_projects").join("projects")) - .expect("create empty projects dir"); - let no_project_entries = - deploy_to_main_impl(&label, "empty_projects".to_string()).unwrap_err(); - assert!( - no_project_entries.contains("No projects found in worktree"), - "{no_project_entries}" - ); - - save_occupation_state( - &workspace_path, - &MainWorkspaceOccupation { - worktree_name: "busy_feature".to_string(), - original_branches: HashMap::new(), - worktree_branches: HashMap::new(), - deployed_at: "2026-06-11T00:00:00Z".to_string(), - }, - ) - .expect("save occupation state"); - let occupied = deploy_to_main_impl(&label, "empty_projects".to_string()).unwrap_err(); - assert!( - occupied.contains("Main workspace is already occupied by worktree 'busy_feature'"), - "{occupied}" - ); - } - - #[serial] - #[test] - fn create_worktree_uses_existing_local_branch_without_creating_remote_tracking_branch() { - let workspace = tempfile::tempdir().expect("create workspace"); - let project_path = make_origin_backed_project(workspace.path(), "demo"); - run_git(&project_path, &["branch", "local_feature"]); - let label = bind_workspace( - workspace.path(), - &workspace_config(vec![project_config("demo")]), - ); - - let created = create_worktree_impl( - &label, - CreateWorktreeRequest { - name: "local_feature".to_string(), - folder_name: None, - projects: vec![CreateProjectRequest { - name: "demo".to_string(), - base_branch: "main".to_string(), - }], - }, - ) - .expect("create worktree from existing local branch"); - let wt_project = PathBuf::from(created).join("projects").join("demo"); - - assert_eq!( - git_output(&wt_project, &["branch", "--show-current"]), - "local_feature" - ); - assert!(!git_output(&project_path, &["branch", "--list", "local_feature"]).is_empty()); - } - - #[serial] - #[test] - fn archive_and_status_accept_worktree_without_projects_directory() { - let workspace = tempfile::tempdir().expect("create workspace"); - let label = bind_workspace(workspace.path(), &workspace_config(vec![])); - let worktree_path = workspace.path().join("worktrees").join("shell_only"); - std::fs::create_dir_all(&worktree_path).expect("create shell worktree"); - - let status = check_worktree_status_impl(&label, "shell_only".to_string()) - .expect("status for shell-only worktree"); - assert!(status.can_archive, "{status:?}"); - assert!(status.projects.is_empty()); - assert!(status.errors.is_empty()); - - archive_worktree_impl(&label, "shell_only".to_string()).expect("archive shell worktree"); - let saved = load_workspace_config(&workspace.path().to_string_lossy()); - assert!(saved.archived_worktrees.contains(&"shell_only".to_string())); - assert!(worktree_path.exists()); - } - - #[serial] - #[test] - fn restore_worktree_recreates_missing_branch_and_relinks_workspace_items() { - let workspace = tempfile::tempdir().expect("create workspace"); - let project_path = make_origin_backed_project(workspace.path(), "demo"); - std::fs::create_dir(project_path.join("cache")).expect("create linked folder"); - std::fs::write(project_path.join("cache").join("data.txt"), "cache\n") - .expect("write linked folder data"); - std::fs::write(workspace.path().join("shared.env"), "A=1\n").expect("write shared item"); - - let mut config = workspace_config(vec![project_config("demo")]); - config.projects[0].linked_folders = vec!["cache".to_string()]; - config.linked_workspace_items = vec!["shared.env".to_string()]; - config.archived_worktrees = vec!["restore_missing_branch".to_string()]; - let label = bind_workspace(workspace.path(), &config); - let archived_project = workspace - .path() - .join("worktrees") - .join("restore_missing_branch") - .join("projects") - .join("demo"); - std::fs::create_dir_all(&archived_project).expect("create archived project placeholder"); - std::fs::write(archived_project.join("placeholder.txt"), "archived\n") - .expect("write placeholder"); - - restore_worktree_impl(&label, "restore_missing_branch".to_string()) - .expect("restore by creating missing branch from origin/main"); - - assert_eq!( - git_output(&archived_project, &["branch", "--show-current"]), - "restore_missing_branch" - ); - assert!(archived_project.join("cache").symlink_metadata().is_ok()); - assert!(workspace - .path() - .join("worktrees") - .join("restore_missing_branch") - .join("shared.env") - .symlink_metadata() - .is_ok()); - let saved = load_workspace_config(&workspace.path().to_string_lossy()); - assert!(!saved - .archived_worktrees - .contains(&"restore_missing_branch".to_string())); - } - - #[serial] - #[test] - fn restore_worktree_skips_missing_main_project_and_invalid_base_branch() { - let workspace = tempfile::tempdir().expect("create workspace"); - make_origin_backed_project(workspace.path(), "demo"); - let mut invalid = project_config("demo"); - invalid.base_branch = "-bad".to_string(); - let mut missing = project_config("missing"); - missing.base_branch = "main".to_string(); - let mut config = workspace_config(vec![invalid, missing]); - config.archived_worktrees = vec!["restore_edge".to_string()]; - let label = bind_workspace(workspace.path(), &config); - - let archived_root = workspace.path().join("worktrees").join("restore_edge"); - std::fs::create_dir_all(archived_root.join("projects").join("demo")) - .expect("create invalid-base placeholder"); - std::fs::create_dir_all(archived_root.join("projects").join("missing")) - .expect("create missing-main placeholder"); - - restore_worktree_impl(&label, "restore_edge".to_string()) - .expect("restore skips unrecoverable projects and clears archive marker"); - - let saved = load_workspace_config(&workspace.path().to_string_lossy()); - assert!(!saved - .archived_worktrees - .contains(&"restore_edge".to_string())); - assert!(!archived_root.join("projects").join("demo").exists()); - assert!(archived_root.join("projects").join("missing").exists()); - } - - #[serial] - #[test] - fn add_project_to_worktree_tracks_existing_remote_branch_and_creates_projects_dir() { - let workspace = tempfile::tempdir().expect("create workspace"); - let api_path = make_origin_backed_project(workspace.path(), "api"); - run_git(&api_path, &["checkout", "-b", "remote_add_feature"]); - std::fs::write(api_path.join("api.txt"), "api\n").expect("write api branch file"); - run_git(&api_path, &["add", "api.txt"]); - run_git(&api_path, &["commit", "-m", "api branch"]); - run_git(&api_path, &["push", "-u", "origin", "remote_add_feature"]); - run_git(&api_path, &["checkout", "main"]); - run_git(&api_path, &["branch", "-D", "remote_add_feature"]); - run_git(&api_path, &["fetch", "origin"]); - - let label = bind_workspace( - workspace.path(), - &workspace_config(vec![project_config("api")]), - ); - std::fs::create_dir_all( - workspace - .path() - .join("worktrees") - .join("remote_add_feature"), - ) - .expect("create empty worktree dir"); - - add_project_to_worktree_impl( - &label, - AddProjectToWorktreeRequest { - worktree_name: "remote_add_feature".to_string(), - project_name: "api".to_string(), - base_branch: "main".to_string(), - }, - ) - .expect("add project by tracking existing remote branch"); - let api_in_worktree = workspace - .path() - .join("worktrees") - .join("remote_add_feature") - .join("projects") - .join("api"); - - assert_eq!( - git_output(&api_in_worktree, &["branch", "--show-current"]), - "remote_add_feature" - ); - assert!(api_in_worktree.join("api.txt").exists()); - } - - #[serial] - #[test] - fn deploy_to_main_reports_dirty_main_and_failed_detach_without_saving_occupation() { - let dirty_workspace = tempfile::tempdir().expect("create dirty workspace"); - make_origin_backed_project(dirty_workspace.path(), "demo"); - let dirty_label = bind_workspace( - dirty_workspace.path(), - &workspace_config(vec![project_config("demo")]), - ); - create_worktree_impl( - &dirty_label, - CreateWorktreeRequest { - name: "dirty_main_feature".to_string(), - folder_name: None, - projects: vec![CreateProjectRequest { - name: "demo".to_string(), - base_branch: "main".to_string(), - }], - }, - ) - .expect("create worktree for dirty deploy check"); - let dirty_main = dirty_workspace.path().join("projects").join("demo"); - std::fs::write(dirty_main.join("dirty.txt"), "dirty\n").expect("dirty main workspace"); - - let dirty_err = - deploy_to_main_impl(&dirty_label, "dirty_main_feature".to_string()).unwrap_err(); - assert!(dirty_err.contains("uncommitted changes"), "{dirty_err}"); - - let broken_workspace = tempfile::tempdir().expect("create broken workspace"); - make_origin_backed_project(broken_workspace.path(), "broken"); - let broken_label = bind_workspace( - broken_workspace.path(), - &workspace_config(vec![project_config("broken")]), - ); - std::fs::create_dir_all( - broken_workspace - .path() - .join("worktrees") - .join("broken_feature") - .join("projects") - .join("broken"), - ) - .expect("create non-git worktree project"); - - let result = deploy_to_main_impl(&broken_label, "broken_feature".to_string()) - .expect("deploy returns per-project failure result"); - assert!(!result.success, "{result:?}"); - assert!(result.switched_projects.is_empty()); - assert_eq!(result.failed_projects.len(), 1); - assert!(result.failed_projects[0] - .error - .contains("Failed to detach worktree HEAD")); - assert!(load_occupation_state(&broken_workspace.path().to_string_lossy()).is_none()); - } - - #[serial] - #[test] - fn process_termination_and_occupation_errors_are_reported_before_mutation() { - let workspace = tempfile::tempdir().expect("create workspace"); - let label = bind_workspace(workspace.path(), &workspace_config(vec![])); - std::fs::create_dir_all(workspace.path().join("worktrees").join("locked_feature")) - .expect("create worktree dir"); - - let lock_err = terminate_worktree_locking_process_impl( - &label, - "locked_feature".to_string(), - 12345, - "start-time".to_string(), - ) - .unwrap_err(); - assert_eq!(lock_err, "Process is no longer locking this worktree"); - - let exit_err = exit_main_occupation_impl(&label, false).unwrap_err(); - assert_eq!(exit_err, "Main workspace is not currently occupied"); - } - - #[serial] - #[test] - fn empty_workspace_impls_report_expected_preconditions_without_global_state() { - let workspace = tempfile::tempdir().expect("create workspace"); - let label = bind_workspace(workspace.path(), &workspace_config(vec![])); - let add_request = AddProjectToWorktreeRequest { - worktree_name: "feature".to_string(), - project_name: "demo".to_string(), - base_branch: "main".to_string(), - }; - - assert!(list_worktrees_impl(&label, false).unwrap().is_empty()); - update_worktree_color_impl( - &label, - "feature".to_string(), - Some(crate::types::WorktreeColor::Green), - ) - .expect("update worktree color"); - let saved = load_workspace_config(&workspace.path().to_string_lossy()); - assert_eq!( - saved.worktree_colors.get("feature"), - Some(&crate::types::WorktreeColor::Green) - ); - assert_eq!( - archive_worktree_impl(&label, "feature".to_string()).unwrap_err(), - "Worktree does not exist" - ); - assert_eq!( - check_worktree_status_impl(&label, "feature".to_string()).unwrap_err(), - "Worktree does not exist" - ); - assert_eq!( - restore_worktree_impl(&label, "feature".to_string()).unwrap_err(), - "Archived worktree does not exist" - ); - assert_eq!( - delete_archived_worktree_impl(&label, "feature".to_string()).unwrap_err(), - "Can only delete archived worktrees" - ); - assert_eq!( - add_project_to_worktree_impl(&label, add_request).unwrap_err(), - "Worktree 'feature' does not exist" - ); - assert_eq!( - deploy_to_main_impl(&label, "feature".to_string()).unwrap_err(), - "Worktree 'feature' does not exist" - ); - assert_eq!( - exit_main_occupation_impl(&label, false).unwrap_err(), - "Main workspace is not currently occupied" - ); - assert!(get_main_occupation_impl(&label).unwrap().is_none()); - } - - #[serial] - #[test] - fn add_project_to_worktree_rejects_invalid_base_branch_before_workspace_lookup() { - let err = add_project_to_worktree_impl( - "unbound-worktree-window", - AddProjectToWorktreeRequest { - worktree_name: "feature".to_string(), - project_name: "demo".to_string(), - base_branch: "-bad".to_string(), - }, - ) - .unwrap_err(); - - assert_eq!(err, "无效的分支名"); - } - - #[serial] - #[test] - fn load_worktree_mapping_returns_empty_for_missing_and_invalid_json() { - let temp = tempfile::tempdir().expect("create temp dir"); - let missing = temp.path().join("missing-mapping.json"); - let invalid = temp.path().join("mapping.json"); - std::fs::write(&invalid, "{not json").expect("write invalid mapping"); - - assert!(load_worktree_mapping(&missing).is_empty()); - assert!(load_worktree_mapping(&invalid).is_empty()); - } - - #[serial] - #[test] - fn delete_archived_worktree_rejects_invalid_mapped_branch_without_deleting() { - let workspace = tempfile::tempdir().expect("create workspace"); - let mut config = workspace_config(vec![]); - config.archived_worktrees.push("folder_alias".to_string()); - let label = bind_workspace(workspace.path(), &config); - let worktrees_dir = workspace.path().join("worktrees"); - let archived_dir = worktrees_dir.join("folder_alias"); - std::fs::create_dir_all(&archived_dir).expect("create archived worktree dir"); - save_worktree_mapping( - &worktrees_dir.join("mapping.json"), - &HashMap::from([("folder_alias".to_string(), "-bad".to_string())]), - ); - - let err = delete_archived_worktree_impl(&label, "folder_alias".to_string()).unwrap_err(); - - assert_eq!(err, "无效的分支名"); - assert!(archived_dir.exists()); - let saved = load_workspace_config(&workspace.path().to_string_lossy()); - assert!(saved - .archived_worktrees - .contains(&"folder_alias".to_string())); - } - - #[serial] - #[test] - fn add_project_to_worktree_rejects_invalid_mapped_branch_before_fetch() { - let workspace = tempfile::tempdir().expect("create workspace"); - make_origin_backed_project(workspace.path(), "demo"); - let label = bind_workspace( - workspace.path(), - &workspace_config(vec![project_config("demo")]), - ); - let worktrees_dir = workspace.path().join("worktrees"); - std::fs::create_dir_all(worktrees_dir.join("folder_alias")) - .expect("create target worktree dir"); - save_worktree_mapping( - &worktrees_dir.join("mapping.json"), - &HashMap::from([("folder_alias".to_string(), "-bad".to_string())]), - ); - - let err = add_project_to_worktree_impl( - &label, - AddProjectToWorktreeRequest { - worktree_name: "folder_alias".to_string(), - project_name: "demo".to_string(), - base_branch: "main".to_string(), - }, - ) - .unwrap_err(); - - assert_eq!(err, "无效的分支名"); - assert!(!worktrees_dir - .join("folder_alias") - .join("projects") - .join("demo") - .exists()); - } - - #[serial] - #[test] - fn restore_worktree_without_projects_dir_clears_archive_and_relinks_workspace_items() { - let workspace = tempfile::tempdir().expect("create workspace"); - std::fs::write(workspace.path().join("shared.env"), "A=1\n").expect("write shared item"); - let mut config = workspace_config(vec![]); - config.archived_worktrees.push("docs_only".to_string()); - config.linked_workspace_items.push("shared.env".to_string()); - let label = bind_workspace(workspace.path(), &config); - let archived_root = workspace.path().join("worktrees").join("docs_only"); - std::fs::create_dir_all(&archived_root).expect("create archived worktree shell"); - - restore_worktree_impl(&label, "docs_only".to_string()).expect("restore shell worktree"); - - let saved = load_workspace_config(&workspace.path().to_string_lossy()); - assert!(!saved.archived_worktrees.contains(&"docs_only".to_string())); - assert!(archived_root.join("shared.env").symlink_metadata().is_ok()); - } - - #[serial] - #[test] - fn get_main_workspace_status_skips_missing_projects_and_reports_existing_metadata() { - let workspace = tempfile::tempdir().expect("create workspace"); - make_origin_backed_project(workspace.path(), "demo"); - let mut demo = project_config("demo"); - demo.linked_folders = vec!["node_modules".to_string()]; - let label = bind_workspace( - workspace.path(), - &workspace_config(vec![demo, project_config("missing")]), - ); - - let status = get_main_workspace_status_impl(&label).expect("main workspace status"); - - assert_eq!(status.name, "Test Workspace"); - assert_eq!(status.projects.len(), 1); - assert_eq!(status.projects[0].name, "demo"); - assert_eq!(status.projects[0].base_branch, "main"); - assert_eq!(status.projects[0].test_branch, "test"); - assert_eq!( - status.projects[0].linked_folders, - vec!["node_modules".to_string()] - ); - } - - #[serial] - #[test] - fn scan_linked_folders_internal_sorts_recommended_before_larger_generic_folder() { - let project = tempfile::tempdir().expect("create project dir"); - let node_modules = project.path().join("node_modules"); - let dist = project.path().join("dist"); - std::fs::create_dir(&node_modules).expect("create node_modules"); - std::fs::create_dir(&dist).expect("create dist"); - std::fs::write(node_modules.join("tiny.bin"), [1_u8; 4]).expect("write tiny file"); - std::fs::write(dist.join("large.bin"), [2_u8; 1024]).expect("write large file"); - - let scanned = - scan_linked_folders_internal(&project.path().to_string_lossy()).expect("scan folders"); - - assert!(scanned.len() >= 2, "{scanned:?}"); - assert_eq!(scanned[0].relative_path, "node_modules"); - assert!(scanned[0].is_recommended); - let dist = scanned - .iter() - .find(|folder| folder.relative_path == "dist") - .expect("dist scan result"); - assert!(!dist.is_recommended); - assert!(dist.size_bytes > scanned[0].size_bytes); - } -} diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs deleted file mode 100644 index 5e3f2a8..0000000 --- a/src-tauri/src/config.rs +++ /dev/null @@ -1,551 +0,0 @@ -use std::fs; -use std::path::PathBuf; - -use crate::state::{GLOBAL_CONFIG_CACHE, WINDOW_WORKSPACES, WORKSPACE_CONFIG_CACHE}; -use crate::types::{GlobalConfig, MainWorkspaceOccupation, WorkspaceConfig}; - -// ==================== 配置路径 ==================== - -pub(crate) fn get_global_config_path() -> PathBuf { - #[cfg(target_os = "windows")] - { - if let Ok(appdata) = std::env::var("APPDATA") { - return PathBuf::from(appdata) - .join("worktree-manager") - .join("global.json"); - } - if let Ok(userprofile) = std::env::var("USERPROFILE") { - return PathBuf::from(userprofile) - .join(".config") - .join("worktree-manager") - .join("global.json"); - } - PathBuf::from(".") - .join("worktree-manager") - .join("global.json") - } - #[cfg(not(target_os = "windows"))] - { - let home = std::env::var("HOME").unwrap_or_default(); - PathBuf::from(home) - .join(".config") - .join("worktree-manager") - .join("global.json") - } -} - -pub(crate) fn get_workspace_config_path(workspace_path: &str) -> PathBuf { - PathBuf::from(workspace_path).join(".worktree-manager.json") -} - -// ==================== 全局配置加载/保存 ==================== - -pub fn load_global_config() -> GlobalConfig { - { - let cache = GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if let Some(ref config) = *cache { - return config.clone(); - } - } - - let config_path = get_global_config_path(); - let mut config = if config_path.exists() { - match fs::read_to_string(&config_path) { - Ok(content) => match serde_json::from_str::(&content) { - Ok(cfg) => cfg, - Err(e) => { - log::warn!("Failed to parse global config at {:?}: {}", config_path, e); - GlobalConfig::default() - } - }, - Err(e) => { - log::warn!("Failed to read global config at {:?}: {}", config_path, e); - GlobalConfig::default() - } - } - } else { - let default_config = GlobalConfig::default(); - let _ = save_global_config_internal(&default_config); - default_config - }; - - if config.commit_prefix_templates.is_empty() { - config.commit_prefix_templates = crate::types::default_prefix_templates(); - let _ = save_global_config_internal(&config); - } - - { - let mut cache = GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = Some(config.clone()); - } - - config -} - -pub fn save_global_config_internal(config: &GlobalConfig) -> Result<(), String> { - let config_path = get_global_config_path(); - - if let Some(parent) = config_path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create config directory: {}", e))?; - } - - let content = serde_json::to_string_pretty(config) - .map_err(|e| format!("Failed to serialize config: {}", e))?; - - fs::write(&config_path, content).map_err(|e| format!("Failed to write config file: {}", e))?; - - { - let mut cache = GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = Some(config.clone()); - } - - Ok(()) -} - -// ==================== Workspace 配置加载/保存 ==================== - -pub fn load_workspace_config(workspace_path: &str) -> WorkspaceConfig { - { - let cache = WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if let Some((ref cached_path, ref config)) = *cache { - if cached_path == workspace_path { - return config.clone(); - } - } - } - - let config_path = get_workspace_config_path(workspace_path); - let config = if config_path.exists() { - fs::read_to_string(&config_path) - .map_err(|e| { - log::warn!( - "Failed to read workspace config at {:?}: {}", - config_path, - e - ) - }) - .ok() - .and_then(|content| { - serde_json::from_str::(&content) - .map_err(|e| { - log::warn!( - "Failed to parse workspace config at {:?}: {}", - config_path, - e - ) - }) - .ok() - }) - .unwrap_or_default() - } else { - let default_config = WorkspaceConfig::default(); - let _ = save_workspace_config_internal(workspace_path, &default_config); - default_config - }; - - { - let mut cache = WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = Some((workspace_path.to_string(), config.clone())); - } - - config -} - -pub fn save_workspace_config_internal( - workspace_path: &str, - config: &WorkspaceConfig, -) -> Result<(), String> { - let config_path = get_workspace_config_path(workspace_path); - - let content = serde_json::to_string_pretty(config) - .map_err(|e| format!("Failed to serialize config: {}", e))?; - - fs::write(&config_path, content).map_err(|e| format!("Failed to write config file: {}", e))?; - - { - let mut cache = WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = Some((workspace_path.to_string(), config.clone())); - } - - Ok(()) -} - -// ==================== 获取当前 Workspace ==================== - -/// 获取窗口绑定的 workspace 路径,优先从 WINDOW_WORKSPACES 获取, -/// 回退到 global config 的 current_workspace -pub(crate) fn get_window_workspace_path(window_label: &str) -> Option { - // 先查窗口绑定 - { - let map = WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if let Some(path) = map.get(window_label) { - return Some(path.clone()); - } - } - // 回退到全局 - let global = load_global_config(); - global.current_workspace -} - -pub(crate) fn get_window_workspace_config(window_label: &str) -> Option<(String, WorkspaceConfig)> { - let workspace_path = get_window_workspace_path(window_label)?; - let config = load_workspace_config(&workspace_path); - Some((workspace_path, config)) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::Value; - use serial_test::serial; - - struct ConfigStateGuard { - previous_workspace: Option<(String, WorkspaceConfig)>, - } - - impl ConfigStateGuard { - fn isolated() -> Self { - let previous_workspace = { - let mut cache = crate::state::WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *cache) - }; - - Self { previous_workspace } - } - } - - impl Drop for ConfigStateGuard { - fn drop(&mut self) { - let mut workspace = crate::state::WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *workspace = self.previous_workspace.take(); - } - } - - fn clear_workspace_config_cache() { - let mut cache = crate::state::WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = None; - } - - #[serial] - #[test] - fn global_config_round_trip() { - let config = GlobalConfig { - current_workspace: Some("/tmp/workspace".to_string()), - ngrok_token: Some(" my-ngrok ".to_string()), - share_password: Some("persisted-secret".to_string()), - dashscope_api_key: Some("my-dashscope".to_string()), - ..GlobalConfig::default() - }; - - let serialized = serde_json::to_string_pretty(&config).expect("serialize config"); - let value: Value = serde_json::from_str(&serialized).expect("parse serialized config"); - let object = value.as_object().expect("config json object"); - - assert_eq!( - object.get("current_workspace"), - Some(&Value::String("/tmp/workspace".to_string())) - ); - assert_eq!( - object.get("ngrok_token"), - Some(&Value::String(" my-ngrok ".to_string())) - ); - assert_eq!( - object.get("share_password"), - Some(&Value::String("persisted-secret".to_string())) - ); - assert_eq!( - object.get("dashscope_api_key"), - Some(&Value::String("my-dashscope".to_string())) - ); - assert!(!object.contains_key("wms_server_url")); - assert!(!object.contains_key("wms_token")); - assert!(!object.contains_key("wms_subdomain")); - assert!(!object.contains_key("wms_jwt")); - assert!(!object.contains_key("device_id")); - assert!( - object.get("commit_prefix_enabled").is_some(), - "commit_prefix_enabled should be serialized" - ); - assert!( - object.get("commit_prefix_templates").is_some(), - "commit_prefix_templates should be serialized" - ); - } - - #[serial] - #[test] - fn global_config_missing_share_password_defaults_to_none() { - let config: GlobalConfig = serde_json::from_value(serde_json::json!({ - "workspaces": [], - "current_workspace": null - })) - .expect("deserialize legacy config"); - - assert_eq!(config.share_password, None); - } - - #[serial] - #[test] - fn load_workspace_config_reads_valid_json_file() { - let _state = ConfigStateGuard::isolated(); - let workspace = tempfile::tempdir().expect("create workspace dir"); - std::fs::write( - get_workspace_config_path(workspace.path().to_str().unwrap()), - serde_json::json!({ - "name": "Demo Workspace", - "worktrees_dir": "trees", - "projects": [{ - "name": "app", - "base_branch": "main", - "test_branch": "uat", - "merge_strategy": "merge" - }], - "linked_workspace_items": ["CLAUDE.md"], - "vault_linked_workspace_items": ["Vault"], - "uat_branch": "staging", - "archived_worktrees": ["old-worktree"], - "worktree_colors": {"feature-a": "red"}, - "tags": [{"id": "frontend", "name": "Frontend", "color": "#336699"}] - }) - .to_string(), - ) - .expect("write workspace config"); - - let config = load_workspace_config(workspace.path().to_str().unwrap()); - - assert_eq!(config.name, "Demo Workspace"); - assert_eq!(config.worktrees_dir, "trees"); - assert_eq!(config.projects.len(), 1); - assert_eq!(config.projects[0].name, "app"); - assert_eq!(config.linked_workspace_items, vec!["CLAUDE.md"]); - assert_eq!(config.vault_linked_workspace_items, vec!["Vault"]); - assert_eq!(config.uat_branch, "staging"); - assert_eq!(config.archived_worktrees, vec!["old-worktree"]); - assert_eq!( - config.worktree_colors.get("feature-a"), - Some(&crate::types::WorktreeColor::Red) - ); - assert_eq!(config.tags[0].id, "frontend"); - } - - #[serial] - #[test] - fn load_workspace_config_defaults_missing_optional_fields() { - let _state = ConfigStateGuard::isolated(); - let workspace = tempfile::tempdir().expect("create workspace dir"); - std::fs::write( - get_workspace_config_path(workspace.path().to_str().unwrap()), - r#"{ - "name": "Legacy Workspace", - "worktrees_dir": "worktrees", - "projects": [] - }"#, - ) - .expect("write legacy workspace config"); - - let config = load_workspace_config(workspace.path().to_str().unwrap()); - - assert_eq!(config.name, "Legacy Workspace"); - assert!(config.linked_workspace_items.is_empty()); - assert!(config.vault_linked_workspace_items.is_empty()); - assert_eq!(config.uat_branch, "uat"); - assert!(config.archived_worktrees.is_empty()); - assert!(config.worktree_colors.is_empty()); - assert!(config.tags.is_empty()); - } - - #[serial] - #[test] - fn load_workspace_config_returns_default_for_corrupted_json() { - let _state = ConfigStateGuard::isolated(); - let workspace = tempfile::tempdir().expect("create workspace dir"); - std::fs::write( - get_workspace_config_path(workspace.path().to_str().unwrap()), - "{not valid json", - ) - .expect("write corrupted workspace config"); - - let config = load_workspace_config(workspace.path().to_str().unwrap()); - - assert_eq!(config.name, WorkspaceConfig::default().name); - assert_eq!( - config.worktrees_dir, - WorkspaceConfig::default().worktrees_dir - ); - assert!(config.projects.is_empty()); - } - - #[serial] - #[test] - fn save_workspace_config_writes_file_and_round_trips_after_cache_clear() { - let _state = ConfigStateGuard::isolated(); - let workspace = tempfile::tempdir().expect("create workspace dir"); - let config = WorkspaceConfig { - name: "Round Trip".to_string(), - worktrees_dir: "custom-worktrees".to_string(), - projects: vec![crate::types::ProjectConfig { - name: "api".to_string(), - base_branch: "main".to_string(), - test_branch: "test".to_string(), - merge_strategy: "squash".to_string(), - linked_folders: vec!["target".to_string()], - commit_prefix_index: Some(0), - git_user_name: Some("Test User".to_string()), - git_user_email: Some("test@example.com".to_string()), - tags: vec!["backend".to_string()], - }], - linked_workspace_items: vec!["README.md".to_string()], - vault_linked_workspace_items: vec!["Vault".to_string()], - uat_branch: "qa".to_string(), - archived_worktrees: vec!["archived".to_string()], - worktree_colors: std::collections::HashMap::from([( - "round-trip".to_string(), - crate::types::WorktreeColor::Blue, - )]), - tags: vec![crate::types::TagDefinition { - id: "backend".to_string(), - name: "Backend".to_string(), - color: "#123456".to_string(), - }], - }; - - save_workspace_config_internal(workspace.path().to_str().unwrap(), &config) - .expect("save workspace config"); - clear_workspace_config_cache(); - - let reloaded = load_workspace_config(workspace.path().to_str().unwrap()); - - assert_eq!(reloaded.name, config.name); - assert_eq!(reloaded.worktrees_dir, config.worktrees_dir); - assert_eq!(reloaded.projects[0].linked_folders, vec!["target"]); - assert_eq!(reloaded.linked_workspace_items, vec!["README.md"]); - assert_eq!(reloaded.vault_linked_workspace_items, vec!["Vault"]); - assert_eq!(reloaded.uat_branch, "qa"); - assert_eq!(reloaded.archived_worktrees, vec!["archived"]); - assert_eq!( - reloaded.worktree_colors.get("round-trip"), - Some(&crate::types::WorktreeColor::Blue) - ); - assert_eq!(reloaded.tags[0].name, "Backend"); - } - - #[serial] - #[test] - fn save_workspace_config_updates_existing_file_fields() { - let _state = ConfigStateGuard::isolated(); - let workspace = tempfile::tempdir().expect("create workspace dir"); - let initial = WorkspaceConfig { - name: "Initial".to_string(), - linked_workspace_items: vec!["old.md".to_string()], - ..WorkspaceConfig::default() - }; - let updated = WorkspaceConfig { - name: "Updated".to_string(), - worktrees_dir: "updated-worktrees".to_string(), - linked_workspace_items: vec!["new.md".to_string()], - archived_worktrees: vec!["archived-a".to_string()], - ..WorkspaceConfig::default() - }; - - save_workspace_config_internal(workspace.path().to_str().unwrap(), &initial) - .expect("save initial config"); - save_workspace_config_internal(workspace.path().to_str().unwrap(), &updated) - .expect("save updated config"); - clear_workspace_config_cache(); - - let reloaded = load_workspace_config(workspace.path().to_str().unwrap()); - - assert_eq!(reloaded.name, "Updated"); - assert_eq!(reloaded.worktrees_dir, "updated-worktrees"); - assert_eq!(reloaded.linked_workspace_items, vec!["new.md"]); - assert_eq!(reloaded.archived_worktrees, vec!["archived-a"]); - } - - #[serial] - #[test] - fn occupation_state_saves_loads_and_clears_file() { - let workspace = tempfile::tempdir().expect("create workspace dir"); - let state = MainWorkspaceOccupation { - worktree_name: "feature-a".to_string(), - original_branches: std::collections::HashMap::from([( - "api".to_string(), - "main".to_string(), - )]), - worktree_branches: std::collections::HashMap::from([( - "api".to_string(), - "feature/a".to_string(), - )]), - deployed_at: "2026-06-11T00:00:00Z".to_string(), - }; - - save_occupation_state(workspace.path().to_str().unwrap(), &state) - .expect("save occupation state"); - let loaded = load_occupation_state(workspace.path().to_str().unwrap()) - .expect("load occupation state"); - - assert_eq!(loaded.worktree_name, "feature-a"); - assert_eq!( - loaded.original_branches.get("api"), - Some(&"main".to_string()) - ); - assert_eq!( - loaded.worktree_branches.get("api"), - Some(&"feature/a".to_string()) - ); - - clear_occupation_state(workspace.path().to_str().unwrap()).expect("clear occupation state"); - assert!(load_occupation_state(workspace.path().to_str().unwrap()).is_none()); - } -} - -// ==================== 主工作区占用状态 ==================== - -pub fn load_occupation_state(workspace_path: &str) -> Option { - let path = std::path::PathBuf::from(workspace_path).join(".worktree-manager-occupation.json"); - if !path.exists() { - return None; - } - std::fs::read_to_string(&path) - .ok() - .and_then(|content| serde_json::from_str(&content).ok()) -} - -pub fn save_occupation_state( - workspace_path: &str, - state: &MainWorkspaceOccupation, -) -> Result<(), String> { - let path = std::path::PathBuf::from(workspace_path).join(".worktree-manager-occupation.json"); - let content = serde_json::to_string_pretty(state) - .map_err(|e| format!("Failed to serialize occupation state: {}", e))?; - std::fs::write(&path, content).map_err(|e| format!("Failed to write occupation state: {}", e)) -} - -pub fn clear_occupation_state(workspace_path: &str) -> Result<(), String> { - let path = std::path::PathBuf::from(workspace_path).join(".worktree-manager-occupation.json"); - if path.exists() { - std::fs::remove_file(&path) - .map_err(|e| format!("Failed to clear occupation state: {}", e))?; - } - Ok(()) -} diff --git a/src-tauri/src/git_ops.rs b/src-tauri/src/git_ops.rs deleted file mode 100644 index 64ef6ac..0000000 --- a/src-tauri/src/git_ops.rs +++ /dev/null @@ -1,2980 +0,0 @@ -use git2::{Repository, StatusOptions}; -use serde::Serialize; -use std::path::Path; - -use crate::utils::{git_command, run_git_logged}; - -fn command_without_window(program: &str) -> std::process::Command { - #[cfg(target_os = "windows")] - { - use std::os::windows::process::CommandExt; - - const CREATE_NO_WINDOW: u32 = 0x08000000; - let mut command = std::process::Command::new(program); - command.creation_flags(CREATE_NO_WINDOW); - command - } - #[cfg(not(target_os = "windows"))] - { - std::process::Command::new(program) - } -} - -/// Helper function to find the main worktree path for a given repository -fn find_main_worktree(repo_path: &Path) -> Option { - let git_path = repo_path.join(".git"); - if git_path.is_dir() { - log::debug!( - "[merge] repo_path={} is the main worktree itself", - repo_path.display() - ); - return Some(repo_path.to_path_buf()); - } else if git_path.is_file() { - if let Ok(content) = std::fs::read_to_string(&git_path) { - if let Some(gitdir) = content.strip_prefix("gitdir: ") { - let gitdir = gitdir.trim(); - let worktrees_idx_opt = gitdir - .find("/.git/worktrees/") - .or_else(|| gitdir.find("\\.git\\worktrees\\")); - if let Some(worktrees_idx) = worktrees_idx_opt { - let main_path = &gitdir[..worktrees_idx]; - log::debug!( - "[merge] Linked worktree detected. Main worktree: {}", - main_path - ); - return Some(std::path::PathBuf::from(main_path)); - } - } - } - } - log::debug!( - "[merge] Could not find main worktree for {}", - repo_path.display() - ); - None -} - -/// Check if a branch is checked out in the main worktree and switch to detached HEAD if needed -/// Returns (switched, original_branch) - switched=true if we switched to detached HEAD -fn handle_branch_checkout_conflict( - main_worktree_path: &Path, - target_branch: &str, -) -> Result<(bool, Option), String> { - log::info!( - "[merge] Checking branch conflict: target_branch={}, main_worktree={}", - target_branch, - main_worktree_path.display() - ); - - let repo = Repository::open(main_worktree_path).map_err(|e| { - format!( - "无法打开主工作区仓库 ({}): {}", - main_worktree_path.display(), - e - ) - })?; - - if let Ok(head) = repo.head() { - let current_branch = head.shorthand().unwrap_or(""); - log::info!( - "[merge] Main worktree current branch: {}, target: {}", - current_branch, - target_branch - ); - - if current_branch == target_branch { - log::info!("[merge] Branch conflict detected! Checking uncommitted changes..."); - - let status_output = git_command() - .arg("-C") - .arg(main_worktree_path) - .arg("status") - .arg("--porcelain") - .output() - .map_err(|e| format!("检查主工作区 git status 失败: {}", e))?; - - let status_str = String::from_utf8_lossy(&status_output.stdout); - let has_changes = !status_str.is_empty(); - - if has_changes { - log::warn!( - "[merge] Main worktree has uncommitted changes:\n{}", - status_str.trim() - ); - return Err(format!( - "主工作区的 {} 分支有未提交的更改,无法自动切换。\n\ - 请先在主工作区提交或撤销更改后再试。\n\ - 未提交的文件: {}", - target_branch, - status_str.trim() - )); - } - - let head_commit = head - .peel_to_commit() - .map_err(|e| format!("获取 HEAD commit 失败: {}", e))?; - let commit_sha = head_commit.id().to_string(); - - log::info!( - "[merge] Main worktree is clean. Switching to detached HEAD at {}", - &commit_sha[..8] - ); - - let mut checkout_cmd = git_command(); - checkout_cmd - .arg("-C") - .arg(main_worktree_path) - .arg("checkout") - .arg("--detach") - .arg(&commit_sha); - let checkout_output = run_git_logged(&mut checkout_cmd, "merge checkout detach") - .map_err(|e| format!("执行 git checkout --detach 失败: {}", e))?; - - if !checkout_output.status.success() { - let stderr = String::from_utf8_lossy(&checkout_output.stderr); - log::error!("[merge] Failed to detach HEAD: {}", stderr); - return Err(format!("无法将主工作区切换到 detached HEAD: {}", stderr)); - } - - log::info!("[merge] Successfully switched main worktree to detached HEAD"); - return Ok((true, Some(target_branch.to_string()))); - } else { - log::info!( - "[merge] No branch conflict (main={}, target={})", - current_branch, - target_branch - ); - } - } else { - log::warn!("[merge] Cannot read HEAD of main worktree, skipping conflict check"); - } - - Ok((false, None)) -} - -#[derive(Debug, Serialize, Clone)] -pub struct WorktreeInfo { - pub current_branch: String, - pub uncommitted_count: usize, - pub is_merged_to_test: bool, - pub is_merged_to_base: bool, - pub ahead_of_base: usize, - pub behind_base: usize, - pub ahead_of_test: usize, - pub unpushed_commits: usize, - pub remote_url: String, -} - -#[derive(Debug, Serialize, Clone)] -pub struct BranchStatus { - pub project_name: String, - pub branch_name: String, - pub has_uncommitted: bool, - pub uncommitted_count: usize, - pub is_pushed: bool, - pub unpushed_commits: usize, - pub has_merge_request: bool, - pub remote_url: String, -} - -impl Default for WorktreeInfo { - fn default() -> Self { - Self { - current_branch: "unknown".to_string(), - uncommitted_count: 0, - is_merged_to_test: false, - is_merged_to_base: false, - ahead_of_base: 0, - behind_base: 0, - ahead_of_test: 0, - unpushed_commits: 0, - remote_url: String::new(), - } - } -} - -pub fn get_worktree_info(path: &Path) -> WorktreeInfo { - get_worktree_info_for_branches( - path, - get_base_branch_for_path(path), - get_test_branch_for_path(path), - ) -} - -pub fn get_worktree_info_for_branches( - path: &Path, - base_branch: &str, - test_branch: &str, -) -> WorktreeInfo { - let repo = match Repository::open(path) { - Ok(r) => r, - Err(_) => return WorktreeInfo::default(), - }; - - let mut info = WorktreeInfo::default(); - - // Get current branch - if let Ok(head) = repo.head() { - if let Some(name) = head.shorthand() { - info.current_branch = name.to_string(); - } - } - - // Get remote URL - if let Ok(remote) = repo.find_remote("origin") { - if let Some(url) = remote.url() { - info.remote_url = url.to_string(); - } - } - - // Get uncommitted changes count - let mut opts = StatusOptions::new(); - opts.include_untracked(true).recurse_untracked_dirs(false); - - if let Ok(statuses) = repo.statuses(Some(&mut opts)) { - info.uncommitted_count = statuses.len(); - } - - // Check if merged to test branch - // This is a simplified check - just see if test branch ref exists and compare - if let Ok(test_ref) = repo.find_reference(&format!("refs/remotes/origin/{}", test_branch)) { - if let Ok(head) = repo.head() { - if let (Ok(test_commit), Ok(head_commit)) = - (test_ref.peel_to_commit(), head.peel_to_commit()) - { - // Check if head commit is ancestor of test branch - if let Ok(is_ancestor) = - repo.graph_descendant_of(test_commit.id(), head_commit.id()) - { - info.is_merged_to_test = is_ancestor; - } - } - // Get ahead count relative to test branch - if let (Some(head_oid), Some(test_oid)) = (head.target(), test_ref.target()) { - if let Ok((ahead, _)) = repo.graph_ahead_behind(head_oid, test_oid) { - info.ahead_of_test = ahead; - } - } - } - } - - // Get ahead/behind count relative to base branch - if let Ok(base_ref) = repo.find_reference(&format!("refs/remotes/origin/{}", base_branch)) { - if let Ok(head) = repo.head() { - if let (Ok(base_oid), Ok(head_oid)) = - (base_ref.target().ok_or(()), head.target().ok_or(())) - { - if let Ok((ahead, behind)) = repo.graph_ahead_behind(head_oid, base_oid) { - info.ahead_of_base = ahead; - info.behind_base = behind; - } - // Check if merged to base (base contains HEAD) - if let Ok(is_ancestor) = repo.graph_descendant_of(base_oid, head_oid) { - info.is_merged_to_base = is_ancestor; - } - } - } - } - - // Get unpushed commits (ahead of origin/) - let remote_branch = format!("refs/remotes/origin/{}", info.current_branch); - if let Ok(remote_ref) = repo.find_reference(&remote_branch) { - if let Ok(head) = repo.head() { - if let (Some(head_oid), Some(remote_oid)) = (head.target(), remote_ref.target()) { - if let Ok((ahead, _)) = repo.graph_ahead_behind(head_oid, remote_oid) { - info.unpushed_commits = ahead; - } - } - } - } else { - // Remote branch doesn't exist — all local commits are unpushed - if let Ok(base_ref) = repo.find_reference(&format!("refs/remotes/origin/{}", base_branch)) { - if let Ok(head) = repo.head() { - if let (Some(head_oid), Some(base_oid)) = (head.target(), base_ref.target()) { - if let Ok((ahead, _)) = repo.graph_ahead_behind(head_oid, base_oid) { - info.unpushed_commits = ahead; - } - } - } - } - } - - info -} - -fn get_base_branch_for_path(_path: &Path) -> &str { - "uat" -} - -fn get_test_branch_for_path(_path: &Path) -> &str { - "test" -} - -pub fn get_branch_status(path: &Path, project_name: &str, base_branch: &str) -> BranchStatus { - let mut status = BranchStatus { - project_name: project_name.to_string(), - branch_name: "unknown".to_string(), - has_uncommitted: false, - uncommitted_count: 0, - is_pushed: false, - unpushed_commits: 0, - has_merge_request: false, - remote_url: String::new(), - }; - - let repo = match Repository::open(path) { - Ok(r) => r, - Err(_) => return status, - }; - - // Get current branch name - if let Ok(head) = repo.head() { - if let Some(name) = head.shorthand() { - status.branch_name = name.to_string(); - } - } - - // Get uncommitted changes - let mut opts = StatusOptions::new(); - opts.include_untracked(true).recurse_untracked_dirs(false); - if let Ok(statuses) = repo.statuses(Some(&mut opts)) { - status.uncommitted_count = statuses.len(); - status.has_uncommitted = status.uncommitted_count > 0; - } - - // Get remote URL - if let Ok(remote) = repo.find_remote("origin") { - if let Some(url) = remote.url() { - status.remote_url = url.to_string(); - } - } - - // Check if branch is pushed to remote (compare with origin/branch) - let remote_branch = format!("refs/remotes/origin/{}", status.branch_name); - if let Ok(head) = repo.head() { - if let Some(head_oid) = head.target() { - if let Ok(remote_ref) = repo.find_reference(&remote_branch) { - if let Some(remote_oid) = remote_ref.target() { - // Branch exists on remote, check how many commits ahead - if let Ok((ahead, _)) = repo.graph_ahead_behind(head_oid, remote_oid) { - status.unpushed_commits = ahead; - status.is_pushed = ahead == 0; - } - } - } else { - // Remote branch doesn't exist, not pushed - status.is_pushed = false; - // Count commits from merge-base with origin/uat or origin/master - let base_ref = format!("refs/remotes/origin/{}", base_branch); - if let Ok(base_ref) = repo.find_reference(&base_ref) { - if let Some(base_oid) = base_ref.target() { - if let Ok((ahead, _)) = repo.graph_ahead_behind(head_oid, base_oid) { - status.unpushed_commits = ahead; - } - } - } - } - } - } - - // Check for merge request by looking at remote refs - // GitLab creates refs/merge-requests/X/head for open MRs - // GitHub creates refs/pull/X/head - // We check if there's a remote ref pointing to our branch - let branch_name = &status.branch_name; - - // Try to detect MR by checking if the branch has been merged or has remote tracking - // A more reliable way: check if remote branch exists with specific patterns - if let Ok(refs) = repo.references() { - for reference in refs.flatten() { - if let Some(name) = reference.name() { - // Check for GitLab merge request refs or GitHub pull refs - if name.contains("merge-requests") || name.contains("pull") { - if let Ok(ref_commit) = reference.peel_to_commit() { - if let Ok(head) = repo.head() { - if let Ok(head_commit) = head.peel_to_commit() { - if ref_commit.id() == head_commit.id() { - status.has_merge_request = true; - break; - } - } - } - } - } - } - } - } - - // Alternative: if branch is pushed and remote branch exists, assume MR might exist - // (This is a heuristic since we can't query GitLab/GitHub API directly without auth) - if status.is_pushed - && !status.branch_name.starts_with("uat") - && !status.branch_name.starts_with("master") - && !status.branch_name.starts_with("test") - && !status.branch_name.starts_with("staging") - { - // Check if the remote branch exists - let remote_branch = format!("refs/remotes/origin/{}", branch_name); - if repo.find_reference(&remote_branch).is_ok() { - // Branch is pushed to remote - we mark has_merge_request as "unknown" - // by keeping it false, user should verify manually - } - } - - status -} - -#[derive(Debug, Serialize, Clone)] -pub struct BranchDiffStats { - pub ahead: usize, - pub behind: usize, - pub changed_files: usize, - pub unpushed_commits: usize, - pub ahead_of_test: usize, -} - -/// Sync with base branch (pull from base branch) -pub fn sync_with_base_branch(path: &Path, base_branch: &str) -> Result { - log::info!( - "[git] Syncing with base branch: path={}, base_branch={}", - path.display(), - base_branch - ); - - // Step 1: Fetch from remote - log::info!("[git] Step 1/2: git fetch origin {}", base_branch); - let mut fetch_cmd = git_command(); - fetch_cmd - .arg("-C") - .arg(path) - .arg("fetch") - .arg("origin") - .arg(base_branch); - let fetch_output = run_git_logged(&mut fetch_cmd, "sync fetch base branch") - .map_err(|e| format!("Failed to execute git fetch: {}", e))?; - - if !fetch_output.status.success() { - let stderr = String::from_utf8_lossy(&fetch_output.stderr); - log::error!( - "[git] Step 1/2 FAILED: git fetch origin {}: {}", - base_branch, - stderr - ); - return Err(format!("Git fetch failed: {}", stderr)); - } - log::info!("[git] Step 1/2: git fetch succeeded"); - - // Step 2: Merge origin/base_branch into current branch - log::info!("[git] Step 2/2: git merge origin/{}", base_branch); - let mut merge_cmd = git_command(); - merge_cmd - .arg("-C") - .arg(path) - .arg("merge") - .arg(format!("origin/{}", base_branch)); - let merge_output = run_git_logged(&mut merge_cmd, "sync merge base branch") - .map_err(|e| format!("Failed to execute git merge: {}", e))?; - - if !merge_output.status.success() { - let stderr = String::from_utf8_lossy(&merge_output.stderr); - log::error!( - "[git] Step 2/2 FAILED: git merge origin/{}: {}", - base_branch, - stderr - ); - return Err(format!("Git merge failed: {}", stderr)); - } - - log::info!( - "[git] Successfully synced with base branch '{}'", - base_branch - ); - Ok(format!("Successfully synced with {}", base_branch)) -} - -/// Push current branch to remote -pub fn push_to_remote(path: &Path) -> Result { - log::info!("[git] Pushing to remote: path={}", path.display()); - - // Step 1: Get current branch - let branch_output = git_command() - .arg("-C") - .arg(path) - .arg("rev-parse") - .arg("--abbrev-ref") - .arg("HEAD") - .output() - .map_err(|e| format!("Failed to get current branch: {}", e))?; - - if !branch_output.status.success() { - log::error!("[git] Failed to get current branch at {}", path.display()); - return Err("Failed to get current branch".to_string()); - } - - let current_branch = String::from_utf8_lossy(&branch_output.stdout) - .trim() - .to_string(); - - log::info!("[git] Pushing branch '{}' to origin", current_branch); - let mut push_cmd = git_command(); - push_cmd - .arg("-C") - .arg(path) - .arg("push") - .arg("-u") - .arg("origin") - .arg(¤t_branch) - .arg("--no-verify"); - let push_output = run_git_logged(&mut push_cmd, "push current branch") - .map_err(|e| format!("Failed to execute git push: {}", e))?; - - if !push_output.status.success() { - let stderr = String::from_utf8_lossy(&push_output.stderr); - log::error!( - "[git] Push failed for branch '{}': {}", - current_branch, - stderr - ); - return Err(format!("Git push failed: {}", stderr)); - } - - log::info!("[git] Successfully pushed '{}' to origin", current_branch); - Ok(format!("Successfully pushed {} to origin", current_branch)) -} - -/// Pull current branch from remote (git pull origin ) -pub fn pull_current_branch(path: &Path) -> Result { - log::info!("[git] Pulling current branch: path={}", path.display()); - - // Step 1: Get current branch - let branch_output = git_command() - .arg("-C") - .arg(path) - .arg("rev-parse") - .arg("--abbrev-ref") - .arg("HEAD") - .output() - .map_err(|e| format!("Failed to get current branch: {}", e))?; - - if !branch_output.status.success() { - log::error!("[git] Failed to get current branch at {}", path.display()); - return Err("Failed to get current branch".to_string()); - } - - let current_branch = String::from_utf8_lossy(&branch_output.stdout) - .trim() - .to_string(); - - // Step 2: Pull from origin - log::info!("[git] Pulling branch '{}' from origin", current_branch); - let mut pull_cmd = git_command(); - pull_cmd - .arg("-C") - .arg(path) - .arg("pull") - .arg("origin") - .arg(¤t_branch); - let pull_output = run_git_logged(&mut pull_cmd, "pull current branch") - .map_err(|e| format!("Failed to execute git pull: {}", e))?; - - if !pull_output.status.success() { - let stderr = String::from_utf8_lossy(&pull_output.stderr); - log::error!( - "[git] Pull failed for branch '{}': {}", - current_branch, - stderr - ); - return Err(format!("Git pull failed: {}", stderr)); - } - - log::info!("[git] Successfully pulled '{}' from origin", current_branch); - Ok(format!( - "Successfully pulled {} from origin", - current_branch - )) -} - -/// Helper to restore main worktree and checkout back to original branch on error/cleanup -fn restore_merge_state( - path: &Path, - original_branch: &str, - switched_main: bool, - main_worktree_path: &Option, - original_main_branch: &Option, -) { - // Checkout back to original branch in worktree - log::info!("[merge] Restoring worktree to branch: {}", original_branch); - let mut restore_cmd = git_command(); - restore_cmd - .arg("-C") - .arg(path) - .arg("checkout") - .arg(original_branch); - let restore = run_git_logged(&mut restore_cmd, "merge restore worktree checkout"); - match &restore { - Ok(output) if output.status.success() => { - log::info!("[merge] Restored worktree to {}", original_branch); - } - Ok(output) => { - log::error!( - "[merge] Failed to restore worktree to {}: {}", - original_branch, - String::from_utf8_lossy(&output.stderr) - ); - } - Err(e) => { - log::error!("[merge] Failed to execute git checkout for restore: {}", e); - } - } - - // Restore main worktree if we switched it - if switched_main { - if let (Some(main_wt), Some(orig_branch)) = (main_worktree_path, original_main_branch) { - log::info!("[merge] Restoring main worktree to branch: {}", orig_branch); - let mut restore_cmd = git_command(); - restore_cmd - .arg("-C") - .arg(main_wt) - .arg("checkout") - .arg(orig_branch); - let restore_output = - run_git_logged(&mut restore_cmd, "merge restore main worktree checkout"); - match &restore_output { - Ok(output) if output.status.success() => { - log::info!("[merge] Restored main worktree to {}", orig_branch); - } - Ok(output) => { - log::error!( - "[merge] Failed to restore main worktree to {}: {}", - orig_branch, - String::from_utf8_lossy(&output.stderr) - ); - } - Err(e) => { - log::error!( - "[merge] Failed to execute git checkout for main restore: {}", - e - ); - } - } - } - } -} - -/// Merge current branch to test branch -pub fn merge_to_test_branch(path: &Path, test_branch: &str) -> Result { - log::info!("[merge-test] ===== START merge_to_test_branch ====="); - log::info!( - "[merge-test] path={}, test_branch={}", - path.display(), - test_branch - ); - - let repo = - Repository::open(path).map_err(|e| format!("无法打开仓库 ({}): {}", path.display(), e))?; - - let head = repo - .head() - .map_err(|e| format!("无法读取 HEAD ({}): {}", path.display(), e))?; - let current_branch = head - .shorthand() - .ok_or_else(|| "无法获取当前分支名 (HEAD 可能处于 detached 状态)".to_string())?; - - log::info!("[merge-test] current_branch={}", current_branch); - - // Find main worktree and handle potential checkout conflict - let mut main_worktree_path: Option = None; - let mut switched_main = false; - let mut original_main_branch: Option = None; - - if let Some(main_wt) = find_main_worktree(path) { - main_worktree_path = Some(main_wt.clone()); - log::info!("[merge-test] Step 1: Handling branch checkout conflict..."); - let (switched, orig_branch) = handle_branch_checkout_conflict(&main_wt, test_branch)?; - switched_main = switched; - original_main_branch = orig_branch; - log::info!("[merge-test] Step 1 done: switched_main={}", switched_main); - } else { - log::info!("[merge-test] Step 1: No main worktree found, skipping conflict check"); - } - - // Step 2: Checkout test branch - log::info!("[merge-test] Step 2: git checkout {}", test_branch); - let mut checkout_cmd = git_command(); - checkout_cmd - .arg("-C") - .arg(path) - .arg("checkout") - .arg(test_branch); - let checkout_output = run_git_logged(&mut checkout_cmd, "merge-test checkout target") - .map_err(|e| format!("执行 git checkout {} 失败: {}", test_branch, e))?; - - if !checkout_output.status.success() { - let stderr = String::from_utf8_lossy(&checkout_output.stderr); - log::error!( - "[merge-test] Step 2 FAILED: checkout {} => {}", - test_branch, - stderr - ); - if switched_main { - restore_merge_state( - path, - current_branch, - switched_main, - &main_worktree_path, - &original_main_branch, - ); - } - return Err(format!("切换到 {} 分支失败: {}", test_branch, stderr)); - } - log::info!("[merge-test] Step 2 OK: checked out {}", test_branch); - - // Step 3: Pull latest - log::info!("[merge-test] Step 3: git pull origin {}", test_branch); - let mut pull_cmd = git_command(); - pull_cmd - .arg("-C") - .arg(path) - .arg("pull") - .arg("origin") - .arg(test_branch); - let pull_output = run_git_logged(&mut pull_cmd, "merge-test pull target") - .map_err(|e| format!("执行 git pull origin {} 失败: {}", test_branch, e))?; - - if !pull_output.status.success() { - let stderr = String::from_utf8_lossy(&pull_output.stderr); - log::error!("[merge-test] Step 3 FAILED: pull => {}", stderr); - restore_merge_state( - path, - current_branch, - switched_main, - &main_worktree_path, - &original_main_branch, - ); - return Err(format!("拉取 {} 最新代码失败: {}", test_branch, stderr)); - } - log::info!("[merge-test] Step 3 OK: pulled latest {}", test_branch); - - // Step 4: Merge - log::info!("[merge-test] Step 4: git merge {}", current_branch); - let mut merge_cmd = git_command(); - merge_cmd - .arg("-C") - .arg(path) - .arg("merge") - .arg(current_branch); - let merge_output = run_git_logged(&mut merge_cmd, "merge-test merge current") - .map_err(|e| format!("执行 git merge {} 失败: {}", current_branch, e))?; - - if !merge_output.status.success() { - let stderr = String::from_utf8_lossy(&merge_output.stderr); - let stdout = String::from_utf8_lossy(&merge_output.stdout); - log::error!( - "[merge-test] Step 4 FAILED: merge => stderr={}, stdout={}", - stderr, - stdout - ); - // Abort merge if in conflict state - let mut abort_cmd = git_command(); - abort_cmd.arg("-C").arg(path).arg("merge").arg("--abort"); - let _ = run_git_logged(&mut abort_cmd, "merge-test merge abort"); - restore_merge_state( - path, - current_branch, - switched_main, - &main_worktree_path, - &original_main_branch, - ); - return Err(format!( - "合并 {} 到 {} 失败: {}{}", - current_branch, - test_branch, - stderr, - if !stdout.is_empty() { - format!("\n{}", stdout) - } else { - String::new() - } - )); - } - log::info!( - "[merge-test] Step 4 OK: merged {} into {}", - current_branch, - test_branch - ); - - // Step 5: Push - log::info!("[merge-test] Step 5: git push origin {}", test_branch); - let mut push_cmd = git_command(); - push_cmd - .arg("-C") - .arg(path) - .arg("push") - .arg("origin") - .arg(test_branch) - .arg("--no-verify"); - let push_output = run_git_logged(&mut push_cmd, "merge-test push target") - .map_err(|e| format!("执行 git push origin {} 失败: {}", test_branch, e))?; - - let push_failed = !push_output.status.success(); - if push_failed { - log::error!( - "[merge-test] Step 5 FAILED: push => {}", - String::from_utf8_lossy(&push_output.stderr) - ); - } else { - log::info!("[merge-test] Step 5 OK: pushed {}", test_branch); - } - - // Step 6: Restore - log::info!("[merge-test] Step 6: Restoring original state..."); - restore_merge_state( - path, - current_branch, - switched_main, - &main_worktree_path, - &original_main_branch, - ); - log::info!("[merge-test] Step 6 OK: Restored"); - - if push_failed { - return Err(format!( - "推送 {} 到远程失败: {}", - test_branch, - String::from_utf8_lossy(&push_output.stderr) - )); - } - - let mut result = format!("成功将 {} 合并到 {}", current_branch, test_branch); - if switched_main { - result.push_str("\n\n✓ 主工作区已临时切换并已恢复"); - } - - log::info!("[merge-test] ===== DONE merge_to_test_branch ====="); - Ok(result) -} - -/// Merge current branch to base branch -pub fn merge_to_base_branch(path: &Path, base_branch: &str) -> Result { - log::info!("[merge-base] ===== START merge_to_base_branch ====="); - log::info!( - "[merge-base] path={}, base_branch={}", - path.display(), - base_branch - ); - - let repo = - Repository::open(path).map_err(|e| format!("无法打开仓库 ({}): {}", path.display(), e))?; - - let head = repo - .head() - .map_err(|e| format!("无法读取 HEAD ({}): {}", path.display(), e))?; - let current_branch = head - .shorthand() - .ok_or_else(|| "无法获取当前分支名 (HEAD 可能处于 detached 状态)".to_string())?; - - log::info!("[merge-base] current_branch={}", current_branch); - - // Find main worktree and handle potential checkout conflict - let mut main_worktree_path: Option = None; - let mut switched_main = false; - let mut original_main_branch: Option = None; - - if let Some(main_wt) = find_main_worktree(path) { - main_worktree_path = Some(main_wt.clone()); - log::info!("[merge-base] Step 1: Handling branch checkout conflict..."); - let (switched, orig_branch) = handle_branch_checkout_conflict(&main_wt, base_branch)?; - switched_main = switched; - original_main_branch = orig_branch; - log::info!("[merge-base] Step 1 done: switched_main={}", switched_main); - } else { - log::info!("[merge-base] Step 1: No main worktree found, skipping conflict check"); - } - - // Step 2: Checkout base branch - log::info!("[merge-base] Step 2: git checkout {}", base_branch); - let mut checkout_cmd = git_command(); - checkout_cmd - .arg("-C") - .arg(path) - .arg("checkout") - .arg(base_branch); - let checkout_output = run_git_logged(&mut checkout_cmd, "merge-base checkout target") - .map_err(|e| format!("执行 git checkout {} 失败: {}", base_branch, e))?; - - if !checkout_output.status.success() { - let stderr = String::from_utf8_lossy(&checkout_output.stderr); - log::error!( - "[merge-base] Step 2 FAILED: checkout {} => {}", - base_branch, - stderr - ); - if switched_main { - restore_merge_state( - path, - current_branch, - switched_main, - &main_worktree_path, - &original_main_branch, - ); - } - return Err(format!("切换到 {} 分支失败: {}", base_branch, stderr)); - } - log::info!("[merge-base] Step 2 OK: checked out {}", base_branch); - - // Step 3: Pull latest - log::info!("[merge-base] Step 3: git pull origin {}", base_branch); - let mut pull_cmd = git_command(); - pull_cmd - .arg("-C") - .arg(path) - .arg("pull") - .arg("origin") - .arg(base_branch); - let pull_output = run_git_logged(&mut pull_cmd, "merge-base pull target") - .map_err(|e| format!("执行 git pull origin {} 失败: {}", base_branch, e))?; - - if !pull_output.status.success() { - let stderr = String::from_utf8_lossy(&pull_output.stderr); - log::error!("[merge-base] Step 3 FAILED: pull => {}", stderr); - restore_merge_state( - path, - current_branch, - switched_main, - &main_worktree_path, - &original_main_branch, - ); - return Err(format!("拉取 {} 最新代码失败: {}", base_branch, stderr)); - } - log::info!("[merge-base] Step 3 OK: pulled latest {}", base_branch); - - // Step 4: Merge - log::info!("[merge-base] Step 4: git merge {}", current_branch); - let mut merge_cmd = git_command(); - merge_cmd - .arg("-C") - .arg(path) - .arg("merge") - .arg(current_branch); - let merge_output = run_git_logged(&mut merge_cmd, "merge-base merge current") - .map_err(|e| format!("执行 git merge {} 失败: {}", current_branch, e))?; - - if !merge_output.status.success() { - let stderr = String::from_utf8_lossy(&merge_output.stderr); - let stdout = String::from_utf8_lossy(&merge_output.stdout); - log::error!( - "[merge-base] Step 4 FAILED: merge => stderr={}, stdout={}", - stderr, - stdout - ); - // Abort merge if in conflict state - let mut abort_cmd = git_command(); - abort_cmd.arg("-C").arg(path).arg("merge").arg("--abort"); - let _ = run_git_logged(&mut abort_cmd, "merge-base merge abort"); - restore_merge_state( - path, - current_branch, - switched_main, - &main_worktree_path, - &original_main_branch, - ); - return Err(format!( - "合并 {} 到 {} 失败: {}{}", - current_branch, - base_branch, - stderr, - if !stdout.is_empty() { - format!("\n{}", stdout) - } else { - String::new() - } - )); - } - log::info!( - "[merge-base] Step 4 OK: merged {} into {}", - current_branch, - base_branch - ); - - // Step 5: Push - log::info!("[merge-base] Step 5: git push origin {}", base_branch); - let mut push_cmd = git_command(); - push_cmd - .arg("-C") - .arg(path) - .arg("push") - .arg("origin") - .arg(base_branch) - .arg("--no-verify"); - let push_output = run_git_logged(&mut push_cmd, "merge-base push target") - .map_err(|e| format!("执行 git push origin {} 失败: {}", base_branch, e))?; - - let push_failed = !push_output.status.success(); - if push_failed { - log::error!( - "[merge-base] Step 5 FAILED: push => {}", - String::from_utf8_lossy(&push_output.stderr) - ); - } else { - log::info!("[merge-base] Step 5 OK: pushed {}", base_branch); - } - - // Step 6: Restore - log::info!("[merge-base] Step 6: Restoring original state..."); - restore_merge_state( - path, - current_branch, - switched_main, - &main_worktree_path, - &original_main_branch, - ); - log::info!("[merge-base] Step 6 OK: Restored"); - - if push_failed { - return Err(format!( - "推送 {} 到远程失败: {}", - base_branch, - String::from_utf8_lossy(&push_output.stderr) - )); - } - - let mut result = format!("成功将 {} 合并到 {}", current_branch, base_branch); - if switched_main { - result.push_str("\n\n✓ 主工作区已临时切换并已恢复"); - } - - log::info!("[merge-base] ===== DONE merge_to_base_branch ====="); - Ok(result) -} - -/// Get branch diff statistics -pub fn get_branch_diff_stats( - path: &Path, - base_branch: &str, - test_branch: Option<&str>, -) -> BranchDiffStats { - // Normalize empty string to None - let test_branch = test_branch.filter(|s| !s.is_empty()); - log::info!( - "[diff-stats] path={}, base_branch={}, test_branch={:?}", - path.display(), - base_branch, - test_branch - ); - let repo = match Repository::open(path) { - Ok(r) => r, - Err(_) => { - return BranchDiffStats { - ahead: 0, - behind: 0, - changed_files: 0, - unpushed_commits: 0, - ahead_of_test: 0, - } - } - }; - - let mut stats = BranchDiffStats { - ahead: 0, - behind: 0, - changed_files: 0, - unpushed_commits: 0, - ahead_of_test: 0, - }; - - // Get ahead/behind count relative to base branch - if let Ok(base_ref) = repo.find_reference(&format!("refs/remotes/origin/{}", base_branch)) { - if let Ok(head) = repo.head() { - if let (Ok(base_oid), Ok(head_oid)) = - (base_ref.target().ok_or(()), head.target().ok_or(())) - { - if let Ok((ahead, behind)) = repo.graph_ahead_behind(head_oid, base_oid) { - stats.ahead = ahead; - stats.behind = behind; - } - } - } - } - - // Get unpushed commits (HEAD vs origin/) - if let Ok(head) = repo.head() { - if let Some(current_branch) = head.shorthand() { - let remote_ref_name = format!("refs/remotes/origin/{}", current_branch); - if let Ok(remote_ref) = repo.find_reference(&remote_ref_name) { - if let (Some(head_oid), Some(remote_oid)) = (head.target(), remote_ref.target()) { - if let Ok((ahead, _)) = repo.graph_ahead_behind(head_oid, remote_oid) { - stats.unpushed_commits = ahead; - } - } - } else { - // Remote branch doesn't exist — all commits ahead of base are unpushed - stats.unpushed_commits = stats.ahead; - } - } - } - - // Get ahead count relative to test branch - if let Some(test) = test_branch { - let test_ref_name = format!("refs/remotes/origin/{}", test); - match repo.find_reference(&test_ref_name) { - Ok(test_ref) => { - if let Ok(head) = repo.head() { - if let (Some(head_oid), Some(test_oid)) = (head.target(), test_ref.target()) { - if let Ok((ahead, _)) = repo.graph_ahead_behind(head_oid, test_oid) { - stats.ahead_of_test = ahead; - log::info!("[diff-stats] ahead_of_test={} (vs {})", ahead, test); - } - } - } - } - Err(e) => { - log::warn!( - "[diff-stats] Cannot find test branch ref '{}': {}", - test_ref_name, - e - ); - } - } - } else { - log::info!("[diff-stats] No test_branch provided, skipping ahead_of_test"); - } - - // Get changed files count - let mut opts = StatusOptions::new(); - opts.include_untracked(true).recurse_untracked_dirs(false); - - if let Ok(statuses) = repo.statuses(Some(&mut opts)) { - stats.changed_files = statuses.len(); - } - - stats -} - -/// Detect git platform (GitHub or GitLab) -#[derive(Debug, PartialEq)] -pub enum GitPlatform { - GitHub, - GitLab, - Unknown, -} - -fn get_remote_origin_url(path: &Path) -> Result { - let output = git_command() - .arg("-C") - .arg(path) - .arg("remote") - .arg("get-url") - .arg("origin") - .output() - .map_err(|e| format!("Failed to get remote URL: {}", e))?; - if !output.status.success() { - return Err(format!( - "Failed to get remote URL: {}", - String::from_utf8_lossy(&output.stderr) - )); - } - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) -} - -fn remote_url_to_web_url(remote_url: &str) -> Option { - let url = remote_url.trim(); - if let Some(rest) = url.strip_prefix("git@") { - let parts: Vec<&str> = rest.splitn(2, ':').collect(); - if parts.len() == 2 { - let host = parts[0]; - let path = parts[1].trim_end_matches(".git"); - return Some(format!("https://{}/{}", host, path)); - } - } - if url.starts_with("https://") || url.starts_with("http://") { - return Some(url.trim_end_matches(".git").to_string()); - } - None -} - -fn get_current_branch_inner(path: &Path) -> Result { - let output = git_command() - .arg("-C") - .arg(path) - .arg("rev-parse") - .arg("--abbrev-ref") - .arg("HEAD") - .output() - .map_err(|e| format!("Failed to get current branch: {}", e))?; - if !output.status.success() { - return Err("Failed to get current branch".to_string()); - } - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) -} - -pub fn detect_git_platform(path: &Path) -> Result { - let remote_output = git_command() - .arg("-C") - .arg(path) - .arg("remote") - .arg("-v") - .output() - .map_err(|e| format!("Failed to execute git remote: {}", e))?; - - if !remote_output.status.success() { - return Err(format!( - "Git remote failed: {}", - String::from_utf8_lossy(&remote_output.stderr) - )); - } - - let output_str = String::from_utf8_lossy(&remote_output.stdout); - - // Check for GitHub - if output_str.contains("github.com") { - return Ok(GitPlatform::GitHub); - } - - // Check for GitLab - if output_str.contains("gitlab.com") || output_str.contains("gitlab") { - return Ok(GitPlatform::GitLab); - } - - Ok(GitPlatform::Unknown) -} - -/// Create a pull request using gh CLI (GitHub) or git push options (GitLab) -pub fn create_pull_request( - path: &Path, - base_branch: &str, - title: &str, - body: &str, -) -> Result { - log::info!( - "[git] Creating pull request: path={}, base_branch={}, title='{}'", - path.display(), - base_branch, - title - ); - - // Detect platform - let platform = detect_git_platform(path)?; - log::info!("[git] Detected platform: {:?}", platform); - - match platform { - GitPlatform::GitHub => { - // Helper: build compare URL for browser-based PR creation - let build_compare_url = || -> Option { - let remote_url = get_remote_origin_url(path).ok()?; - let web_url = remote_url_to_web_url(&remote_url)?; - let head = get_current_branch_inner(path).ok()?; - Some(format!( - "{}/compare/{}...{}?expand=1&title={}&body={}", - web_url, - urlencoding::encode(base_branch), - urlencoding::encode(&head), - urlencoding::encode(title), - urlencoding::encode(body) - )) - }; - - // Check if gh CLI is available - log::info!("[git] Checking gh CLI availability"); - let gh_available = command_without_window("gh") - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - - if !gh_available { - log::info!("[git] gh CLI not available, falling back to browser URL"); - return if let Some(url) = build_compare_url() { - log::info!("[git] Browser PR URL: {}", url); - Ok(url) - } else { - Err( - "gh CLI is not installed. Please install it from https://cli.github.com/" - .to_string(), - ) - }; - } - - // Create PR using gh CLI - log::info!( - "[git] Running: gh pr create --base {} --title '{}'", - base_branch, - title - ); - let pr_output = command_without_window("gh") - .arg("pr") - .arg("create") - .arg("--base") - .arg(base_branch) - .arg("--title") - .arg(title) - .arg("--body") - .arg(body) - .current_dir(path) - .output() - .map_err(|e| format!("Failed to execute gh pr create: {}", e))?; - - if !pr_output.status.success() { - let stderr = String::from_utf8_lossy(&pr_output.stderr); - log::error!("[git] gh pr create failed: {}", stderr); - // Fall back to browser URL (e.g. branch not yet pushed, auth issue) - if let Some(url) = build_compare_url() { - log::info!("[git] Falling back to browser PR URL: {}", url); - return Ok(url); - } - return Err(format!("Failed to create PR: {}", stderr)); - } - - let pr_url = String::from_utf8_lossy(&pr_output.stdout) - .trim() - .to_string(); - log::info!("[git] Successfully created GitHub PR: {}", pr_url); - Ok(pr_url) - } - GitPlatform::GitLab => { - log::info!("[git] Creating GitLab MR"); - let current_branch = get_current_branch_inner(path)?; - - // Helper: build browser URL for GitLab MR creation - let build_mr_browser_url = || -> Option { - let remote_url = get_remote_origin_url(path).ok()?; - let web_url = remote_url_to_web_url(&remote_url)?; - Some(format!( - "{}/-/merge_requests/new?merge_request[source_branch]={}&merge_request[target_branch]={}&merge_request[title]={}&merge_request[description]={}", - web_url, - urlencoding::encode(¤t_branch), - urlencoding::encode(base_branch), - urlencoding::encode(title), - urlencoding::encode(body) - )) - }; - - // Try: push with merge request creation options (GitLab push options) - log::info!( - "[git] Running: git push -u origin {} with MR options (target={})", - current_branch, - base_branch - ); - let mut push_cmd = git_command(); - push_cmd - .arg("-C") - .arg(path) - .arg("push") - .arg("-u") - .arg("origin") - .arg(¤t_branch) - .arg("--no-verify") - .arg("-o") - .arg("merge_request.create") - .arg("-o") - .arg(format!("merge_request.target={}", base_branch)) - .arg("-o") - .arg(format!("merge_request.title={}", title)) - .arg("-o") - .arg(format!("merge_request.description={}", body)); - let push_output = run_git_logged(&mut push_cmd, "create pull request gitlab push") - .map_err(|e| format!("Failed to push and create MR: {}", e))?; - - if !push_output.status.success() { - let stderr = String::from_utf8_lossy(&push_output.stderr); - log::error!("[git] GitLab push+MR failed: {}", stderr); - // Fall back to browser URL - if let Some(url) = build_mr_browser_url() { - log::info!("[git] Falling back to browser MR URL: {}", url); - return Ok(url); - } - return Err(format!("Failed to create MR: {}", stderr)); - } - - // Extract MR URL from push stderr output - let output_str = String::from_utf8_lossy(&push_output.stderr); - for line in output_str.lines() { - if line.contains("merge_request") || line.contains("/merge_requests/") { - if let Some(url_start) = line.find("http") { - let url_part = &line[url_start..]; - let url = if let Some(url_end) = url_part.find(char::is_whitespace) { - url_part[..url_end].to_string() - } else { - url_part.to_string() - }; - log::info!("[git] GitLab MR URL extracted: {}", url); - return Ok(url); - } - } - } - - // URL not in push output - return browser URL for user to open - log::info!( - "[git] GitLab MR created for branch {} -> {} (URL not extracted, using browser fallback)", - current_branch, - base_branch - ); - if let Some(url) = build_mr_browser_url() { - Ok(url) - } else { - Ok(format!( - "MR created successfully for branch {} -> {}", - current_branch, base_branch - )) - } - } - GitPlatform::Unknown => { - log::error!("[git] Unknown git platform, cannot create PR"); - Err("Unknown git platform. Only GitHub and GitLab are supported.".to_string()) - } - } -} - -/// Fetch from remote origin (updates remote-tracking branches) -pub fn fetch_remote(path: &Path) -> Result<(), String> { - log::info!("[git] Fetching remote origin: path={}", path.display()); - let mut fetch_cmd = git_command(); - fetch_cmd.arg("-C").arg(path).arg("fetch").arg("origin"); - let output = run_git_logged(&mut fetch_cmd, "fetch remote") - .map_err(|e| format!("Failed to execute git fetch: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - log::error!("[git] Fetch failed for {}: {}", path.display(), stderr); - return Err(format!("Git fetch failed: {}", stderr)); - } - - log::info!("[git] Fetch succeeded for {}", path.display()); - Ok(()) -} - -/// Check if a remote branch exists -pub fn check_remote_branch_exists(path: &Path, branch_name: &str) -> Result { - log::debug!( - "[git] Checking remote branch exists: path={}, branch=origin/{}", - path.display(), - branch_name - ); - // Check locally if the remote-tracking branch exists (no network call). - // Remote-tracking branches are updated by git fetch/pull/push operations, - // so this is accurate enough for UI button state. - let output = git_command() - .arg("-C") - .arg(path) - .arg("branch") - .arg("-r") - .arg("--list") - .arg(format!("origin/{}", branch_name)) - .output() - .map_err(|e| format!("Failed to execute git branch: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - log::error!( - "[git] Branch check failed for origin/{}: {}", - branch_name, - stderr - ); - return Err(format!("Git branch check failed: {}", stderr)); - } - - let output_str = String::from_utf8_lossy(&output.stdout); - let exists = !output_str.trim().is_empty(); - log::debug!( - "[git] Remote branch origin/{} exists: {}", - branch_name, - exists - ); - Ok(exists) -} - -/// Get list of remote branches -pub fn get_remote_branches(path: &Path) -> Result, String> { - log::info!("[git] Getting remote branches: path={}", path.display()); - - // Fetch from remote to ensure we have the latest branch info - log::info!("[git] Step 1/2: git fetch origin"); - let mut fetch_cmd = git_command(); - fetch_cmd.arg("-C").arg(path).arg("fetch").arg("origin"); - let fetch_output = run_git_logged(&mut fetch_cmd, "get remote branches fetch") - .map_err(|e| format!("Failed to execute git fetch: {}", e))?; - - if !fetch_output.status.success() { - let stderr = String::from_utf8_lossy(&fetch_output.stderr); - log::error!("[git] Step 1/2 FAILED: git fetch: {}", stderr); - return Err(format!("Git fetch failed: {}", stderr)); - } - - // Get list of remote branches - log::info!("[git] Step 2/2: git ls-remote --heads origin"); - let ls_remote_output = git_command() - .arg("-C") - .arg(path) - .arg("ls-remote") - .arg("--heads") - .arg("origin") - .output() - .map_err(|e| format!("Failed to execute git ls-remote: {}", e))?; - - if !ls_remote_output.status.success() { - let stderr = String::from_utf8_lossy(&ls_remote_output.stderr); - log::error!("[git] Step 2/2 FAILED: git ls-remote: {}", stderr); - return Err(format!("Git ls-remote failed: {}", stderr)); - } - - let output_str = String::from_utf8_lossy(&ls_remote_output.stdout); - let branches: Vec = output_str - .lines() - .filter_map(|line| { - // Format: \trefs/heads/ - let parts: Vec<&str> = line.split('\t').collect(); - if parts.len() == 2 { - parts[1].strip_prefix("refs/heads/").map(|s| s.to_string()) - } else { - None - } - }) - .collect(); - - log::info!("[git] Found {} remote branches", branches.len()); - Ok(branches) -} - -/// Get combined git diff for AI commit message generation -pub fn get_git_diff(path: &Path) -> Result { - log::info!("[git] Getting diff for: {}", path.display()); - - // Get staged diff - let staged = git_command() - .arg("-C") - .arg(path) - .args(["diff", "--cached", "--stat"]) - .output() - .map_err(|e| format!("Failed to get staged diff: {}", e))?; - - // Get unstaged diff (tracked files) - let unstaged = git_command() - .arg("-C") - .arg(path) - .args(["diff", "--stat"]) - .output() - .map_err(|e| format!("Failed to get unstaged diff: {}", e))?; - - // Get untracked files - let untracked = git_command() - .arg("-C") - .arg(path) - .args(["ls-files", "--others", "--exclude-standard"]) - .output() - .map_err(|e| format!("Failed to get untracked files: {}", e))?; - - // Also get a compact diff of actual content changes (limited size for AI) - let content_diff = git_command() - .arg("-C") - .arg(path) - .args(["diff", "HEAD", "--no-color", "-U2"]) - .output() - .map_err(|e| format!("Failed to get content diff: {}", e))?; - - let mut result = String::new(); - - let staged_str = String::from_utf8_lossy(&staged.stdout); - if !staged_str.trim().is_empty() { - result.push_str("Staged changes:\n"); - result.push_str(&staged_str); - result.push('\n'); - } - - let unstaged_str = String::from_utf8_lossy(&unstaged.stdout); - if !unstaged_str.trim().is_empty() { - result.push_str("Unstaged changes:\n"); - result.push_str(&unstaged_str); - result.push('\n'); - } - - let untracked_str = String::from_utf8_lossy(&untracked.stdout); - if !untracked_str.trim().is_empty() { - result.push_str("New files:\n"); - result.push_str(&untracked_str); - result.push('\n'); - } - - let diff_str = String::from_utf8_lossy(&content_diff.stdout); - if !diff_str.trim().is_empty() { - // Truncate to ~4000 chars to keep token usage reasonable - let truncated: String = diff_str.chars().take(4000).collect(); - result.push_str("Diff:\n"); - result.push_str(&truncated); - if diff_str.len() > 4000 { - result.push_str("\n... (truncated)"); - } - } - - if result.trim().is_empty() { - return Err("No changes to commit".to_string()); - } - - Ok(result) -} - -/// Stage all changes and commit with the given message -pub fn commit_all( - path: &Path, - message: &str, - author_name: Option<&str>, - author_email: Option<&str>, - skip_hooks: bool, -) -> Result { - log::info!( - "[git] Committing all changes at: {}, skip_hooks={}", - path.display(), - skip_hooks - ); - - // git add -A - let add_output = git_command() - .arg("-C") - .arg(path) - .args(["add", "-A"]) - .output() - .map_err(|e| format!("Failed to stage changes: {}", e))?; - - if !add_output.status.success() { - let stderr = String::from_utf8_lossy(&add_output.stderr); - return Err(format!("git add failed: {}", stderr)); - } - - // git commit -m with optional author override - let mut cmd = git_command(); - cmd.arg("-C").arg(path); - if let Some(name) = author_name { - cmd.env("GIT_AUTHOR_NAME", name) - .env("GIT_COMMITTER_NAME", name); - } - if let Some(email) = author_email { - cmd.env("GIT_AUTHOR_EMAIL", email) - .env("GIT_COMMITTER_EMAIL", email); - } - let mut args = vec!["commit", "-m", message]; - if skip_hooks { - args.push("--no-verify"); - } - let commit_output = cmd - .args(args) - .output() - .map_err(|e| format!("Failed to commit: {}", e))?; - - if !commit_output.status.success() { - let stderr = String::from_utf8_lossy(&commit_output.stderr); - return Err(format!("git commit failed: {}", stderr)); - } - - let stdout = String::from_utf8_lossy(&commit_output.stdout) - .trim() - .to_string(); - log::info!("[git] Commit successful: {}", stdout); - Ok(format!("Committed: {}", message)) -} - -/// Get local git user.name and user.email config -pub fn get_git_user_config(path: &Path) -> Result<(Option, Option), String> { - let name_output = git_command() - .arg("-C") - .arg(path) - .args(["config", "--local", "user.name"]) - .output() - .map_err(|e| format!("Failed to get user.name: {}", e))?; - let name = if name_output.status.success() { - Some( - String::from_utf8_lossy(&name_output.stdout) - .trim() - .to_string(), - ) - } else { - None - }; - - let email_output = git_command() - .arg("-C") - .arg(path) - .args(["config", "--local", "user.email"]) - .output() - .map_err(|e| format!("Failed to get user.email: {}", e))?; - let email = if email_output.status.success() { - Some( - String::from_utf8_lossy(&email_output.stdout) - .trim() - .to_string(), - ) - } else { - None - }; - - Ok((name, email)) -} - -/// Set local git user.name and user.email config -pub fn set_git_user_config( - path: &Path, - name: Option<&str>, - email: Option<&str>, -) -> Result<(), String> { - if let Some(name) = name { - let output = git_command() - .arg("-C") - .arg(path) - .args(["config", "user.name", name]) - .output() - .map_err(|e| format!("Failed to set user.name: {}", e))?; - if !output.status.success() { - return Err(format!( - "git config user.name failed: {}", - String::from_utf8_lossy(&output.stderr) - )); - } - } - if let Some(email) = email { - let output = git_command() - .arg("-C") - .arg(path) - .args(["config", "user.email", email]) - .output() - .map_err(|e| format!("Failed to set user.email: {}", e))?; - if !output.status.success() { - return Err(format!( - "git config user.email failed: {}", - String::from_utf8_lossy(&output.stderr) - )); - } - } - Ok(()) -} - -// ==================== Changed Files API ==================== - -#[derive(Debug, Serialize, Clone)] -pub struct ChangedFile { - pub path: String, - pub status: String, // "M" | "A" | "D" | "R" | "?" (untracked) | "C" (copied) - pub staged: bool, -} - -/// Get list of changed files in a git repo using `git status --porcelain=v1`. -pub fn get_changed_files(path: &Path) -> Result, String> { - let output = git_command() - .arg("-C") - .arg(path) - .args(["status", "--porcelain=v1"]) - .output() - .map_err(|e| format!("Failed to get git status: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git status failed: {}", stderr)); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let mut files = Vec::new(); - - for line in stdout.lines() { - if line.len() < 4 { - continue; - } - let index_status = line.chars().next().unwrap_or(' '); - let worktree_status = line.chars().nth(1).unwrap_or(' '); - let file_path = line[3..].to_string(); - - // Handle rename: "R old -> new" - let file_path = if file_path.contains(" -> ") { - file_path - .split(" -> ") - .last() - .unwrap_or(&file_path) - .to_string() - } else { - file_path - }; - - // Determine status and staged state - let (status, staged) = match (index_status, worktree_status) { - ('?', '?') => ("?".to_string(), false), // untracked - ('A', _) => ("A".to_string(), true), // added (staged) - ('D', _) => ("D".to_string(), true), // deleted (staged) - ('R', _) => ("R".to_string(), true), // renamed (staged) - ('C', _) => ("C".to_string(), true), // copied (staged) - ('M', _) => ("M".to_string(), true), // modified (staged) - (_, 'M') => ("M".to_string(), false), // modified (unstaged) - (_, 'D') => ("D".to_string(), false), // deleted (unstaged) - _ => ("M".to_string(), false), // fallback - }; - - files.push(ChangedFile { - path: file_path, - status, - staged, - }); - } - - Ok(files) -} - -#[derive(Debug, Serialize, Clone)] -pub struct FileDiff { - pub file_path: String, - pub old_content: String, - pub new_content: String, - pub is_new: bool, - pub is_deleted: bool, - pub is_binary: bool, -} - -/// Get old (HEAD) and new (working tree) content for a single file for side-by-side diff. -pub fn get_file_diff(path: &Path, file_path: &str) -> Result { - let full_path = path.join(file_path); - - // Try to get old content from HEAD - let old_output = git_command() - .arg("-C") - .arg(path) - .args(["show", &format!("HEAD:{}", file_path)]) - .output(); - - let old_content = match old_output { - Ok(out) if out.status.success() => { - // Check if binary - let raw = &out.stdout; - if raw.contains(&0u8) { - return Ok(FileDiff { - file_path: file_path.to_string(), - old_content: String::new(), - new_content: String::new(), - is_new: false, - is_deleted: false, - is_binary: true, - }); - } - String::from_utf8_lossy(raw).to_string() - } - _ => String::new(), // File doesn't exist in HEAD (new file) - }; - - let is_new = old_content.is_empty(); - - // Get new content from working tree - let new_content = if full_path.exists() { - match std::fs::read(&full_path) { - Ok(bytes) => { - if bytes.contains(&0u8) { - return Ok(FileDiff { - file_path: file_path.to_string(), - old_content: String::new(), - new_content: String::new(), - is_new, - is_deleted: false, - is_binary: true, - }); - } - String::from_utf8_lossy(&bytes).to_string() - } - Err(_) => String::new(), - } - } else { - String::new() // File deleted - }; - - let is_deleted = new_content.is_empty() && !is_new; - - Ok(FileDiff { - file_path: file_path.to_string(), - old_content, - new_content, - is_new, - is_deleted, - is_binary: false, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use serial_test::serial; - use std::process::Command; - use tempfile::TempDir; - - fn run_git(repo: &Path, args: &[&str]) { - let output = Command::new("git") - .args(args) - .current_dir(repo) - .output() - .expect("run git command"); - assert!( - output.status.success(), - "git {:?} failed\nstdout:\n{}\nstderr:\n{}", - args, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } - - fn git_output(repo: &Path, args: &[&str]) -> String { - let output = Command::new("git") - .args(args) - .current_dir(repo) - .output() - .expect("run git command"); - assert!( - output.status.success(), - "git {:?} failed\nstdout:\n{}\nstderr:\n{}", - args, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8_lossy(&output.stdout).trim().to_string() - } - - fn make_test_repo() -> TempDir { - let temp = tempfile::tempdir().expect("create temp repo"); - let repo = temp.path(); - - run_git(repo, &["init"]); - run_git(repo, &["checkout", "-b", "main"]); - run_git(repo, &["config", "user.email", "test@example.com"]); - run_git(repo, &["config", "user.name", "Test User"]); - - std::fs::write(repo.join("README.md"), "initial\n").expect("write initial file"); - run_git(repo, &["add", "README.md"]); - run_git(repo, &["commit", "-m", "initial commit"]); - run_git(repo, &["branch", "test"]); - - let origin_path = repo.join(".git").join("origin.git"); - let output = Command::new("git") - .args(["init", "--bare"]) - .arg(&origin_path) - .output() - .expect("init bare origin"); - assert!( - output.status.success(), - "git init --bare failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - run_git( - repo, - &["remote", "add", "origin", origin_path.to_str().unwrap()], - ); - run_git(repo, &["push", "origin", "main"]); - run_git(repo, &["push", "origin", "test"]); - run_git(repo, &["fetch", "origin"]); - - run_git(repo, &["checkout", "-b", "feature/demo"]); - std::fs::write(repo.join("feature.txt"), "feature\n").expect("write feature file"); - run_git(repo, &["add", "feature.txt"]); - run_git(repo, &["commit", "-m", "feature commit"]); - - temp - } - - fn clone_repo(origin_path: &Path, clone_path: &Path) { - let output = Command::new("git") - .arg("clone") - .arg(origin_path) - .arg(clone_path) - .output() - .expect("clone repo"); - assert!( - output.status.success(), - "git clone {} {} failed\nstdout:\n{}\nstderr:\n{}", - origin_path.display(), - clone_path.display(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } - - fn changed_file<'a>(files: &'a [ChangedFile], path: &str) -> &'a ChangedFile { - files - .iter() - .find(|file| file.path == path) - .unwrap_or_else(|| panic!("missing changed file {path}; got {files:?}")) - } - - #[serial] - #[test] - fn find_main_worktree_returns_main_for_linked_worktree() { - let repo = make_test_repo(); - let path = repo.path(); - let linked_parent = tempfile::tempdir().expect("create linked worktree parent"); - let linked = linked_parent.path().join("linked-main"); - - run_git(path, &["worktree", "add", linked.to_str().unwrap(), "main"]); - - let main = find_main_worktree(&linked).expect("linked worktree resolves main path"); - - assert_eq!( - std::fs::canonicalize(main).expect("canonical main worktree"), - std::fs::canonicalize(path).expect("canonical repo path") - ); - } - - #[serial] - #[test] - fn find_main_worktree_ignores_plain_dirs_and_malformed_git_files() { - let temp = tempfile::tempdir().expect("create temp dir"); - - assert_eq!(find_main_worktree(temp.path()), None); - - std::fs::write(temp.path().join(".git"), "not a gitdir pointer") - .expect("write malformed .git file"); - - assert_eq!(find_main_worktree(temp.path()), None); - } - - #[serial] - #[test] - fn handle_branch_checkout_conflict_noops_when_main_on_different_branch() { - let repo = make_test_repo(); - let path = repo.path(); - - let result = handle_branch_checkout_conflict(path, "main") - .expect("different current branch does not need detach"); - - assert_eq!(result, (false, None)); - assert_eq!( - git_output(path, &["branch", "--show-current"]), - "feature/demo" - ); - } - - #[serial] - #[test] - fn get_changed_files_marks_merge_conflict_as_unstaged_modified() { - let repo = make_test_repo(); - let path = repo.path(); - - std::fs::write(path.join("README.md"), "feature side\n").expect("write feature side"); - run_git(path, &["add", "README.md"]); - run_git(path, &["commit", "-m", "feature readme conflict"]); - run_git(path, &["checkout", "main"]); - std::fs::write(path.join("README.md"), "main side\n").expect("write main side"); - run_git(path, &["add", "README.md"]); - run_git(path, &["commit", "-m", "main readme conflict"]); - run_git(path, &["checkout", "feature/demo"]); - - let merge = Command::new("git") - .args(["merge", "main"]) - .current_dir(path) - .output() - .expect("run conflicting merge"); - assert!(!merge.status.success(), "merge should conflict"); - - let files = get_changed_files(path).expect("get conflict status"); - let conflicted = changed_file(&files, "README.md"); - assert_eq!(conflicted.status, "M"); - assert!(!conflicted.staged); - - run_git(path, &["merge", "--abort"]); - } - - #[serial] - #[test] - fn get_git_user_config_returns_none_for_unset_local_values() { - let repo = make_test_repo(); - let path = repo.path(); - run_git(path, &["config", "--unset", "user.name"]); - run_git(path, &["config", "--unset", "user.email"]); - - let (name, email) = get_git_user_config(path).expect("read unset local config"); - - assert_eq!(name, None); - assert_eq!(email, None); - } - - #[cfg(unix)] - #[serial] - #[test] - fn commit_all_skip_hooks_bypasses_failing_pre_commit_hook() { - use std::os::unix::fs::PermissionsExt; - - let repo = make_test_repo(); - let path = repo.path(); - let hook = path.join(".git").join("hooks").join("pre-commit"); - std::fs::write(&hook, "#!/bin/sh\nexit 1\n").expect("write failing pre-commit hook"); - let mut permissions = std::fs::metadata(&hook) - .expect("read hook metadata") - .permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&hook, permissions).expect("make hook executable"); - - std::fs::write(path.join("hooked.txt"), "hooked\n").expect("write hooked file"); - let err = commit_all(path, "hook should fail", None, None, false).unwrap_err(); - assert!(err.contains("git commit failed"), "{err}"); - - let committed = - commit_all(path, "hook skipped", None, None, true).expect("commit bypasses hook"); - - assert_eq!(committed, "Committed: hook skipped"); - assert_eq!( - git_output(path, &["log", "-1", "--format=%s"]), - "hook skipped" - ); - } - - #[serial] - #[test] - fn remote_url_to_web_url_handles_trimmed_http_and_nested_ssh_forms() { - assert_eq!( - remote_url_to_web_url(" https://github.com/owner/repo.git ").as_deref(), - Some("https://github.com/owner/repo") - ); - assert_eq!( - remote_url_to_web_url("http://gitlab.local/group/repo").as_deref(), - Some("http://gitlab.local/group/repo") - ); - assert_eq!( - remote_url_to_web_url("git@gitlab.example.com:group/sub/repo.git").as_deref(), - Some("https://gitlab.example.com/group/sub/repo") - ); - assert_eq!( - remote_url_to_web_url("ssh://git@example.com/group/repo.git"), - None - ); - } - - #[serial] - #[test] - fn detect_git_platform_errors_for_non_git_directory() { - let non_git = tempfile::tempdir().expect("create non-git dir"); - - let err = detect_git_platform(non_git.path()).unwrap_err(); - - assert!(err.contains("Git remote failed"), "{err}"); - } - - #[serial] - #[test] - fn get_remote_origin_url_reports_missing_origin_remote() { - let repo = tempfile::tempdir().expect("create repo without origin"); - run_git(repo.path(), &["init"]); - - let err = get_remote_origin_url(repo.path()).unwrap_err(); - - assert!(err.contains("Failed to get remote URL"), "{err}"); - } - - #[serial] - #[test] - fn get_branch_status_returns_default_for_non_git_directory() { - let non_git = tempfile::tempdir().expect("create non-git dir"); - - let status = get_branch_status(non_git.path(), "project-a", "main"); - - assert_eq!(status.project_name, "project-a"); - assert_eq!(status.branch_name, "unknown"); - assert!(!status.has_uncommitted); - assert_eq!(status.uncommitted_count, 0); - assert!(!status.is_pushed); - assert_eq!(status.unpushed_commits, 0); - assert!(!status.has_merge_request); - assert_eq!(status.remote_url, ""); - } - - #[serial] - #[test] - fn get_worktree_info_counts_behind_base_after_remote_advances() { - let repo = make_test_repo(); - let path = repo.path(); - let origin_url = git_output(path, &["remote", "get-url", "origin"]); - let upstream = tempfile::tempdir().expect("create upstream clone dir"); - clone_repo(Path::new(&origin_url), upstream.path()); - run_git(upstream.path(), &["checkout", "main"]); - run_git( - upstream.path(), - &["config", "user.email", "upstream@example.com"], - ); - run_git(upstream.path(), &["config", "user.name", "Upstream User"]); - std::fs::write(upstream.path().join("main-only.txt"), "main only\n") - .expect("write upstream main file"); - run_git(upstream.path(), &["add", "main-only.txt"]); - run_git(upstream.path(), &["commit", "-m", "advance main"]); - run_git(upstream.path(), &["push", "origin", "main"]); - run_git(path, &["fetch", "origin"]); - - let info = get_worktree_info_for_branches(path, "main", "test"); - - assert_eq!(info.current_branch, "feature/demo"); - assert_eq!(info.ahead_of_base, 1); - assert_eq!(info.behind_base, 1); - assert!(!info.is_merged_to_base); - } - - #[serial] - #[test] - fn get_branch_diff_stats_counts_behind_base_after_remote_advances() { - let repo = make_test_repo(); - let path = repo.path(); - let origin_url = git_output(path, &["remote", "get-url", "origin"]); - let upstream = tempfile::tempdir().expect("create upstream clone dir"); - clone_repo(Path::new(&origin_url), upstream.path()); - run_git(upstream.path(), &["checkout", "main"]); - run_git( - upstream.path(), - &["config", "user.email", "upstream@example.com"], - ); - run_git(upstream.path(), &["config", "user.name", "Upstream User"]); - std::fs::write(upstream.path().join("remote-main.txt"), "remote main\n") - .expect("write remote main file"); - run_git(upstream.path(), &["add", "remote-main.txt"]); - run_git(upstream.path(), &["commit", "-m", "remote main commit"]); - run_git(upstream.path(), &["push", "origin", "main"]); - run_git(path, &["fetch", "origin"]); - - let stats = get_branch_diff_stats(path, "main", Some("test")); - - assert_eq!(stats.ahead, 1); - assert_eq!(stats.behind, 1); - assert_eq!(stats.unpushed_commits, 1); - assert_eq!(stats.ahead_of_test, 1); - } - - #[serial] - #[test] - fn get_git_diff_includes_staged_sections_for_added_modified_and_deleted_files() { - let repo = make_test_repo(); - let path = repo.path(); - std::fs::write(path.join("README.md"), "staged readme\n").expect("modify readme"); - std::fs::write(path.join("staged-new.txt"), "staged new\n").expect("write staged file"); - run_git(path, &["rm", "feature.txt"]); - run_git(path, &["add", "README.md", "staged-new.txt"]); - - let diff = get_git_diff(path).expect("get staged diff"); - - assert!(diff.contains("Staged changes:"), "{diff}"); - assert!(diff.contains("README.md"), "{diff}"); - assert!(diff.contains("staged-new.txt"), "{diff}"); - assert!(diff.contains("feature.txt"), "{diff}"); - assert!(diff.contains("Diff:"), "{diff}"); - } - - #[serial] - #[test] - fn get_file_diff_reports_absent_never_tracked_file_as_new_empty_file() { - let repo = make_test_repo(); - - let diff = get_file_diff(repo.path(), "missing.txt").expect("missing file diff"); - - assert_eq!(diff.file_path, "missing.txt"); - assert_eq!(diff.old_content, ""); - assert_eq!(diff.new_content, ""); - assert!(diff.is_new); - assert!(!diff.is_deleted); - assert!(!diff.is_binary); - } - - #[serial] - #[test] - fn set_git_user_config_updates_name_and_email_independently() { - let repo = make_test_repo(); - let path = repo.path(); - run_git(path, &["config", "--unset", "user.name"]); - run_git(path, &["config", "--unset", "user.email"]); - - set_git_user_config(path, Some("Only Name"), None).expect("set only name"); - let (name, email) = get_git_user_config(path).expect("get name-only config"); - assert_eq!(name.as_deref(), Some("Only Name")); - assert_eq!(email, None); - - set_git_user_config(path, None, Some("only-email@example.com")).expect("set only email"); - let (name, email) = get_git_user_config(path).expect("get partial config"); - assert_eq!(name.as_deref(), Some("Only Name")); - assert_eq!(email.as_deref(), Some("only-email@example.com")); - } - - #[serial] - #[test] - fn check_remote_branch_exists_returns_false_for_missing_tracking_branch() { - let repo = make_test_repo(); - - let exists = check_remote_branch_exists(repo.path(), "does-not-exist") - .expect("missing tracking branch check succeeds"); - - assert!(!exists); - } - - #[serial] - #[test] - fn get_worktree_info_reports_branch_remote_ahead_and_uncommitted_counts() { - let repo = make_test_repo(); - let path = repo.path(); - std::fs::write(path.join("untracked.txt"), "new\n").expect("write untracked file"); - - let info = get_worktree_info_for_branches(path, "main", "test"); - - assert_eq!(info.current_branch, "feature/demo"); - assert_eq!(info.uncommitted_count, 1); - assert_eq!(info.ahead_of_base, 1); - assert_eq!(info.ahead_of_test, 1); - assert_eq!(info.unpushed_commits, 1); - assert!(info.remote_url.ends_with(".git/origin.git")); - assert!(!info.is_merged_to_base); - assert!(!info.is_merged_to_test); - } - - #[serial] - #[test] - fn get_worktree_info_returns_default_for_non_git_directory() { - let dir = tempfile::tempdir().expect("create non-git dir"); - - let info = get_worktree_info(dir.path()); - - assert_eq!(info.current_branch, "unknown"); - assert_eq!(info.uncommitted_count, 0); - assert_eq!(info.ahead_of_base, 0); - assert_eq!(info.remote_url, ""); - } - - #[serial] - #[test] - fn get_branch_status_distinguishes_pushed_main_from_unpushed_feature() { - let repo = make_test_repo(); - let path = repo.path(); - - let feature_status = get_branch_status(path, "demo", "main"); - assert_eq!(feature_status.project_name, "demo"); - assert_eq!(feature_status.branch_name, "feature/demo"); - assert!(!feature_status.is_pushed); - assert_eq!(feature_status.unpushed_commits, 1); - assert!(!feature_status.has_uncommitted); - - run_git(path, &["checkout", "main"]); - let main_status = get_branch_status(path, "demo", "main"); - assert_eq!(main_status.branch_name, "main"); - assert!(main_status.is_pushed); - assert_eq!(main_status.unpushed_commits, 0); - assert_eq!(main_status.remote_url, feature_status.remote_url); - } - - #[serial] - #[test] - fn get_branch_status_counts_untracked_worktree_changes() { - let repo = make_test_repo(); - let path = repo.path(); - std::fs::write(path.join("local.txt"), "local\n").expect("write local file"); - - let status = get_branch_status(path, "demo", "main"); - - assert!(status.has_uncommitted); - assert_eq!(status.uncommitted_count, 1); - assert_eq!(status.branch_name, "feature/demo"); - } - - #[serial] - #[test] - fn check_remote_branch_exists_uses_local_tracking_refs_and_errors_for_non_git() { - let repo = make_test_repo(); - - assert_eq!( - check_remote_branch_exists(repo.path(), "main").expect("check main"), - true - ); - assert_eq!( - check_remote_branch_exists(repo.path(), "missing").expect("check missing"), - false - ); - - let non_git = tempfile::tempdir().expect("create non-git dir"); - let err = check_remote_branch_exists(non_git.path(), "main").unwrap_err(); - assert!(err.contains("Git branch check failed"), "{err}"); - } - - #[serial] - #[test] - fn get_current_branch_inner_reads_branch_and_errors_for_non_git() { - let repo = make_test_repo(); - - assert_eq!( - get_current_branch_inner(repo.path()).expect("read current branch"), - "feature/demo" - ); - - let non_git = tempfile::tempdir().expect("create non-git dir"); - let err = get_current_branch_inner(non_git.path()).unwrap_err(); - assert_eq!(err, "Failed to get current branch"); - } - - #[serial] - #[test] - fn get_changed_files_parses_unstaged_staged_and_untracked_entries() { - let repo = make_test_repo(); - let path = repo.path(); - std::fs::write(path.join("README.md"), "changed\n").expect("modify readme"); - std::fs::write(path.join("staged.txt"), "staged\n").expect("write staged file"); - run_git(path, &["add", "staged.txt"]); - std::fs::write(path.join("untracked.txt"), "untracked\n").expect("write untracked file"); - - let files = get_changed_files(path).expect("get changed files"); - - let modified = changed_file(&files, "README.md"); - assert_eq!(modified.status, "M"); - assert!(!modified.staged); - - let staged = changed_file(&files, "staged.txt"); - assert_eq!(staged.status, "A"); - assert!(staged.staged); - - let untracked = changed_file(&files, "untracked.txt"); - assert_eq!(untracked.status, "?"); - assert!(!untracked.staged); - } - - #[serial] - #[test] - fn get_changed_files_returns_git_status_error_for_non_git_directory() { - let non_git = tempfile::tempdir().expect("create non-git dir"); - - let err = get_changed_files(non_git.path()).unwrap_err(); - - assert!(err.contains("git status failed"), "{err}"); - assert!(err.contains("not a git repository"), "{err}"); - } - - #[serial] - #[test] - fn get_file_diff_reports_modified_new_deleted_and_binary_files() { - let repo = make_test_repo(); - let path = repo.path(); - - std::fs::write(path.join("README.md"), "changed\n").expect("modify readme"); - let modified = get_file_diff(path, "README.md").expect("modified diff"); - assert_eq!(modified.file_path, "README.md"); - assert_eq!(modified.old_content, "initial\n"); - assert_eq!(modified.new_content, "changed\n"); - assert!(!modified.is_new); - assert!(!modified.is_deleted); - assert!(!modified.is_binary); - - std::fs::write(path.join("new.txt"), "new\n").expect("write new file"); - let new_file = get_file_diff(path, "new.txt").expect("new file diff"); - assert_eq!(new_file.old_content, ""); - assert_eq!(new_file.new_content, "new\n"); - assert!(new_file.is_new); - assert!(!new_file.is_deleted); - - std::fs::remove_file(path.join("README.md")).expect("delete readme"); - let deleted = get_file_diff(path, "README.md").expect("deleted diff"); - assert_eq!(deleted.old_content, "initial\n"); - assert_eq!(deleted.new_content, ""); - assert!(!deleted.is_new); - assert!(deleted.is_deleted); - - std::fs::write(path.join("binary.bin"), b"a\0b").expect("write binary file"); - let binary = get_file_diff(path, "binary.bin").expect("binary diff"); - assert!(binary.is_binary); - assert_eq!(binary.file_path, "binary.bin"); - } - - #[serial] - #[test] - fn get_git_diff_errors_when_clean_and_summarizes_local_changes() { - let repo = make_test_repo(); - let path = repo.path(); - assert_eq!( - get_git_diff(path).unwrap_err(), - "No changes to commit".to_string() - ); - - std::fs::write(path.join("README.md"), "changed\n").expect("modify readme"); - std::fs::write(path.join("new.txt"), "new\n").expect("write new file"); - - let diff = get_git_diff(path).expect("get git diff"); - - assert!(diff.contains("Unstaged changes:"), "{diff}"); - assert!(diff.contains("README.md"), "{diff}"); - assert!(diff.contains("New files:"), "{diff}"); - assert!(diff.contains("new.txt"), "{diff}"); - assert!(diff.contains("Diff:"), "{diff}"); - } - - #[serial] - #[test] - fn get_branch_diff_stats_counts_local_ahead_and_changed_files() { - let repo = make_test_repo(); - let path = repo.path(); - std::fs::write(path.join("README.md"), "changed\n").expect("modify readme"); - - let stats = get_branch_diff_stats(path, "main", Some("test")); - - assert_eq!(stats.ahead, 1); - assert_eq!(stats.behind, 0); - assert_eq!(stats.changed_files, 1); - assert_eq!(stats.unpushed_commits, 1); - assert_eq!(stats.ahead_of_test, 1); - } - - #[serial] - #[test] - fn sync_with_base_branch_uses_local_origin_and_reports_success() { - let repo = make_test_repo(); - - let message = sync_with_base_branch(repo.path(), "main").expect("sync with main"); - - assert_eq!(message, "Successfully synced with main"); - assert_eq!( - git_output(repo.path(), &["branch", "--show-current"]), - "feature/demo" - ); - } - - #[serial] - #[test] - fn handle_branch_checkout_conflict_detaches_clean_matching_main_worktree() { - let repo = make_test_repo(); - let path = repo.path(); - - let result = handle_branch_checkout_conflict(path, "feature/demo") - .expect("handle branch checkout conflict"); - - assert_eq!(result, (true, Some("feature/demo".to_string()))); - assert_eq!( - git_output(path, &["rev-parse", "--abbrev-ref", "HEAD"]), - "HEAD" - ); - - run_git(path, &["checkout", "feature/demo"]); - assert_eq!( - git_output(path, &["branch", "--show-current"]), - "feature/demo" - ); - } - - #[serial] - #[test] - fn handle_branch_checkout_conflict_rejects_dirty_matching_main_worktree() { - let repo = make_test_repo(); - let path = repo.path(); - std::fs::write(path.join("dirty.txt"), "dirty\n").expect("write dirty file"); - - let err = handle_branch_checkout_conflict(path, "feature/demo").unwrap_err(); - - assert!(err.contains("feature/demo"), "{err}"); - assert!(err.contains("未提交的更改"), "{err}"); - assert_eq!( - git_output(path, &["branch", "--show-current"]), - "feature/demo" - ); - } - - #[serial] - #[test] - fn merge_to_test_branch_merges_locally_pushes_to_local_origin_and_restores_branch() { - let repo = make_test_repo(); - let path = repo.path(); - - let result = merge_to_test_branch(path, "test").expect("merge to test branch"); - - assert!( - result.contains("成功将 feature/demo 合并到 test"), - "{result}" - ); - assert_eq!( - git_output(path, &["branch", "--show-current"]), - "feature/demo" - ); - run_git( - path, - &["merge-base", "--is-ancestor", "feature/demo", "origin/test"], - ); - } - - #[serial] - #[test] - fn merge_to_base_branch_reports_checkout_error_for_nonexistent_branch() { - let repo = make_test_repo(); - - let err = merge_to_base_branch(repo.path(), "does-not-exist").unwrap_err(); - - assert!(err.contains("切换到 does-not-exist 分支失败"), "{err}"); - assert_eq!( - git_output(repo.path(), &["branch", "--show-current"]), - "feature/demo" - ); - } - - #[serial] - #[test] - fn push_pull_fetch_and_remote_branch_listing_use_local_bare_origin() { - let repo = make_test_repo(); - let path = repo.path(); - - let push = push_to_remote(path).expect("push current branch"); - assert_eq!(push, "Successfully pushed feature/demo to origin"); - assert!(check_remote_branch_exists(path, "feature/demo").expect("feature remote exists")); - - let origin_url = git_output(path, &["remote", "get-url", "origin"]); - let upstream = tempfile::tempdir().expect("create upstream clone dir"); - clone_repo(Path::new(&origin_url), upstream.path()); - run_git( - upstream.path(), - &["config", "user.email", "upstream@example.com"], - ); - run_git(upstream.path(), &["config", "user.name", "Upstream User"]); - run_git(upstream.path(), &["checkout", "feature/demo"]); - std::fs::write(upstream.path().join("upstream.txt"), "upstream\n") - .expect("write upstream file"); - run_git(upstream.path(), &["add", "upstream.txt"]); - run_git(upstream.path(), &["commit", "-m", "upstream commit"]); - run_git(upstream.path(), &["push", "origin", "feature/demo"]); - - let pull = pull_current_branch(path).expect("pull current branch"); - assert_eq!(pull, "Successfully pulled feature/demo from origin"); - assert_eq!( - std::fs::read_to_string(path.join("upstream.txt")).expect("read pulled file"), - "upstream\n" - ); - - fetch_remote(path).expect("fetch remote"); - let branches = get_remote_branches(path).expect("get remote branches"); - assert!(branches.contains(&"main".to_string()), "{branches:?}"); - assert!(branches.contains(&"test".to_string()), "{branches:?}"); - assert!( - branches.contains(&"feature/demo".to_string()), - "{branches:?}" - ); - } - - #[serial] - #[test] - fn sync_with_base_branch_reports_merge_conflict_from_local_origin() { - let repo = make_test_repo(); - let path = repo.path(); - - std::fs::write(path.join("README.md"), "feature side\n").expect("write feature readme"); - run_git(path, &["add", "README.md"]); - run_git(path, &["commit", "-m", "feature readme change"]); - run_git(path, &["checkout", "main"]); - std::fs::write(path.join("README.md"), "main side\n").expect("write main readme"); - run_git(path, &["add", "README.md"]); - run_git(path, &["commit", "-m", "main readme change"]); - run_git(path, &["push", "origin", "main"]); - run_git(path, &["checkout", "feature/demo"]); - - let err = sync_with_base_branch(path, "main").unwrap_err(); - - assert!(err.contains("Git merge failed"), "{err}"); - let status = git_output(path, &["status", "--porcelain"]); - assert!(status.contains("UU README.md"), "{status}"); - run_git(path, &["merge", "--abort"]); - } - - #[serial] - #[test] - fn merge_to_base_branch_merges_pushes_and_restores_feature_branch() { - let repo = make_test_repo(); - let path = repo.path(); - - let result = merge_to_base_branch(path, "main").expect("merge to base"); - - assert!( - result.contains("成功将 feature/demo 合并到 main"), - "{result}" - ); - assert_eq!( - git_output(path, &["branch", "--show-current"]), - "feature/demo" - ); - run_git(path, &["fetch", "origin"]); - run_git( - path, - &["merge-base", "--is-ancestor", "feature/demo", "origin/main"], - ); - } - - #[serial] - #[test] - fn merge_to_test_branch_aborts_conflict_and_restores_original_branch() { - let repo = make_test_repo(); - let path = repo.path(); - - std::fs::write(path.join("README.md"), "feature side\n").expect("write feature readme"); - run_git(path, &["add", "README.md"]); - run_git(path, &["commit", "-m", "feature readme change"]); - run_git(path, &["checkout", "test"]); - std::fs::write(path.join("README.md"), "test side\n").expect("write test readme"); - run_git(path, &["add", "README.md"]); - run_git(path, &["commit", "-m", "test readme change"]); - run_git(path, &["push", "origin", "test"]); - run_git(path, &["checkout", "feature/demo"]); - - let err = merge_to_test_branch(path, "test").unwrap_err(); - - assert!(err.contains("合并 feature/demo 到 test 失败"), "{err}"); - assert_eq!( - git_output(path, &["branch", "--show-current"]), - "feature/demo" - ); - assert_eq!(git_output(path, &["status", "--porcelain"]), ""); - } - - #[serial] - #[test] - fn get_changed_files_parses_rename_staged_modified_and_unstaged_delete() { - let repo = make_test_repo(); - let path = repo.path(); - - std::fs::write(path.join("delete-me.txt"), "delete me\n").expect("write tracked file"); - run_git(path, &["add", "delete-me.txt"]); - run_git(path, &["commit", "-m", "add delete target"]); - run_git(path, &["mv", "README.md", "RENAMED.md"]); - std::fs::write(path.join("feature.txt"), "feature changed\n") - .expect("modify tracked feature"); - run_git(path, &["add", "feature.txt"]); - std::fs::remove_file(path.join("delete-me.txt")).expect("delete tracked file"); - - let files = get_changed_files(path).expect("get changed files"); - - let renamed = changed_file(&files, "RENAMED.md"); - assert_eq!(renamed.status, "R"); - assert!(renamed.staged); - - let modified = changed_file(&files, "feature.txt"); - assert_eq!(modified.status, "M"); - assert!(modified.staged); - - let deleted = changed_file(&files, "delete-me.txt"); - assert_eq!(deleted.status, "D"); - assert!(!deleted.staged); - } - - #[serial] - #[test] - fn branch_diff_stats_handles_non_git_empty_test_branch_and_missing_refs() { - let non_git = tempfile::tempdir().expect("create non git dir"); - let empty = get_branch_diff_stats(non_git.path(), "main", Some("test")); - assert_eq!(empty.ahead, 0); - assert_eq!(empty.behind, 0); - assert_eq!(empty.changed_files, 0); - assert_eq!(empty.unpushed_commits, 0); - assert_eq!(empty.ahead_of_test, 0); - - let repo = make_test_repo(); - let no_test = get_branch_diff_stats(repo.path(), "main", Some("")); - assert_eq!(no_test.ahead, 1); - assert_eq!(no_test.ahead_of_test, 0); - - let missing_test = get_branch_diff_stats(repo.path(), "main", Some("missing-test")); - assert_eq!(missing_test.ahead, 1); - assert_eq!(missing_test.ahead_of_test, 0); - } - - #[serial] - #[test] - fn remote_url_platform_detection_and_pr_unknown_platform_are_pure_local_logic() { - let repo = make_test_repo(); - let path = repo.path(); - - assert_eq!( - remote_url_to_web_url("git@github.com:owner/repo.git").as_deref(), - Some("https://github.com/owner/repo") - ); - assert_eq!( - remote_url_to_web_url("https://gitlab.com/group/repo.git").as_deref(), - Some("https://gitlab.com/group/repo") - ); - assert!(remote_url_to_web_url("/tmp/local.git").is_none()); - - run_git( - path, - &[ - "remote", - "set-url", - "origin", - "git@github.com:owner/repo.git", - ], - ); - assert_eq!( - detect_git_platform(path).expect("detect github"), - GitPlatform::GitHub - ); - - run_git( - path, - &[ - "remote", - "set-url", - "origin", - "git@gitlab.com:group/repo.git", - ], - ); - assert_eq!( - detect_git_platform(path).expect("detect gitlab"), - GitPlatform::GitLab - ); - - run_git(path, &["remote", "set-url", "origin", "/tmp/local.git"]); - assert_eq!( - detect_git_platform(path).expect("detect unknown"), - GitPlatform::Unknown - ); - let err = create_pull_request(path, "main", "Title", "Body").unwrap_err(); - assert_eq!( - err, - "Unknown git platform. Only GitHub and GitLab are supported." - ); - } - - #[serial] - #[test] - fn detached_head_paths_report_head_boundary_conditions() { - let repo = make_test_repo(); - let path = repo.path(); - let head = git_output(path, &["rev-parse", "HEAD"]); - run_git(path, &["checkout", "--detach", &head]); - - assert_eq!( - get_current_branch_inner(path).expect("read detached branch"), - "HEAD" - ); - let info = get_worktree_info_for_branches(path, "main", "test"); - assert_eq!(info.current_branch, "HEAD"); - - let result = merge_to_test_branch(path, "test").expect("merge detached HEAD to test"); - assert!(result.contains("成功将 HEAD 合并到 test"), "{result}"); - assert_eq!( - git_output(path, &["rev-parse", "--abbrev-ref", "HEAD"]), - "test" - ); - let ancestor = Command::new("git") - .args(["merge-base", "--is-ancestor", &head, "origin/test"]) - .current_dir(path) - .status() - .expect("check detached commit ancestry"); - assert!( - !ancestor.success(), - "detached commit should not currently be merged despite success message" - ); - } - - #[serial] - #[test] - fn branch_status_detects_merge_request_ref_matching_head_commit() { - let repo = make_test_repo(); - let path = repo.path(); - run_git(path, &["update-ref", "refs/pull/7/head", "HEAD"]); - - let status = get_branch_status(path, "demo", "main"); - - assert_eq!(status.branch_name, "feature/demo"); - assert!(status.has_merge_request, "{status:?}"); - } - - #[serial] - #[test] - fn git_command_error_paths_report_specific_failures() { - let non_git = tempfile::tempdir().expect("create non-git dir"); - - let sync_err = sync_with_base_branch(non_git.path(), "main").unwrap_err(); - assert!(sync_err.contains("Git fetch failed"), "{sync_err}"); - - let push_err = push_to_remote(non_git.path()).unwrap_err(); - assert_eq!(push_err, "Failed to get current branch"); - - let pull_err = pull_current_branch(non_git.path()).unwrap_err(); - assert_eq!(pull_err, "Failed to get current branch"); - - let fetch_err = fetch_remote(non_git.path()).unwrap_err(); - assert!(fetch_err.contains("Git fetch failed"), "{fetch_err}"); - - let branches_err = get_remote_branches(non_git.path()).unwrap_err(); - assert!(branches_err.contains("Git fetch failed"), "{branches_err}"); - } - - #[serial] - #[test] - fn create_pull_request_returns_browser_urls_or_errors_without_network_success() { - let repo = make_test_repo(); - let path = repo.path(); - - run_git( - path, - &[ - "remote", - "set-url", - "origin", - "git@github.com:owner/repo.git", - ], - ); - let github = create_pull_request(path, "main", "Feature title", "Body text") - .expect("github should return browser fallback when gh cannot create PR"); - assert!( - github == "https://github.com/owner/repo/pull/new/feature/demo" - || github - .starts_with("https://github.com/owner/repo/compare/main...feature%2Fdemo?"), - "{github}" - ); - - let gitlab_origin = path.join(".git").join("gitlab-origin.git"); - let output = Command::new("git") - .args(["init", "--bare"]) - .arg(&gitlab_origin) - .output() - .expect("init gitlab-named bare origin"); - assert!( - output.status.success(), - "git init --bare failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - run_git( - path, - &[ - "remote", - "set-url", - "origin", - gitlab_origin.to_str().unwrap(), - ], - ); - let gitlab = create_pull_request(path, "main", "Feature title", "Body text").unwrap_err(); - assert!(gitlab.contains("Failed to create MR"), "{gitlab}"); - } - - #[serial] - #[test] - fn binary_file_diff_detects_binary_content_stored_in_head() { - let repo = make_test_repo(); - let path = repo.path(); - std::fs::write(path.join("tracked.bin"), b"old\0binary").expect("write binary file"); - run_git(path, &["add", "tracked.bin"]); - run_git(path, &["commit", "-m", "add binary"]); - - let diff = get_file_diff(path, "tracked.bin").expect("binary diff from HEAD"); - - assert_eq!(diff.file_path, "tracked.bin"); - assert!(diff.is_binary); - assert!(!diff.is_new); - assert!(!diff.is_deleted); - assert!(diff.old_content.is_empty()); - assert!(diff.new_content.is_empty()); - } - - #[serial] - #[test] - fn commit_all_applies_author_override_and_user_config_round_trips() { - let repo = make_test_repo(); - let path = repo.path(); - - set_git_user_config(path, Some("Local Name"), Some("local@example.com")) - .expect("set local git user config"); - let (name, email) = get_git_user_config(path).expect("get local git user config"); - assert_eq!(name.as_deref(), Some("Local Name")); - assert_eq!(email.as_deref(), Some("local@example.com")); - - std::fs::write(path.join("author.txt"), "authored\n").expect("write authored file"); - let message = commit_all( - path, - "author override commit", - Some("Override Name"), - Some("override@example.com"), - false, - ) - .expect("commit with author override"); - - assert_eq!(message, "Committed: author override commit"); - assert_eq!( - git_output(path, &["log", "-1", "--format=%an <%ae>"]), - "Override Name " - ); - } -} diff --git a/src-tauri/src/http_origin_policy.rs b/src-tauri/src/http_origin_policy.rs deleted file mode 100644 index fb1ffa1..0000000 --- a/src-tauri/src/http_origin_policy.rs +++ /dev/null @@ -1,103 +0,0 @@ -fn parse_origin_url(origin: &str) -> Option { - let parsed = url::Url::parse(origin).ok()?; - matches!(parsed.scheme(), "http" | "https").then_some(parsed) -} - -fn is_loopback_origin(origin: &url::Url) -> bool { - match origin.host() { - Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), - Some(url::Host::Ipv4(ipv4)) => ipv4.is_loopback(), - Some(url::Host::Ipv6(ipv6)) => ipv6.is_loopback(), - None => false, - } -} - -fn is_private_lan_origin(origin: &url::Url) -> bool { - matches!(origin.host(), Some(url::Host::Ipv4(ipv4)) if ipv4.is_private() || is_cgnat(ipv4)) -} - -/// Carrier-grade NAT range (100.64.0.0/10), used by Tailscale, etc. -fn is_cgnat(ip: std::net::Ipv4Addr) -> bool { - let octets = ip.octets(); - octets[0] == 100 && (64..128).contains(&octets[1]) -} - -fn same_origin(left: &url::Url, right: &url::Url) -> bool { - left.scheme() == right.scheme() - && match (left.host(), right.host()) { - (Some(url::Host::Domain(a)), Some(url::Host::Domain(b))) => a.eq_ignore_ascii_case(b), - (Some(url::Host::Ipv4(a)), Some(url::Host::Ipv4(b))) => a == b, - (Some(url::Host::Ipv6(a)), Some(url::Host::Ipv6(b))) => a == b, - _ => false, - } - && left.port_or_known_default() == right.port_or_known_default() -} - -pub fn is_allowed_origin(origin: &str, ngrok_url: Option<&str>) -> bool { - let Some(parsed_origin) = parse_origin_url(origin) else { - return false; - }; - - if is_loopback_origin(&parsed_origin) || is_private_lan_origin(&parsed_origin) { - return true; - } - - if let Some(ngrok_url) = ngrok_url { - if let Some(parsed_ngrok) = parse_origin_url(ngrok_url) { - if same_origin(&parsed_origin, &parsed_ngrok) { - return true; - } - } - } - - false -} - -#[cfg(test)] -mod tests { - use super::is_allowed_origin; - use serial_test::serial; - - #[serial] - #[test] - fn allows_exact_loopback_and_private_lan_origins_only() { - assert!(is_allowed_origin("http://localhost:1420", None)); - assert!(is_allowed_origin("https://127.0.0.1", None)); - assert!(is_allowed_origin("http://[::1]:8080", None)); - assert!(is_allowed_origin("http://192.168.1.8:3000", None)); - assert!(is_allowed_origin("http://10.0.0.8", None)); - assert!(is_allowed_origin("http://172.16.5.4", None)); - // CGNAT / Tailscale - assert!(is_allowed_origin("https://100.96.211.238:64896", None)); - assert!(is_allowed_origin("http://100.64.0.1:3000", None)); - assert!(is_allowed_origin("http://100.127.255.254", None)); - - assert!(!is_allowed_origin("http://100.63.255.255", None)); // below CGNAT - assert!(!is_allowed_origin("http://100.128.0.0", None)); // above CGNAT - assert!(!is_allowed_origin("https://localhost.evil.example", None)); - assert!(!is_allowed_origin("https://127.0.0.1.evil.example", None)); - assert!(!is_allowed_origin("https://192.168.1.8.evil.example", None)); - assert!(!is_allowed_origin("not-a-url", None)); - } - - #[serial] - #[test] - fn only_allows_exact_active_ngrok_origin() { - assert!(is_allowed_origin( - "https://demo.ngrok-free.app", - Some("https://demo.ngrok-free.app/") - )); - assert!(is_allowed_origin( - "https://demo.ngrok-free.app:443", - Some("https://demo.ngrok-free.app/") - )); - assert!(!is_allowed_origin( - "https://demo.ngrok-free.app.evil.example", - Some("https://demo.ngrok-free.app/") - )); - assert!(!is_allowed_origin( - "https://other.ngrok-free.app", - Some("https://demo.ngrok-free.app/") - )); - } -} diff --git a/src-tauri/src/http_server.rs b/src-tauri/src/http_server.rs deleted file mode 100644 index aa702f9..0000000 --- a/src-tauri/src/http_server.rs +++ /dev/null @@ -1,5615 +0,0 @@ -use axum::{ - extract::{ - ws::{Message, WebSocket, WebSocketUpgrade}, - ConnectInfo, Json, Query, - }, - http::{header, HeaderMap, StatusCode}, - response::{IntoResponse, Response}, - serve, Extension, Router, -}; -use futures_util::{SinkExt, StreamExt}; -use serde::Deserialize; -use serde_json::{json, Value}; -use std::collections::HashMap; -use std::net::SocketAddr; -use std::path::PathBuf; -use std::sync::Arc; -use tauri::{Emitter, Manager}; -use tokio::net::TcpListener; -use tokio::sync::Mutex as TokioMutex; -use tower_http::limit::RequestBodyLimitLayer; -use tower_http::services::{ServeDir, ServeFile}; - -use crate::pty_manager::bytes_to_utf8_with_pending; - -use crate::tls::TlsCerts; - -#[path = "http_server/middleware.rs"] -mod middleware; -#[path = "http_server/routing.rs"] -mod routing; - -use crate::{ - // Project management - add_existing_project_impl, - add_project_to_worktree_impl, - archive_worktree_impl, - check_worktree_status_impl, - clone_project_impl, - create_worktree_impl, - delete_archived_worktree_impl, - deploy_to_main_impl, - exit_main_occupation_impl, - get_config_path_info_impl, - // _impl functions (window-context commands) - get_current_workspace_impl, - get_main_occupation_impl, - get_main_workspace_status_impl, - get_workspace_config_impl, - git_ops, - import_external_project_impl, - list_worktrees_impl, - load_workspace_config, - lock_worktree_impl, - normalize_path, - remove_project_from_config_impl, - restore_worktree_impl, - save_workspace_config_impl, - scan_existing_projects_impl, - set_window_workspace_impl, - switch_workspace_impl, - terminate_worktree_locking_process_impl, - unlock_worktree_impl, - unregister_window_impl, - AddProjectToWorktreeRequest, - CloneProjectRequest, - ConnectedClient, - CreateWorktreeRequest, - OpenEditorRequest, - SwitchBranchRequest, - // Direct functions (no window context) - WorkspaceConfig, - AUTHENTICATED_SESSIONS, - AUTH_RATE_LIMITER, - CONNECTED_CLIENTS, - LOCK_BROADCAST, - NONCE_CACHE, - PTY_MANAGER, - SHARE_STATE, - TERMINAL_STATE_BROADCAST, -}; -use middleware::{ - auth_middleware, localhost_only_middleware, no_cache_html_middleware, - security_headers_middleware, session_id, -}; -use routing::{build_api_router, build_cors_layer, resolve_dist_path}; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/// Convert a Result to an Axum response (200 with JSON or 400 with error text). -fn result_json(r: Result) -> Response { - match r { - Ok(v) => (StatusCode::OK, Json(json!(v))).into_response(), - Err(e) => (StatusCode::BAD_REQUEST, e).into_response(), - } -} - -fn result_ok(r: Result<(), String>) -> Response { - match r { - Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => (StatusCode::BAD_REQUEST, e).into_response(), - } -} - -fn result_void_ok() -> Response { - StatusCode::NO_CONTENT.into_response() -} - -fn mask_api_key_for_response(key: &str) -> String { - let char_count = key.chars().count(); - if char_count <= 8 { - return "****".to_string(); - } - - let prefix: String = key.chars().take(4).collect(); - let suffix: String = key.chars().skip(char_count.saturating_sub(4)).collect(); - format!("{}...{}", prefix, suffix) -} - -/// Get a string parameter from JSON args, accepting either camelCase or snake_case key. -/// Returns owned String to avoid borrow lifetime issues with spawn_blocking. -fn get_param(args: &Value, key: &str) -> String { - let map = match args.as_object() { - Some(m) => m, - None => return String::new(), - }; - let camel = to_camel(key); - let snake = to_snake(key); - map.get(&camel) - .or_else(|| map.get(&snake)) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_default() -} - -fn get_param_opt(args: &Value, key: &str) -> Option { - let map = args.as_object()?; - let camel = to_camel(key); - let snake = to_snake(key); - map.get(&camel) - .or_else(|| map.get(&snake)) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) -} - -fn get_param_bool(args: &Value, key: &str, default: bool) -> bool { - let map = match args.as_object() { - Some(m) => m, - None => return default, - }; - let camel = to_camel(key); - let snake = to_snake(key); - map.get(&camel) - .or_else(|| map.get(&snake)) - .and_then(|v| v.as_bool()) - .unwrap_or(default) -} - -fn get_param_u64(args: &Value, key: &str, default: u64) -> u64 { - let map = match args.as_object() { - Some(m) => m, - None => return default, - }; - let camel = to_camel(key); - let snake = to_snake(key); - map.get(&camel) - .or_else(|| map.get(&snake)) - .and_then(|v| v.as_u64()) - .unwrap_or(default) -} - -fn to_camel(s: &str) -> String { - // e.g. "base_branch" -> "baseBranch", "workspace_path" -> "workspacePath" - let mut result = String::new(); - let mut chars = s.chars().peekable(); - while let Some(c) = chars.next() { - if c == '_' { - if let Some(next) = chars.next() { - result.push(next.to_ascii_uppercase()); - } - } else { - result.push(c); - } - } - result -} - -fn to_snake(s: &str) -> String { - // e.g. "baseBranch" -> "base_branch", "workspacePath" -> "workspace_path" - let mut result = String::new(); - for (i, c) in s.chars().enumerate() { - if c.is_uppercase() && i > 0 { - result.push('_'); - } - result.push(c.to_ascii_lowercase()); - } - result -} - -fn current_app_handle() -> Result { - crate::APP_HANDLE - .lock() - .map_err(|_| "Internal app state error".to_string())? - .clone() - .ok_or("App handle unavailable".to_string()) -} - -// --------------------------------------------------------------------------- -// Route handlers -// --------------------------------------------------------------------------- - -// -- Workspace management (no window context) -- - -async fn h_list_workspaces() -> Response { - let list: Vec<_> = crate::load_global_config() - .workspaces - .into_iter() - .map(|mut w| { - w.path = crate::normalize_path(&w.path); - w - }) - .collect(); - Json(json!(list)).into_response() -} - -#[derive(Deserialize)] -struct AddWsArgs { - name: String, - path: String, -} - -async fn h_add_workspace(Json(args): Json) -> Response { - result_ok(crate::add_workspace_internal(&args.name, &args.path)) -} - -#[derive(Deserialize)] -struct PathArgs { - path: String, -} - -async fn h_remove_workspace(Json(args): Json) -> Response { - result_ok(crate::remove_workspace_internal(&args.path)) -} - -async fn h_create_workspace(Json(args): Json) -> Response { - result_ok(crate::create_workspace_internal(&args.name, &args.path)) -} - -// -- Workspace management (with window/session context) -- - -async fn h_set_window_workspace(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let ws_path = normalize_path(&get_param(&args, "workspace_path")); - result_ok(set_window_workspace_impl(&sid, ws_path)) -} - -async fn h_get_current_workspace(headers: HeaderMap) -> Response { - let sid = session_id(&headers); - Json(json!(get_current_workspace_impl(&sid))).into_response() -} - -async fn h_switch_workspace(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let path = normalize_path(args["path"].as_str().unwrap_or("")); - result_ok(switch_workspace_impl(&sid, path)) -} - -async fn h_get_workspace_config(headers: HeaderMap) -> Response { - let sid = session_id(&headers); - result_json(get_workspace_config_impl(&sid)) -} - -async fn h_save_workspace_config(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let config: WorkspaceConfig = match serde_json::from_value(args["config"].clone()) { - Ok(c) => c, - Err(e) => { - return (StatusCode::BAD_REQUEST, format!("Invalid config: {}", e)).into_response() - } - }; - result_ok(save_workspace_config_impl(&sid, config)) -} - -async fn h_load_workspace_config_by_path(Json(args): Json) -> Response { - let path = normalize_path(args["path"].as_str().unwrap_or("")); - result_json(crate::commands::workspace::load_workspace_config_by_path( - path, - )) -} - -async fn h_save_workspace_config_by_path(Json(args): Json) -> Response { - let path = normalize_path(args["path"].as_str().unwrap_or("")); - let config: WorkspaceConfig = match serde_json::from_value(args["config"].clone()) { - Ok(c) => c, - Err(e) => { - return (StatusCode::BAD_REQUEST, format!("Invalid config: {}", e)).into_response() - } - }; - result_ok(crate::commands::workspace::save_workspace_config_by_path( - path, config, - )) -} - -async fn h_get_config_path_info(headers: HeaderMap) -> Response { - let sid = session_id(&headers); - Json(json!(get_config_path_info_impl(&sid))).into_response() -} - -// -- Worktree operations -- - -async fn h_list_worktrees(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let include_archived = get_param_bool(&args, "include_archived", false); - result_json(list_worktrees_impl(&sid, include_archived)) -} - -async fn h_update_worktree_color(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let worktree_name = args["worktree_name"].as_str().unwrap_or("").to_string(); - let color: Option = match args.get("color") { - Some(v) if !v.is_null() => match serde_json::from_value(v.clone()) { - Ok(c) => Some(c), - Err(e) => { - return (StatusCode::BAD_REQUEST, format!("Invalid color: {}", e)).into_response() - } - }, - _ => None, - }; - result_ok(crate::commands::worktree::update_worktree_color_impl( - &sid, - worktree_name, - color, - )) -} - -async fn h_get_main_workspace_status(headers: HeaderMap) -> Response { - let sid = session_id(&headers); - result_json(get_main_workspace_status_impl(&sid)) -} - -async fn h_create_worktree(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let request: CreateWorktreeRequest = match serde_json::from_value(args["request"].clone()) { - Ok(r) => r, - Err(e) => { - return (StatusCode::BAD_REQUEST, format!("Invalid request: {}", e)).into_response() - } - }; - result_json(create_worktree_impl(&sid, request)) -} - -async fn h_archive_worktree(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let name = args["name"].as_str().unwrap_or("").to_string(); - result_ok(archive_worktree_impl(&sid, name)) -} - -async fn h_check_worktree_status(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let name = args["name"].as_str().unwrap_or("").to_string(); - result_json(check_worktree_status_impl(&sid, name)) -} - -async fn h_terminate_worktree_locking_process( - headers: HeaderMap, - Json(args): Json, -) -> Response { - let sid = session_id(&headers); - let name = args["name"].as_str().unwrap_or("").to_string(); - let Some(pid_value) = args["pid"].as_u64() else { - return (StatusCode::BAD_REQUEST, "Invalid pid").into_response(); - }; - if pid_value > u32::MAX as u64 { - return (StatusCode::BAD_REQUEST, "Invalid pid").into_response(); - } - let process_start_time = args - .get("processStartTime") - .or_else(|| args.get("process_start_time")) - .and_then(|value| value.as_str()) - .unwrap_or(""); - if process_start_time.is_empty() { - return (StatusCode::BAD_REQUEST, "Invalid processStartTime").into_response(); - } - result_ok(terminate_worktree_locking_process_impl( - &sid, - name, - pid_value as u32, - process_start_time.to_string(), - )) -} - -async fn h_restore_worktree(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let name = args["name"].as_str().unwrap_or("").to_string(); - result_ok(restore_worktree_impl(&sid, name)) -} - -async fn h_delete_archived_worktree(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let name = args["name"].as_str().unwrap_or("").to_string(); - result_ok(delete_archived_worktree_impl(&sid, name)) -} - -async fn h_add_project_to_worktree(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let request: AddProjectToWorktreeRequest = match serde_json::from_value(args["request"].clone()) - { - Ok(r) => r, - Err(e) => { - return (StatusCode::BAD_REQUEST, format!("Invalid request: {}", e)).into_response() - } - }; - result_ok(add_project_to_worktree_impl(&sid, request)) -} - -async fn h_deploy_to_main(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let worktree_name = get_param(&args, "worktree_name"); - result_json(deploy_to_main_impl(&sid, worktree_name)) -} - -async fn h_exit_main_occupation(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let force = args["force"].as_bool().unwrap_or(false); - result_ok(exit_main_occupation_impl(&sid, force)) -} - -async fn h_get_main_occupation(headers: HeaderMap) -> Response { - let sid = session_id(&headers); - result_json(get_main_occupation_impl(&sid)) -} - -async fn h_clone_project(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let request: CloneProjectRequest = match serde_json::from_value(args["request"].clone()) { - Ok(r) => r, - Err(e) => { - return (StatusCode::BAD_REQUEST, format!("Invalid request: {}", e)).into_response() - } - }; - result_ok(clone_project_impl(&sid, request)) -} - -async fn h_scan_existing_projects(headers: HeaderMap) -> Response { - let sid = session_id(&headers); - result_json(scan_existing_projects_impl(&sid)) -} - -async fn h_add_existing_project(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let name = args["name"].as_str().unwrap_or("").to_string(); - let base_branch = get_param(&args, "base_branch"); - let test_branch = get_param(&args, "test_branch"); - let merge_strategy = get_param(&args, "merge_strategy"); - result_ok(add_existing_project_impl( - &sid, - name, - base_branch, - test_branch, - merge_strategy, - )) -} - -async fn h_import_external_project(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let source_path = normalize_path( - args["sourcePath"] - .as_str() - .or_else(|| args["source_path"].as_str()) - .unwrap_or(""), - ); - result_json(import_external_project_impl(&sid, source_path)) -} - -async fn h_remove_project_from_config(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let name = args["name"].as_str().unwrap_or("").to_string(); - result_ok(remove_project_from_config_impl(&sid, name)) -} - -// -- Git operations -- - -async fn h_switch_branch(Json(args): Json) -> Response { - let request: SwitchBranchRequest = match serde_json::from_value(args["request"].clone()) { - Ok(r) => r, - Err(e) => { - return (StatusCode::BAD_REQUEST, format!("Invalid request: {}", e)).into_response() - } - }; - result_ok(crate::switch_branch_internal(&request)) -} - -async fn h_get_branch_diff_stats(Json(args): Json) -> Response { - let path = args["path"].as_str().unwrap_or("").to_string(); - let base_branch = get_param(&args, "base_branch"); - let test_branch = args - .get("testBranch") - .or_else(|| args.get("test_branch")) - .and_then(|v| v.as_str()) - .map(String::from); - let normalized = normalize_path(&path); - let stats = git_ops::get_branch_diff_stats( - std::path::Path::new(&normalized), - &base_branch, - test_branch.as_deref(), - ); - Json(json!(stats)).into_response() -} - -async fn h_get_changed_files(Json(args): Json) -> Response { - let path = args["path"].as_str().unwrap_or("").to_string(); - let normalized = normalize_path(&path); - result_json(git_ops::get_changed_files(std::path::Path::new( - &normalized, - ))) -} - -async fn h_get_file_diff(Json(args): Json) -> Response { - let path = args["path"].as_str().unwrap_or("").to_string(); - let file_path = get_param(&args, "file_path"); - let normalized = normalize_path(&path); - result_json(git_ops::get_file_diff( - std::path::Path::new(&normalized), - &file_path, - )) -} - -async fn h_check_remote_branch_exists(Json(args): Json) -> Response { - let path = args["path"].as_str().unwrap_or("").to_string(); - let branch_name = get_param(&args, "branch_name"); - let normalized = normalize_path(&path); - result_json(git_ops::check_remote_branch_exists( - std::path::Path::new(&normalized), - &branch_name, - )) -} - -async fn h_fetch_project_remote(Json(args): Json) -> Response { - let path = args["path"].as_str().unwrap_or("").to_string(); - let normalized = normalize_path(&path); - let result = tokio::task::spawn_blocking(move || { - git_ops::fetch_remote(std::path::Path::new(&normalized)) - }) - .await - .map_err(|e| format!("Task join error: {}", e)) - .and_then(|r| r); - result_json(result) -} - -async fn h_sync_with_base_branch(Json(args): Json) -> Response { - let path = args["path"].as_str().unwrap_or("").to_string(); - let base_branch = get_param(&args, "base_branch"); - let normalized = normalize_path(&path); - let result = tokio::task::spawn_blocking(move || { - git_ops::sync_with_base_branch(std::path::Path::new(&normalized), &base_branch) - }) - .await - .map_err(|e| format!("Task join error: {}", e)) - .and_then(|r| r); - result_json(result) -} - -async fn h_sync_all_projects_to_base(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let project_paths: Vec = args - .get("projectPaths") - .or_else(|| args.get("project_paths")) - .and_then(|v| v.as_array()) - .map(|a| { - a.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - let result = tokio::task::spawn_blocking(move || { - crate::commands::git::sync_all_projects_to_base_impl(&sid, project_paths) - }) - .await - .map_err(|e| format!("Task join error: {}", e)) - .and_then(|r| r); - result_json(result) -} - -async fn h_push_to_remote(Json(args): Json) -> Response { - let path = args["path"].as_str().unwrap_or("").to_string(); - let normalized = normalize_path(&path); - let result = tokio::task::spawn_blocking(move || { - git_ops::push_to_remote(std::path::Path::new(&normalized)) - }) - .await - .map_err(|e| format!("Task join error: {}", e)) - .and_then(|r| r); - result_json(result) -} - -async fn h_pull_current_branch(Json(args): Json) -> Response { - let path = args["path"].as_str().unwrap_or("").to_string(); - let normalized = normalize_path(&path); - let result = tokio::task::spawn_blocking(move || { - git_ops::pull_current_branch(std::path::Path::new(&normalized)) - }) - .await - .map_err(|e| format!("Task join error: {}", e)) - .and_then(|r| r); - result_json(result) -} - -async fn h_merge_to_test_branch(Json(args): Json) -> Response { - let path = args["path"].as_str().unwrap_or("").to_string(); - let test_branch = get_param(&args, "test_branch"); - let normalized = normalize_path(&path); - let result = tokio::task::spawn_blocking(move || { - git_ops::merge_to_test_branch(std::path::Path::new(&normalized), &test_branch) - }) - .await - .map_err(|e| format!("Task join error: {}", e)) - .and_then(|r| r); - result_json(result) -} - -async fn h_merge_to_base_branch(Json(args): Json) -> Response { - let path = args["path"].as_str().unwrap_or("").to_string(); - let base_branch = get_param(&args, "base_branch"); - let normalized = normalize_path(&path); - let result = tokio::task::spawn_blocking(move || { - git_ops::merge_to_base_branch(std::path::Path::new(&normalized), &base_branch) - }) - .await - .map_err(|e| format!("Task join error: {}", e)) - .and_then(|r| r); - result_json(result) -} - -async fn h_create_pull_request(Json(args): Json) -> Response { - let path = args["path"].as_str().unwrap_or("").to_string(); - let base_branch = get_param(&args, "base_branch"); - let title = args["title"].as_str().unwrap_or("").to_string(); - let body = args["body"].as_str().unwrap_or("").to_string(); - let normalized = normalize_path(&path); - let result = tokio::task::spawn_blocking(move || { - git_ops::create_pull_request( - std::path::Path::new(&normalized), - &base_branch, - &title, - &body, - ) - }) - .await - .map_err(|e| format!("Task join error: {}", e)) - .and_then(|r| r); - result_json(result) -} - -async fn h_get_remote_branches(Json(args): Json) -> Response { - let path = args["path"].as_str().unwrap_or("").to_string(); - let normalized = normalize_path(&path); - let result = tokio::task::spawn_blocking(move || { - git_ops::get_remote_branches(std::path::Path::new(&normalized)) - }) - .await - .map_err(|e| format!("Task join error: {}", e)) - .and_then(|r| r); - result_json(result) -} - -async fn h_get_git_diff(Json(args): Json) -> Response { - let path = args["path"].as_str().unwrap_or("").to_string(); - result_json(crate::commands::git::get_git_diff(path).await) -} - -async fn h_commit_all(Json(args): Json) -> Response { - let path = args["path"].as_str().unwrap_or("").to_string(); - let message = args["message"].as_str().unwrap_or("").to_string(); - let author_name = args - .get("authorName") - .or_else(|| args.get("author_name")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let author_email = args - .get("authorEmail") - .or_else(|| args.get("author_email")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let skip_hooks = args - .get("skipHooks") - .or_else(|| args.get("skip_hooks")) - .and_then(|v| v.as_bool()); - result_json( - crate::commands::git::commit_all(path, message, author_name, author_email, skip_hooks) - .await, - ) -} - -async fn h_generate_commit_message(Json(args): Json) -> Response { - let diff = args["diff"].as_str().unwrap_or("").to_string(); - result_json(crate::commands::voice::generate_commit_message(diff).await) -} - -async fn h_get_commit_prefix_config() -> Response { - result_json(crate::commands::config::get_commit_prefix_config()) -} - -#[derive(serde::Deserialize)] -struct SetPrefixArgs { - templates: Vec, - enabled: bool, - #[serde(alias = "defaultIndex")] - default_index: usize, -} - -async fn h_set_commit_prefix_config(Json(args): Json) -> Response { - result_ok(crate::commands::config::set_commit_prefix_config( - args.templates, - args.enabled, - args.default_index, - )) -} - -async fn h_get_git_user_global_config() -> Response { - result_json(crate::commands::config::get_git_user_global_config()) -} - -#[derive(serde::Deserialize)] -struct SetGitUserGlobalArgs { - name: Option, - email: Option, -} - -async fn h_set_git_user_global_config(Json(args): Json) -> Response { - result_ok(crate::commands::config::set_git_user_global_config( - args.name, args.email, - )) -} - -async fn h_get_skip_git_hooks() -> Response { - result_json(crate::commands::config::get_skip_git_hooks()) -} - -#[derive(serde::Deserialize)] -struct SetSkipGitHooksArgs { - skip: bool, -} - -async fn h_set_skip_git_hooks(Json(args): Json) -> Response { - result_ok(crate::commands::config::set_skip_git_hooks(args.skip)) -} - -async fn h_get_shell_integration_enabled() -> Response { - result_json(crate::commands::config::get_shell_integration_enabled()) -} - -#[derive(serde::Deserialize)] -struct SetShellIntegrationEnabledArgs { - enabled: bool, -} - -async fn h_set_shell_integration_enabled( - Json(args): Json, -) -> Response { - result_ok(crate::commands::config::set_shell_integration_enabled( - args.enabled, - )) -} - -async fn h_get_git_user_config(Json(args): Json) -> Response { - let path = args["path"].as_str().unwrap_or("").to_string(); - let normalized = normalize_path(&path); - let result = tokio::task::spawn_blocking(move || { - git_ops::get_git_user_config(std::path::Path::new(&normalized)) - }) - .await - .map_err(|e| format!("Task join error: {}", e)) - .and_then(|r| r); - result_json(result) -} - -#[derive(serde::Deserialize)] -struct SetGitUserArgs { - path: String, - name: Option, - email: Option, -} - -async fn h_set_git_user_config(Json(args): Json) -> Response { - let normalized = normalize_path(&args.path); - let name = args.name; - let email = args.email; - let result = tokio::task::spawn_blocking(move || { - git_ops::set_git_user_config( - std::path::Path::new(&normalized), - name.as_deref(), - email.as_deref(), - ) - }) - .await - .map_err(|e| format!("Task join error: {}", e)) - .and_then(|r| r); - result_ok(result) -} - -async fn h_check_dashscope_api_key() -> Response { - Json(json!(crate::commands::voice::check_dashscope_api_key())).into_response() -} - -async fn h_get_commit_ai_api_key() -> Response { - // Return masked key via HTTP to avoid exposing secrets in LAN sharing mode - let result: Result, String> = - crate::commands::voice::get_commit_ai_api_key().await; - match result { - Ok(Some(key)) if !key.is_empty() => { - let masked = mask_api_key_for_response(&key); - result_json(Ok(Some(masked))) - } - Ok(_) => result_json(Ok(None::)), - Err(e) => result_json::>(Err(e)), - } -} - -async fn h_set_commit_ai_api_key(Json(args): Json) -> Response { - let key = args["key"].as_str().unwrap_or("").to_string(); - result_json(crate::commands::voice::set_commit_ai_api_key(key).await) -} - -async fn h_set_commit_ai_enabled(Json(args): Json) -> Response { - let enabled = args["enabled"].as_bool().unwrap_or(true); - result_json(crate::commands::voice::set_commit_ai_enabled(enabled).await) -} - -async fn h_get_commit_ai_enabled() -> Response { - Json(json!(crate::commands::voice::get_commit_ai_enabled().await)).into_response() -} - -async fn h_check_commit_ai_api_key() -> Response { - Json(json!(crate::commands::voice::check_commit_ai_api_key())).into_response() -} - -// -- Scan -- - -async fn h_scan_linked_folders(Json(args): Json) -> Response { - let project_path = normalize_path(&get_param(&args, "project_path")); - result_json(crate::scan_linked_folders_internal(&project_path)) -} - -// -- System utilities -- - -async fn h_open_in_terminal(Json(args): Json) -> Response { - let path = args["path"].as_str().unwrap_or("").to_string(); - let terminal = args["terminal"].as_str().map(|s| s.to_string()); - let shell = args["shell"].as_str().map(|s| s.to_string()); - result_ok(crate::open_in_terminal_internal( - &path, - terminal.as_deref(), - shell.as_deref(), - )) -} - -async fn h_open_in_editor(Json(args): Json) -> Response { - let request: OpenEditorRequest = match serde_json::from_value(args["request"].clone()) { - Ok(r) => r, - Err(e) => { - return (StatusCode::BAD_REQUEST, format!("Invalid request: {}", e)).into_response() - } - }; - let custom_path = get_param_opt(&args, "custom_path"); - result_ok(crate::open_in_editor_internal( - &request, - custom_path.as_deref(), - )) -} - -async fn h_detect_tools() -> Response { - result_json(Ok(crate::detect_tools_internal().await)) -} - -async fn h_get_crash_report() -> Response { - Json(json!(crate::commands::system::get_crash_report())).into_response() -} - -async fn h_frontend_log(Json(args): Json) -> Response { - let level = get_param(&args, "level"); - let message = get_param(&args, "message"); - crate::commands::system::frontend_log(level, message).await; - result_void_ok() -} - -async fn h_set_git_path(Json(args): Json) -> Response { - let path = args["path"].as_str().unwrap_or("").to_string(); - crate::set_git_path_internal(&path); - result_void_ok() -} - -async fn h_reveal_in_finder(Json(args): Json) -> Response { - let path = args["path"].as_str().unwrap_or("").to_string(); - result_ok(crate::reveal_in_finder_internal(&path)) -} - -async fn h_open_log_dir() -> Response { - result_ok(crate::open_log_dir_internal()) -} - -async fn h_get_app_icon(Json(args): Json) -> Response { - let path = args["path"].as_str().unwrap_or("").to_string(); - Json(json!(crate::get_app_icon_internal(&path))).into_response() -} - -// -- Multi-window management -- - -async fn h_get_opened_workspaces() -> Response { - match crate::WINDOW_WORKSPACES.lock() { - Ok(map) => { - let values: Vec = map.values().cloned().collect(); - Json(json!(values)).into_response() - } - Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal state error").into_response(), - } -} - -async fn h_unregister_window(headers: HeaderMap) -> Response { - let sid = session_id(&headers); - unregister_window_impl(&sid); - result_void_ok() -} - -async fn h_lock_worktree(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let ws_path = get_param(&args, "workspace_path"); - let wt_name = get_param(&args, "worktree_name"); - result_ok(lock_worktree_impl(&sid, ws_path, wt_name)) -} - -async fn h_unlock_worktree(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let ws_path = get_param(&args, "workspace_path"); - let wt_name = get_param(&args, "worktree_name"); - unlock_worktree_impl(&sid, ws_path, wt_name); - result_void_ok() -} - -async fn h_get_locked_worktrees(Json(args): Json) -> Response { - let ws_path = get_param(&args, "workspace_path"); - match crate::WORKTREE_LOCKS.lock() { - Ok(locks) => { - let result: HashMap = locks - .iter() - .filter(|((wp, _), _)| *wp == ws_path) - .map(|((_, wt), label)| (wt.clone(), label.clone())) - .collect(); - Json(json!(result)).into_response() - } - Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal state error").into_response(), - } -} - -async fn h_broadcast_terminal_state(Json(args): Json) -> Response { - let app = match current_app_handle() { - Ok(app) => app, - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), - }; - - let workspace_path = get_param(&args, "workspace_path"); - let worktree_name = get_param(&args, "worktree_name"); - let activated_terminals = args - .get("activatedTerminals") - .or_else(|| args.get("activated_terminals")) - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default() - .into_iter() - .filter_map(|value| value.as_str().map(ToOwned::to_owned)) - .collect(); - let active_terminal_tab = args - .get("activeTerminalTab") - .or_else(|| args.get("active_terminal_tab")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let terminal_visible = args - .get("terminalVisible") - .or_else(|| args.get("terminal_visible")) - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let client_id = args - .get("clientId") - .or_else(|| args.get("client_id")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let session_id = args - .get("sessionId") - .or_else(|| args.get("session_id")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - crate::commands::window::broadcast_terminal_state( - app, - workspace_path, - worktree_name, - activated_terminals, - active_terminal_tab, - terminal_visible, - client_id, - session_id, - ); - result_void_ok() -} - -// -- PTY -- - -/// Run a closure that requires the PTY_MANAGER lock on a blocking thread. -async fn with_pty_manager(f: F) -> Result -where - T: Send + 'static, - F: FnOnce(&mut crate::pty_manager::PtyManager) -> Result + Send + 'static, -{ - tokio::task::spawn_blocking(move || { - let mut manager = PTY_MANAGER - .lock() - .map_err(|e| format!("Lock error: {}", e))?; - f(&mut manager) - }) - .await - .unwrap_or_else(|e| Err(format!("Task error: {}", e))) -} - -async fn h_pty_create(Json(args): Json) -> Response { - let session_id = get_param(&args, "session_id"); - let cwd = normalize_path(&get_param(&args, "cwd")); - let cols = get_param_u64(&args, "cols", 80) as u16; - let rows = get_param_u64(&args, "rows", 24) as u16; - let shell = get_param_opt(&args, "shell"); - - result_ok( - with_pty_manager(move |m| { - let requested_shell = crate::pty_manager::requested_shell_path(shell.as_deref()); - if let Some(existing_shell) = m.session_shell_path(&session_id) { - if existing_shell == requested_shell { - log::info!( - "[pty] Session already exists (HTTP), skipping create: id={}, requested cols={}, rows={}, shell={}", - session_id, - cols, - rows, - requested_shell - ); - return Ok(()); - } - - log::info!( - "[pty] Session exists with different shell (HTTP), recreating: id={}, existing_shell={}, requested_shell={}", - session_id, - existing_shell, - requested_shell - ); - m.close_session(&session_id, "h_pty_create: shell changed (HTTP)")?; - } - - m.create_session(&session_id, &cwd, cols, rows, shell.as_deref()) - }) - .await, - ) -} - -async fn h_pty_write(Json(args): Json) -> Response { - let session_id = get_param(&args, "session_id"); - let data = get_param(&args, "data"); - result_ok(with_pty_manager(move |m| m.write_to_session(&session_id, &data)).await) -} - -async fn h_pty_read(Json(args): Json) -> Response { - let session_id = get_param(&args, "session_id"); - let client_id = get_param_opt(&args, "client_id"); - result_json( - with_pty_manager(move |m| m.read_from_session(&session_id, client_id.as_deref())).await, - ) -} - -async fn h_pty_resize(Json(args): Json) -> Response { - let session_id = get_param(&args, "session_id"); - let cols = get_param_u64(&args, "cols", 80) as u16; - let rows = get_param_u64(&args, "rows", 24) as u16; - let _request_client_id = get_param_opt(&args, "client_id"); - - log::info!( - "[http] pty_resize: session={} size={}x{}", - session_id, - cols, - rows - ); - result_ok(with_pty_manager(move |m| m.resize_session(&session_id, cols, rows)).await) -} - -async fn h_pty_close(Json(args): Json) -> Response { - let session_id = get_param(&args, "session_id"); - result_ok( - with_pty_manager(move |m| m.close_session(&session_id, "h_pty_close: HTTP request")).await, - ) -} - -async fn h_pty_exists(Json(args): Json) -> Response { - let session_id = get_param(&args, "session_id"); - result_json(with_pty_manager(move |m| Ok(m.has_session(&session_id))).await) -} - -async fn h_pty_close_by_path(Json(args): Json) -> Response { - let path_prefix = normalize_path(&get_param(&args, "path_prefix")); - result_json( - with_pty_manager(move |m| { - Ok(m.close_sessions_by_path_prefix(&path_prefix, "h_pty_close_by_path: HTTP request")) - }) - .await, - ) -} - -// -- Auth -- - -async fn h_auth_challenge(ConnectInfo(addr): ConnectInfo) -> Response { - let client_ip = addr.ip().to_string(); - log::info!("[auth] Challenge requested from IP: {}", client_ip); - - // Rate limiting: max 5 attempts per 60 seconds per IP - let rate_ok = AUTH_RATE_LIMITER - .lock() - .map(|mut limiter| limiter.check_and_record(&client_ip)) - .unwrap_or(false); - if !rate_ok { - log::warn!( - "[auth] Rate limited: IP {} exceeded 5 attempts/60s", - client_ip - ); - return (StatusCode::TOO_MANY_REQUESTS, "请求过于频繁,请稍后再试").into_response(); - } - - // Get salt - let salt = SHARE_STATE - .lock() - .ok() - .and_then(|state| state.auth_salt.clone()) - .unwrap_or_default(); - - if salt.is_empty() { - log::error!("[auth] No password configured for challenge"); - return (StatusCode::INTERNAL_SERVER_ERROR, "No password configured").into_response(); - } - - // Generate nonce - let nonce_hex = match NONCE_CACHE.lock() { - Ok(mut cache) => match cache.generate() { - Ok(n) => { - log::info!("[auth] Nonce generated successfully for IP: {}", client_ip); - n - } - Err(e) => { - log::error!("[auth] Failed to generate nonce: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(); - } - }, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response(), - }; - - Json(json!({ - "nonce": nonce_hex, - "salt": hex::encode(&salt), - })) - .into_response() -} - -#[derive(serde::Deserialize)] -struct VerifyRequest { - proof: String, // hex-encoded HMAC - nonce: String, // hex-encoded nonce -} - -async fn h_auth_verify( - ConnectInfo(addr): ConnectInfo, - headers: HeaderMap, - Json(req): Json, -) -> Response { - let client_ip = addr.ip().to_string(); - log::info!("[auth] Verification attempt from IP: {}", client_ip); - - // Consume nonce (one-time use) - let nonce_bytes = match NONCE_CACHE.lock() { - Ok(mut cache) => match cache.consume(&req.nonce) { - Some(n) => n, - None => { - log::warn!("[auth] Invalid or expired nonce from IP: {}", client_ip); - return (StatusCode::UNAUTHORIZED, "Invalid or expired nonce").into_response(); - } - }, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response(), - }; - - // Get auth key - let auth_key = SHARE_STATE - .lock() - .ok() - .and_then(|state| state.auth_key.clone()) - .unwrap_or_default(); - - if auth_key.is_empty() { - log::error!("[auth] No password configured for verification"); - return (StatusCode::INTERNAL_SERVER_ERROR, "No password configured").into_response(); - } - - // Compute expected HMAC - use ring::hmac; - let key = hmac::Key::new(hmac::HMAC_SHA256, &auth_key); - let expected_tag = hmac::sign(&key, &nonce_bytes); - let expected_hex = hex::encode(expected_tag.as_ref()); - - // Constant-time comparison - let proof_match = req.proof.len() == expected_hex.len() - && req - .proof - .as_bytes() - .iter() - .zip(expected_hex.as_bytes()) - .fold(0u8, |acc, (a, b)| acc | (a ^ b)) - == 0; - - if !proof_match { - log::warn!("[auth] Verification failed from IP: {}", client_ip); - return (StatusCode::UNAUTHORIZED, "密码错误").into_response(); - } - - // Generate session ID (same logic as before) - let sid = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); - let user_agent = headers - .get("user-agent") - .and_then(|v| v.to_str().ok()) - .unwrap_or("unknown") - .to_string(); - - let client_ip = addr.ip().to_string(); - let client = ConnectedClient { - session_id: sid.clone(), - ip: client_ip.clone(), - user_agent, - authenticated_at: now.clone(), - last_active: now, - ws_connected: false, - }; - - // Remove old sessions from the same IP that don't have an active WebSocket - let stale_sids: Vec = if let Ok(mut clients) = CONNECTED_CLIENTS.lock() { - let stale: Vec = clients - .iter() - .filter(|(_, c)| c.ip == client_ip && !c.ws_connected) - .map(|(s, _)| s.clone()) - .collect(); - for s in &stale { - clients.remove(s); - } - clients.insert(sid.clone(), client); - stale - } else { - vec![] - }; - - if let Ok(mut sessions) = AUTHENTICATED_SESSIONS.lock() { - for s in &stale_sids { - sessions.remove(s); - } - sessions.insert(sid.clone()); - } - - log::info!( - "[auth] Verification successful for session: {}, IP: {}", - sid, - client_ip - ); - Json(json!({ "sessionId": sid })).into_response() -} - -// -- ngrok token -- - -async fn h_get_ngrok_token() -> Response { - let config = crate::load_global_config(); - Json(json!(config.ngrok_token)).into_response() -} - -async fn h_set_ngrok_token(Json(args): Json) -> Response { - let token = args["token"].as_str().unwrap_or("").to_string(); - let mut config = crate::load_global_config(); - config.ngrok_token = if token.is_empty() { None } else { Some(token) }; - match crate::save_global_config_internal(&config) { - Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => (StatusCode::BAD_REQUEST, e).into_response(), - } -} - -async fn h_start_ngrok_tunnel() -> Response { - match crate::start_ngrok_tunnel_internal().await { - Ok(url) => Json(json!(url)).into_response(), - Err(e) => (StatusCode::BAD_REQUEST, e).into_response(), - } -} - -async fn h_stop_ngrok_tunnel() -> Response { - match SHARE_STATE.lock() { - Ok(mut state) => { - if let Some(handle) = state.ngrok_task.take() { - handle.abort(); - } - state.ngrok_url = None; - StatusCode::NO_CONTENT.into_response() - } - Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal state error").into_response(), - } -} - -// -- Share info -- - -async fn h_get_share_info() -> Response { - let null_response = || { - Json(json!({ - "workspace_name": null, - "workspace_path": null, - "current_worktree": null, - })) - .into_response() - }; - - // Extract share state data, then drop the lock before acquiring WORKTREE_LOCKS - let (ws_name, ws_path) = { - let share_state = match SHARE_STATE.lock() { - Ok(s) if s.active => s, - _ => return null_response(), - }; - match share_state.workspace_path { - Some(ref path) => { - let config = load_workspace_config(path); - (Some(config.name), Some(path.clone())) - } - None => (None, None), - } - }; - - // Get current locked worktree (the one the desktop user is viewing) - let current_worktree = if let Some(ref ws_path) = ws_path { - if let Ok(locks) = crate::WORKTREE_LOCKS.lock() { - // Find the first locked worktree for this workspace - locks - .iter() - .find(|((wp, _), _)| wp == ws_path) - .map(|((_, wt), _)| wt.clone()) - } else { - None - } - } else { - None - }; - - Json(json!({ - "workspace_name": ws_name, - "workspace_path": ws_path.map(|p| normalize_path(&p)), - "current_worktree": current_worktree, - })) - .into_response() -} - -async fn h_start_sharing(headers: HeaderMap, Json(args): Json) -> Response { - let sid = session_id(&headers); - let workspace_path = match crate::config::get_window_workspace_path(&sid) { - Some(path) => path, - None => return (StatusCode::BAD_REQUEST, "No workspace selected").into_response(), - }; - let port = args["port"].as_u64().unwrap_or(0) as u16; - let password = args["password"].as_str().unwrap_or("").to_string(); - match crate::commands::sharing::start_sharing_internal(workspace_path, port, password).await { - Ok(url) => Json(json!(url)).into_response(), - Err(e) => (StatusCode::BAD_REQUEST, e).into_response(), - } -} - -async fn h_stop_sharing() -> Response { - result_ok(crate::commands::sharing::stop_sharing_internal()) -} - -async fn h_get_share_state() -> Response { - result_json(crate::commands::sharing::get_share_state().await) -} - -async fn h_update_share_password(Json(args): Json) -> Response { - let password = args["password"].as_str().unwrap_or("").to_string(); - result_ok(crate::commands::sharing::update_share_password(password).await) -} - -async fn h_get_last_share_port() -> Response { - result_json(crate::commands::sharing::get_last_share_port().await) -} - -async fn h_get_last_share_password() -> Response { - result_json(crate::commands::sharing::get_last_share_password().await) -} - -// -- Misc -- - -async fn h_get_terminal_state(Json(args): Json) -> Response { - let ws_path = normalize_path(&get_param(&args, "workspace_path")); - let wt_name = get_param(&args, "worktree_name"); - let state = crate::commands::window::get_terminal_state_inner(ws_path, wt_name); - Json(json!(state)).into_response() -} - -async fn h_open_workspace_window(Json(args): Json) -> Response { - // In browser mode, "open new window" just opens a new browser tab - let ws_path = normalize_path(&get_param(&args, "workspace_path")); - // Return a URL that the frontend can use to open a new tab - let url = format!("/?workspace={}", urlencoding::encode(&ws_path)); - Json(json!(url)).into_response() -} - -async fn h_get_app_version() -> Response { - Json(json!(env!("CARGO_PKG_VERSION"))).into_response() -} - -async fn h_check_mirror_update(Json(payload): Json) -> Response { - let mirror_url = payload["mirrorUrl"] - .as_str() - .unwrap_or("https://gh-proxy.org/") - .to_string(); - result_json(crate::commands::system::check_mirror_update(mirror_url).await) -} - -async fn h_download_update_via_mirror(Json(payload): Json) -> Response { - let app = match current_app_handle() { - Ok(app) => app, - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), - }; - let mirror_url = payload["mirrorUrl"] - .as_str() - .unwrap_or("https://gh-proxy.org/") - .to_string(); - result_ok(crate::commands::system::download_update_via_mirror(app, mirror_url).await) -} - -async fn h_test_mirror_speed() -> Response { - result_json(crate::commands::system::test_mirror_speed().await) -} - -async fn h_speed_test_single_mirror(Json(payload): Json) -> Response { - let mirror_url = payload["mirrorUrl"].as_str().unwrap_or("").to_string(); - result_json(crate::commands::system::speed_test_single_mirror(mirror_url).await) -} - -async fn h_get_mirror_sources() -> Response { - result_json(Ok(crate::commands::system::get_mirror_sources())) -} - -async fn h_save_custom_mirrors(Json(payload): Json) -> Response { - let mirrors: Vec = - match serde_json::from_value(payload["mirrors"].clone()) { - Ok(m) => m, - Err(e) => { - return (StatusCode::BAD_REQUEST, format!("Invalid mirrors: {}", e)).into_response() - } - }; - result_ok(crate::commands::system::save_custom_mirrors(mirrors)) -} - -async fn h_open_devtools() -> Response { - let app = match current_app_handle() { - Ok(app) => app, - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), - }; - let window = match app.get_webview_window("main") { - Some(window) => window, - None => return (StatusCode::BAD_REQUEST, "Main window not found").into_response(), - }; - crate::commands::window::open_devtools(window); - result_void_ok() -} - -// --------------------------------------------------------------------------- -// Vault Handlers -// --------------------------------------------------------------------------- - -pub async fn h_vault_status(headers: HeaderMap) -> axum::response::Response { - let sid = session_id(&headers); - result_json(crate::commands::vault::vault_status_impl(&sid)) -} - -pub async fn h_vault_link( - headers: HeaderMap, - axum::extract::Json(args): axum::extract::Json, -) -> axum::response::Response { - let sid = session_id(&headers); - let path = args.get("path").and_then(|v| { - if v.is_null() { - None - } else { - v.as_str().map(|s| s.to_string()) - } - }); - let keep_symlinks = args - .get("keepSymlinks") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - result_json(crate::commands::vault::vault_link_impl( - &sid, - path, - keep_symlinks, - )) -} - -pub async fn h_list_vault_item_children( - axum::extract::Json(args): axum::extract::Json, -) -> axum::response::Response { - let vault_path = args - .get("vaultPath") - .and_then(|v| v.as_str().map(|s| s.to_string())) - .unwrap_or_default(); - let relative_path = args - .get("relativePath") - .and_then(|v| v.as_str().map(|s| s.to_string())) - .unwrap_or_default(); - result_json(crate::commands::vault::list_vault_item_children( - vault_path, - relative_path, - )) -} - -// --------------------------------------------------------------------------- -// WebSocket -// --------------------------------------------------------------------------- - -#[derive(Deserialize)] -struct WsParams { - session_id: Option, -} - -async fn h_ws_upgrade( - ws: WebSocketUpgrade, - headers: HeaderMap, - ConnectInfo(addr): ConnectInfo, - Query(params): Query, -) -> Response { - if let Some(origin) = headers - .get(header::ORIGIN) - .and_then(|value| value.to_str().ok()) - { - if !is_allowed_origin(origin) { - return (StatusCode::FORBIDDEN, "Origin not allowed").into_response(); - } - } - - // Authenticate via query param - let sid = match params.session_id { - Some(s) => s, - None => return (StatusCode::UNAUTHORIZED, "Missing session_id").into_response(), - }; - - let needs_auth = SHARE_STATE - .lock() - .map(|state| state.active && state.auth_key.is_some()) - .unwrap_or(false); - - if needs_auth { - let is_authenticated = AUTHENTICATED_SESSIONS - .lock() - .map(|sessions| sessions.contains(&sid)) - .unwrap_or(false); - if !is_authenticated { - return (StatusCode::UNAUTHORIZED, "Not authenticated").into_response(); - } - } - - // Mark WebSocket connected - if let Ok(mut clients) = CONNECTED_CLIENTS.lock() { - if let Some(client) = clients.get_mut(&sid) { - client.ws_connected = true; - client.last_active = chrono::Utc::now().to_rfc3339(); - } - } - log::info!("WebSocket upgrade for session {} from {}", sid, addr.ip()); - - ws.on_upgrade(move |socket| handle_ws(socket, sid)) -} - -// TODO(security): Consider per-session rate limiting for WebSocket messages -// to prevent a single client from flooding the server with pty_write commands. -async fn handle_ws(socket: WebSocket, session_id: String) { - let (ws_sender, mut ws_receiver) = socket.split(); - let ws_sender = Arc::new(TokioMutex::new(ws_sender)); - - // Auto-bind session to the shared workspace - if let Ok(share_state) = SHARE_STATE.lock() { - if let Some(ref ws_path) = share_state.workspace_path { - if share_state.active { - let _ = set_window_workspace_impl(&session_id, ws_path.clone()); - } - } - } - - // Track spawned forwarder tasks so we can abort them on disconnect - let mut pty_forwarders: HashMap> = HashMap::new(); - let mut lock_forwarder: Option> = None; - let mut terminal_state_forwarder: Option> = None; - let mut voice_forwarder: Option> = None; - - // Always-on: subscribe to per-client notifications (kick events, etc.) - let notification_forwarder: tokio::task::JoinHandle<()> = { - let mut rx = crate::state::CLIENT_NOTIFICATION_BROADCAST.subscribe(); - let sender = Arc::clone(&ws_sender); - let sid = session_id.clone(); - tokio::spawn(async move { - loop { - match rx.recv().await { - Ok(json_str) => { - if let Ok(val) = serde_json::from_str::(&json_str) { - // Only forward notifications targeted at this session - if val["session_id"].as_str() == Some(&sid) { - let msg_type = val["type"].as_str().unwrap_or(""); - let reason = val["reason"].as_str().unwrap_or("").to_string(); - let msg = json!({ - "type": msg_type, - "reason": reason, - }); - let mut sender = sender.lock().await; - let _ = sender.send(Message::text(msg.to_string())).await; - // After sending kick notification, close the connection - if msg_type == "kicked" { - let _ = sender.close().await; - break; - } - } - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - } - } - }) - }; - - // Process incoming messages - while let Some(msg) = ws_receiver.next().await { - let msg = match msg { - Ok(m) => m, - Err(_) => break, - }; - - let text = match msg { - Message::Text(t) => t, - Message::Close(_) => break, - _ => continue, - }; - - let parsed: Value = match serde_json::from_str(&text) { - Ok(v) => v, - Err(_) => continue, - }; - - let msg_type = parsed["type"].as_str().unwrap_or(""); - - match msg_type { - "pty_subscribe" => { - let pty_session_id = match parsed["sessionId"].as_str() { - Some(s) => s.to_string(), - None => continue, - }; - - // Abort existing forwarder for this session if any - if let Some(handle) = pty_forwarders.remove(&pty_session_id) { - handle.abort(); - } - - // Get replay buffer + broadcast receiver from PTY manager - let subscription = { - let manager = match PTY_MANAGER.lock() { - Ok(m) => m, - Err(_) => continue, - }; - manager.subscribe_session(&pty_session_id) - }; - - if let Some((replay, mut rx)) = subscription { - log::info!( - "PTY subscribe '{}': replay buffer {} bytes", - pty_session_id, - replay.len() - ); - let sender = Arc::clone(&ws_sender); - let sid = pty_session_id.clone(); - let handle = tokio::spawn(async move { - // Pending buffer for incomplete UTF-8 sequences across chunk boundaries - let mut utf8_pending: Vec = Vec::new(); - - // Send replay buffer first so new subscribers see existing content - if !replay.is_empty() { - let (text, pending) = bytes_to_utf8_with_pending(&replay); - utf8_pending = pending; - if !text.is_empty() { - let msg = json!({ - "type": "pty_output", - "sessionId": sid, - "data": text, - }); - let mut s = sender.lock().await; - if s.send(Message::text(msg.to_string())).await.is_err() { - return; - } - } - } - - // Forward real-time output - loop { - match rx.recv().await { - Ok(data) => { - // Prepend any leftover bytes from the previous chunk - let combined = if utf8_pending.is_empty() { - data - } else { - let mut buf = std::mem::take(&mut utf8_pending); - buf.extend(data); - buf - }; - let (text, pending) = bytes_to_utf8_with_pending(&combined); - utf8_pending = pending; - if !text.is_empty() { - let msg = json!({ - "type": "pty_output", - "sessionId": sid, - "data": text, - }); - let mut sender = sender.lock().await; - if sender - .send(Message::text(msg.to_string())) - .await - .is_err() - { - break; - } - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { - log::warn!("PTY output broadcast lagged, skipped {} messages for session {}", - skipped, sid); - // Clear pending buffer on lag — skipped messages may have - // contained the continuation bytes we were waiting for. - utf8_pending.clear(); - continue; - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - break; - } - } - } - }); - pty_forwarders.insert(pty_session_id, handle); - } else { - log::warn!( - "PTY subscribe '{}': session not found in PTY manager", - pty_session_id - ); - } - } - - "pty_unsubscribe" => { - if let Some(sid) = parsed["sessionId"].as_str() { - if let Some(handle) = pty_forwarders.remove(sid) { - handle.abort(); - } - } - } - - "pty_write" => { - let pty_session_id = match parsed["sessionId"].as_str() { - Some(s) => s.to_string(), - None => continue, - }; - let data = match parsed["data"].as_str() { - Some(d) => d.to_string(), - None => continue, - }; - let _ = tokio::task::spawn_blocking(move || { - PTY_MANAGER - .lock() - .map_err(|e| format!("Lock error: {}", e)) - .and_then(|m| m.write_to_session(&pty_session_id, &data)) - }) - .await; - } - - "pty_resize" => { - let pty_session_id = match parsed["sessionId"].as_str() { - Some(s) => s.to_string(), - None => continue, - }; - let cols = parsed["cols"].as_u64().unwrap_or(80) as u16; - let rows = parsed["rows"].as_u64().unwrap_or(24) as u16; - let _request_client_id = parsed["clientId"].as_str().map(|s| s.to_string()); - - log::info!( - "[ws] pty_resize: session={} size={}x{}", - pty_session_id, - cols, - rows - ); - let _ = tokio::task::spawn_blocking(move || { - PTY_MANAGER - .lock() - .map_err(|e| format!("Lock error: {}", e)) - .and_then(|m| m.resize_session(&pty_session_id, cols, rows)) - }) - .await; - } - - "subscribe_locks" => { - let workspace_path = match parsed["workspacePath"].as_str() { - Some(s) => s.to_string(), - None => continue, - }; - - // Abort existing lock forwarder if any - if let Some(handle) = lock_forwarder.take() { - handle.abort(); - } - - // Send initial lock state - // Scope the std::sync::MutexGuard so it drops before any .await - let initial_lock_msg = if let Ok(locks) = crate::WORKTREE_LOCKS.lock() { - let lock_snapshot: HashMap = locks - .iter() - .filter(|((wp, _), _)| *wp == workspace_path) - .map(|((_, wt), label)| (wt.clone(), label.clone())) - .collect(); - Some( - json!({ - "type": "lock_update", - "locks": lock_snapshot, - }) - .to_string(), - ) - } else { - None - }; - if let Some(msg_str) = initial_lock_msg { - let mut sender = ws_sender.lock().await; - let _ = sender.send(Message::text(msg_str)).await; - } - - // Subscribe to lock broadcast - let mut rx = LOCK_BROADCAST.subscribe(); - let sender = Arc::clone(&ws_sender); - let ws_path = workspace_path.clone(); - let handle = tokio::spawn(async move { - loop { - match rx.recv().await { - Ok(json_str) => { - // Parse the broadcast to check if it's for our workspace - if let Ok(val) = serde_json::from_str::(&json_str) { - if val["workspacePath"].as_str() == Some(&ws_path) { - let locks = &val["locks"]; - let msg = json!({ - "type": "lock_update", - "locks": locks, - }); - let mut sender = sender.lock().await; - if sender - .send(Message::text(msg.to_string())) - .await - .is_err() - { - break; - } - } - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - } - } - }); - lock_forwarder = Some(handle); - } - - "subscribe_terminal_state" => { - let workspace_path = match parsed["workspacePath"].as_str() { - Some(s) => s.to_string(), - None => continue, - }; - let worktree_name = match parsed["worktreeName"].as_str() { - Some(s) => s.to_string(), - None => continue, - }; - - // Abort existing terminal state forwarder if any - if let Some(handle) = terminal_state_forwarder.take() { - handle.abort(); - } - - // Send initial terminal state from cache - let initial_state = crate::TERMINAL_STATES.lock().ok().and_then(|states| { - let key = (workspace_path.clone(), worktree_name.clone()); - states.get(&key).cloned() - }); - - if let Some(state) = initial_state { - let msg = json!({ - "type": "terminal_state_update", - "workspacePath": &workspace_path, - "worktreeName": &worktree_name, - "activatedTerminals": state.activated_terminals, - "activeTerminalTab": state.active_terminal_tab, - "terminalVisible": state.terminal_visible, - "clientId": state.client_id, - }); - let mut sender = ws_sender.lock().await; - let _ = sender.send(Message::text(msg.to_string())).await; - } - - // Subscribe to terminal state broadcast - let mut rx = TERMINAL_STATE_BROADCAST.subscribe(); - let sender = Arc::clone(&ws_sender); - let ws_path = workspace_path.clone(); - let wt_name = worktree_name.clone(); - let handle = tokio::spawn(async move { - loop { - match rx.recv().await { - Ok(json_str) => { - // Parse the broadcast to check if it's for our workspace/worktree - if let Ok(val) = serde_json::from_str::(&json_str) { - if val["workspacePath"].as_str() == Some(&ws_path) - && val["worktreeName"].as_str() == Some(&wt_name) - { - let msg = json!({ - "type": "terminal_state_update", - "workspacePath": &ws_path, - "worktreeName": &wt_name, - "activatedTerminals": val["activatedTerminals"], - "activeTerminalTab": val["activeTerminalTab"], - "terminalVisible": val["terminalVisible"], - "clientId": val["clientId"], - }); - let mut sender = sender.lock().await; - if sender - .send(Message::text(msg.to_string())) - .await - .is_err() - { - break; - } - } - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { - // Log lagged receiver warning - client is too slow - log::warn!("Terminal state broadcast lagged, skipped {} messages for {}/{}", - skipped, ws_path, wt_name); - continue; - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - } - } - }); - terminal_state_forwarder = Some(handle); - } - - "broadcast_terminal_state" => { - let workspace_path = match parsed["workspacePath"].as_str() { - Some(s) => s.to_string(), - None => continue, - }; - let worktree_name = match parsed["worktreeName"].as_str() { - Some(s) => s.to_string(), - None => continue, - }; - let activated_terminals = parsed["activatedTerminals"] - .as_array() - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect::>() - }) - .unwrap_or_default(); - let active_terminal_tab = - parsed["activeTerminalTab"].as_str().map(|s| s.to_string()); - let terminal_visible = parsed["terminalVisible"].as_bool().unwrap_or(false); - let client_id = parsed["clientId"].as_str().map(|s| s.to_string()); - let session_id = parsed["sessionId"].as_str().map(|s| s.to_string()); - - // Update cache with client_id and session_id - if let Ok(mut states) = crate::TERMINAL_STATES.lock() { - let key = (workspace_path.clone(), worktree_name.clone()); - states.insert( - key, - crate::TerminalState { - activated_terminals: activated_terminals.clone(), - active_terminal_tab: active_terminal_tab.clone(), - terminal_visible, - client_id: client_id.clone(), - session_id: session_id.clone(), - }, - ); - } - - // Broadcast to all connected clients with clientId - let broadcast_msg = json!({ - "workspacePath": workspace_path, - "worktreeName": worktree_name, - "activatedTerminals": activated_terminals, - "activeTerminalTab": active_terminal_tab, - "terminalVisible": terminal_visible, - "clientId": client_id, - "sessionId": session_id, - }) - .to_string(); - let _ = TERMINAL_STATE_BROADCAST.send(broadcast_msg); - - // Also emit Tauri event for PC端 to receive Web端 changes - if let Some(app_handle) = crate::APP_HANDLE - .lock() - .ok() - .and_then(|h| h.as_ref().cloned()) - { - let _ = app_handle.emit( - "terminal-state-update", - json!({ - "workspacePath": workspace_path, - "worktreeName": worktree_name, - "activatedTerminals": activated_terminals, - "activeTerminalTab": active_terminal_tab, - "terminalVisible": terminal_visible, - "clientId": client_id, - "sessionId": session_id, - }), - ); - } - } - - "subscribe_voice_events" => { - // Abort existing voice forwarder if any - if let Some(handle) = voice_forwarder.take() { - handle.abort(); - } - - let mut rx = crate::state::VOICE_BROADCAST.subscribe(); - let sender = Arc::clone(&ws_sender); - let handle = tokio::spawn(async move { - loop { - match rx.recv().await { - Ok(json_str) => { - if let Ok(val) = serde_json::from_str::(&json_str) { - let event = val["event"].as_str().unwrap_or(""); - let payload = &val["payload"]; - let msg = json!({ - "type": "voice_event", - "event": event, - "payload": payload, - }); - let mut sender = sender.lock().await; - if sender.send(Message::text(msg.to_string())).await.is_err() { - break; - } - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - } - } - }); - voice_forwarder = Some(handle); - } - - _ => {} - } - } - - // Cleanup: abort all forwarder tasks on disconnect - for (_, handle) in pty_forwarders { - handle.abort(); - } - if let Some(handle) = lock_forwarder { - handle.abort(); - } - if let Some(handle) = terminal_state_forwarder { - handle.abort(); - } - if let Some(handle) = voice_forwarder { - handle.abort(); - } - notification_forwarder.abort(); - - // Mark WebSocket disconnected - if let Ok(mut clients) = CONNECTED_CLIENTS.lock() { - if let Some(client) = clients.get_mut(&session_id) { - client.ws_connected = false; - } - } - log::info!("WebSocket disconnected for session {}", session_id); -} - -// -- Voice -- - -async fn h_voice_start(Json(args): Json) -> Response { - let sample_rate = args - .get("sampleRate") - .or_else(|| args.get("sample_rate")) - .and_then(|v| v.as_u64()) - .map(|v| v as u32); - result_ok(crate::commands::voice::voice_start_inner(sample_rate).await) -} - -async fn h_voice_send_audio(Json(args): Json) -> Response { - let data = args["data"].as_str().unwrap_or("").to_string(); - result_ok(crate::commands::voice::voice_send_audio_inner(data)) -} - -async fn h_voice_stop() -> Response { - result_ok(crate::commands::voice::voice_stop_inner()) -} - -async fn h_voice_is_active() -> Response { - result_json(crate::commands::voice::voice_is_active_inner()) -} - -async fn h_voice_refine_text(Json(args): Json) -> Response { - let text = args["text"].as_str().unwrap_or("").to_string(); - result_json(crate::commands::voice::voice_refine_text_inner(text).await) -} - -async fn h_get_dashscope_api_key() -> Response { - // Return masked key via HTTP to avoid exposing secrets in LAN sharing mode - let result: Result, String> = - crate::commands::voice::get_dashscope_api_key_inner(); - match result { - Ok(Some(key)) if !key.is_empty() => { - let masked = mask_api_key_for_response(&key); - result_json(Ok(Some(masked))) - } - Ok(_) => result_json(Ok(None::)), - Err(e) => result_json::>(Err(e)), - } -} - -async fn h_set_dashscope_api_key(Json(args): Json) -> Response { - let key = args["key"].as_str().unwrap_or("").to_string(); - result_ok(crate::commands::voice::set_dashscope_api_key_inner(key)) -} - -async fn h_get_dashscope_base_url() -> Response { - result_json(crate::commands::voice::get_dashscope_base_url_inner()) -} - -async fn h_set_dashscope_base_url(Json(args): Json) -> Response { - let url = args["url"].as_str().unwrap_or("").to_string(); - result_ok(crate::commands::voice::set_dashscope_base_url_inner(url)) -} - -async fn h_get_voice_refine_enabled() -> Response { - result_json(crate::commands::voice::get_voice_refine_enabled_inner()) -} - -async fn h_set_voice_refine_enabled(Json(args): Json) -> Response { - let enabled = args["enabled"].as_bool().unwrap_or(true); - result_ok(crate::commands::voice::set_voice_refine_enabled_inner( - enabled, - )) -} - -async fn h_get_voice_refine_base_url() -> Response { - result_json(crate::commands::voice::get_voice_refine_base_url_inner()) -} - -async fn h_set_voice_refine_base_url(Json(args): Json) -> Response { - let url = args["url"].as_str().unwrap_or("").to_string(); - result_ok(crate::commands::voice::set_voice_refine_base_url_inner(url)) -} - -async fn h_get_voice_asr_model() -> Response { - result_json(crate::commands::voice::get_voice_asr_model_inner()) -} - -async fn h_set_voice_asr_model(Json(args): Json) -> Response { - let model = args["model"].as_str().unwrap_or("").to_string(); - result_ok(crate::commands::voice::set_voice_asr_model_inner(model)) -} - -async fn h_get_voice_refine_model() -> Response { - result_json(crate::commands::voice::get_voice_refine_model_inner()) -} - -async fn h_set_voice_refine_model(Json(args): Json) -> Response { - let model = args["model"].as_str().unwrap_or("").to_string(); - result_ok(crate::commands::voice::set_voice_refine_model_inner(model)) -} - -async fn h_list_dashscope_models() -> Response { - result_json(crate::commands::voice::list_dashscope_models_inner().await) -} - -// -- Cloud connection -- - -async fn h_cloud_get_status() -> Response { - result_json(crate::commands::cloud::cloud_get_status().await) -} - -async fn h_cloud_start_pairing() -> Response { - result_json(crate::commands::cloud::cloud_start_pairing().await) -} - -async fn h_cloud_check_pairing_status() -> Response { - result_json(crate::commands::cloud::cloud_check_pairing_status().await) -} - -async fn h_cloud_approve_pairing() -> Response { - result_json(crate::commands::cloud::cloud_approve_pairing().await) -} - -async fn h_cloud_reject_pairing() -> Response { - result_ok(crate::commands::cloud::cloud_reject_pairing().await) -} - -async fn h_cloud_disconnect() -> Response { - result_ok(crate::commands::cloud::cloud_disconnect().await) -} - -// -- Connected clients -- - -async fn h_get_connected_clients() -> Response { - match CONNECTED_CLIENTS.lock() { - Ok(clients) => { - let list: Vec = clients.values().cloned().collect(); - Json(json!(list)).into_response() - } - Err(_) => Json(json!(Vec::::new())).into_response(), - } -} - -async fn h_kick_client(Json(args): Json) -> Response { - let session_id = get_param(&args, "session_id"); - result_ok(crate::kick_client_internal(&session_id)) -} - -// -- Certificate download -- - -async fn h_cert_pem(Extension(cert_pem): Extension>) -> Response { - ( - StatusCode::OK, - [ - (header::CONTENT_TYPE, "application/x-pem-file"), - ( - header::CONTENT_DISPOSITION, - "attachment; filename=\"worktree-manager.pem\"", - ), - ], - cert_pem.as_str().to_string(), - ) - .into_response() -} - -// --------------------------------------------------------------------------- -// Router -// --------------------------------------------------------------------------- - -/// Check if an origin is allowed (localhost, LAN, or active ngrok URL). -fn is_allowed_origin(origin: &str) -> bool { - let ngrok_url = SHARE_STATE.lock().ok().and_then(|s| s.ngrok_url.clone()); - crate::http_origin_policy::is_allowed_origin(origin, ngrok_url.as_deref()) -} - -#[cfg(test)] -mod tests { - use super::create_router; - use super::is_allowed_origin; - use crate::{ - AUTHENTICATED_SESSIONS, AUTH_RATE_LIMITER, CONNECTED_CLIENTS, NONCE_CACHE, SHARE_STATE, - }; - use axum::body::{to_bytes, Body}; - use axum::http::{header, Method, Request, StatusCode}; - use axum::response::Response; - use once_cell::sync::Lazy; - use serde_json::{json, Value}; - use serial_test::serial; - use std::collections::{HashMap, HashSet}; - use std::net::SocketAddr; - use std::sync::{Mutex, MutexGuard}; - use tower::ServiceExt; - - static TEST_MUTEX: Lazy> = Lazy::new(|| Mutex::new(())); - - fn lock_test_mutex() -> MutexGuard<'static, ()> { - TEST_MUTEX.lock().unwrap_or_else(|err| err.into_inner()) - } - - struct ShareStateTestGuard { - prev_share_state: crate::ShareState, - prev_sessions: HashSet, - prev_clients: HashMap, - prev_rate_limiter: crate::AuthRateLimiter, - prev_nonce_cache: crate::NonceCache, - } - - impl ShareStateTestGuard { - fn with_auth_enabled() -> Self { - let guard = Self::capture(); - { - let mut state = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state.active = true; - state.auth_key = Some(b"test-auth-key".to_vec()); - state.auth_salt = Some(b"test-auth-salt".to_vec()); - state.workspace_path = Some("/tmp/test-workspace".to_string()); - } - guard - } - - fn without_password() -> Self { - let guard = Self::capture(); - { - let mut state = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state.active = false; - state.auth_key = None; - state.auth_salt = None; - state.workspace_path = None; - } - guard - } - - fn capture() -> Self { - let prev_share_state = { - let mut state = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *state) - }; - let prev_sessions = { - let mut sessions = AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *sessions) - }; - let prev_clients = { - let mut clients = CONNECTED_CLIENTS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *clients) - }; - let prev_rate_limiter = { - let mut limiter = AUTH_RATE_LIMITER - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::replace(&mut *limiter, crate::AuthRateLimiter::new()) - }; - let prev_nonce_cache = { - let mut cache = NONCE_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::replace(&mut *cache, crate::NonceCache::new()) - }; - - Self { - prev_share_state, - prev_sessions, - prev_clients, - prev_rate_limiter, - prev_nonce_cache, - } - } - } - - impl Drop for ShareStateTestGuard { - fn drop(&mut self) { - *SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = - std::mem::take(&mut self.prev_share_state); - *AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = - std::mem::take(&mut self.prev_sessions); - *CONNECTED_CLIENTS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = - std::mem::take(&mut self.prev_clients); - *AUTH_RATE_LIMITER - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = - std::mem::replace(&mut self.prev_rate_limiter, crate::AuthRateLimiter::new()); - *NONCE_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = - std::mem::replace(&mut self.prev_nonce_cache, crate::NonceCache::new()); - } - } - - async fn request_with_addr(addr: SocketAddr, request: Request) -> Response { - let make_svc = create_router(Some("dummy-cert-pem".to_string())) - .into_make_service_with_connect_info::(); - let svc = make_svc.oneshot(addr).await.unwrap(); - svc.oneshot(request).await.unwrap() - } - - async fn response_json(response: Response) -> Value { - let status = response.status(); - let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); - match serde_json::from_slice(&bytes) { - Ok(value) => value, - Err(err) => { - eprintln!( - "response_json parse failed (status {}). raw body: {:?}", - status, - String::from_utf8_lossy(&bytes) - ); - panic!("{}", err); - } - } - } - - async fn request_json( - addr: SocketAddr, - path: &str, - payload: Value, - user_agent: Option<&str>, - ) -> (StatusCode, Value) { - let mut builder = Request::builder() - .method(Method::POST) - .uri(path) - .header(header::CONTENT_TYPE, "application/json"); - - if let Some(agent) = user_agent { - builder = builder.header(header::USER_AGENT, agent); - } - - let response = - request_with_addr(addr, builder.body(Body::from(payload.to_string())).unwrap()).await; - let status = response.status(); - let body = response_json(response).await; - (status, body) - } - - fn build_proof_hex(auth_key: &[u8], nonce_hex: &str) -> String { - let nonce_bytes = hex::decode(nonce_hex).unwrap(); - let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, auth_key); - hex::encode(ring::hmac::sign(&key, &nonce_bytes).as_ref()) - } - - #[serial] - #[test] - fn masks_multibyte_api_keys_without_byte_slicing() { - assert_eq!( - super::mask_api_key_for_response("abcd1234wxyz"), - "abcd...wxyz" - ); - assert_eq!(super::mask_api_key_for_response("abcdefgh"), "****"); - assert_eq!( - super::mask_api_key_for_response("密钥abcd尾巴EF"), - "密钥ab...尾巴EF" - ); - } - - // Pure-logic origin policy tests live in crate::http_origin_policy. - // This test exercises the global-state path (ngrok URL from SHARE_STATE). - #[serial] - #[test] - fn only_allows_exact_active_ngrok_origin() { - let _serial = lock_test_mutex(); - let previous = { - let mut state = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let previous = state.ngrok_url.clone(); - state.ngrok_url = Some("https://demo.ngrok-free.app/".to_string()); - previous - }; - - assert!(is_allowed_origin("https://demo.ngrok-free.app")); - assert!(is_allowed_origin("https://demo.ngrok-free.app:443")); - assert!(!is_allowed_origin( - "https://demo.ngrok-free.app.evil.example" - )); - assert!(!is_allowed_origin("https://other.ngrok-free.app")); - - let mut state = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state.ngrok_url = previous; - } - - fn extract_api_routes_from_routing_source() -> Vec<(Method, String)> { - // Keep this in sync with routing behavior without hand-maintaining a list. - // We parse `.route(...)` calls in `routing.rs` and extract (method, path). - let src = include_str!("http_server/routing.rs"); - let mut routes = Vec::new(); - - let mut i = 0usize; - while let Some(found) = src[i..].find(".route(") { - let start = i + found + ".route(".len(); - let bytes = src.as_bytes(); - let mut depth = 1i32; - let mut j = start; - while j < bytes.len() && depth > 0 { - match bytes[j] as char { - '(' => depth += 1, - ')' => depth -= 1, - _ => {} - } - j += 1; - } - if depth != 0 { - break; - } - - // `.route()` contents: - let call = &src[start..(j - 1)]; - - // Extract first string literal as the path. - let q1 = match call.find('"') { - Some(x) => x, - None => { - i = j; - continue; - } - }; - let q2 = match call[q1 + 1..].find('"') { - Some(x) => q1 + 1 + x, - None => { - i = j; - continue; - } - }; - let path = call[q1 + 1..q2].to_string(); - - // Determine HTTP method by looking for `get(` / `post(` inside the call. - let method = if call.contains("get(") { - Method::GET - } else if call.contains("post(") { - Method::POST - } else { - i = j; - continue; - }; - - if path.starts_with("/api/") { - routes.push((method, path)); - } - - i = j; - } - - // Stable order makes failures easier to read. - routes.sort_by(|a, b| a.1.cmp(&b.1)); - routes - } - - #[serial] - #[tokio::test] - async fn auth_middleware_rejects_unauthenticated_protected_route() { - let _serial = lock_test_mutex(); - let _guard = ShareStateTestGuard::with_auth_enabled(); - - let response = request_with_addr( - SocketAddr::from(([127, 0, 0, 1], 31001)), - Request::builder() - .method(Method::POST) - .uri("/api/get_share_state") - .header(header::CONTENT_TYPE, "application/json") - .header("x-session-id", "unauth-session") - .body(Body::from("{}")) - .unwrap(), - ) - .await; - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } - - #[serial] - #[tokio::test] - async fn auth_middleware_allows_authenticated_protected_route() { - let _serial = lock_test_mutex(); - let _guard = ShareStateTestGuard::with_auth_enabled(); - AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert("auth-session".to_string()); - - let response = request_with_addr( - SocketAddr::from(([127, 0, 0, 1], 31002)), - Request::builder() - .method(Method::POST) - .uri("/api/get_share_state") - .header(header::CONTENT_TYPE, "application/json") - .header("x-session-id", "auth-session") - .body(Body::from("{}")) - .unwrap(), - ) - .await; - - assert_eq!(response.status(), StatusCode::OK); - } - - #[serial] - #[tokio::test] - async fn localhost_only_middleware_blocks_remote_clients() { - let _serial = lock_test_mutex(); - let _guard = ShareStateTestGuard::without_password(); - - let response = request_with_addr( - SocketAddr::from(([203, 0, 113, 9], 32001)), - Request::builder() - .method(Method::POST) - .uri("/api/get_ngrok_token") - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from("{}")) - .unwrap(), - ) - .await; - - assert_eq!(response.status(), StatusCode::FORBIDDEN); - } - - #[serial] - #[tokio::test] - async fn localhost_only_middleware_blocks_remote_last_share_password() { - let _serial = lock_test_mutex(); - let _guard = ShareStateTestGuard::without_password(); - - let response = request_with_addr( - SocketAddr::from(([203, 0, 113, 9], 32004)), - Request::builder() - .method(Method::POST) - .uri("/api/get_last_share_password") - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from("{}")) - .unwrap(), - ) - .await; - - assert_eq!(response.status(), StatusCode::FORBIDDEN); - } - - #[serial] - #[tokio::test] - async fn localhost_only_middleware_blocks_forwarded_loopback_clients() { - let _serial = lock_test_mutex(); - let _guard = ShareStateTestGuard::without_password(); - - let response = request_with_addr( - SocketAddr::from(([127, 0, 0, 1], 32003)), - Request::builder() - .method(Method::POST) - .uri("/api/get_ngrok_token") - .header(header::CONTENT_TYPE, "application/json") - .header("x-forwarded-for", "203.0.113.9") - .body(Body::from("{}")) - .unwrap(), - ) - .await; - - assert_eq!(response.status(), StatusCode::FORBIDDEN); - } - - #[serial] - #[tokio::test] - async fn localhost_only_middleware_allows_loopback_clients() { - let _serial = lock_test_mutex(); - let _guard = ShareStateTestGuard::without_password(); - - let response = request_with_addr( - SocketAddr::from(([127, 0, 0, 1], 32002)), - Request::builder() - .method(Method::POST) - .uri("/api/get_ngrok_token") - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from("{}")) - .unwrap(), - ) - .await; - - assert_eq!(response.status(), StatusCode::OK); - } - - #[serial] - #[tokio::test] - async fn auth_challenge_requires_configured_salt() { - let _serial = lock_test_mutex(); - let _guard = ShareStateTestGuard::without_password(); - - { - let mut state = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state.active = true; - state.auth_key = Some(b"test-auth-key".to_vec()); - state.auth_salt = None; - } - - let response = request_with_addr( - SocketAddr::from(([127, 0, 0, 1], 33001)), - Request::builder() - .method(Method::POST) - .uri("/api/auth/challenge") - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from("{}")) - .unwrap(), - ) - .await; - - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); - } - - #[serial] - #[tokio::test] - async fn auth_challenge_rate_limits_after_five_attempts() { - let _serial = lock_test_mutex(); - let _guard = ShareStateTestGuard::with_auth_enabled(); - - for attempt in 1..=6 { - let response = request_with_addr( - SocketAddr::from(([127, 0, 0, 1], 33002)), - Request::builder() - .method(Method::POST) - .uri("/api/auth/challenge") - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from("{}")) - .unwrap(), - ) - .await; - - let expected = if attempt < 6 { - StatusCode::OK - } else { - StatusCode::TOO_MANY_REQUESTS - }; - assert_eq!(response.status(), expected); - } - } - - #[serial] - #[tokio::test] - async fn auth_verify_accepts_valid_proof_and_rejects_nonce_reuse() { - let _serial = lock_test_mutex(); - let _guard = ShareStateTestGuard::with_auth_enabled(); - - let (_status, challenge) = request_json( - SocketAddr::from(([127, 0, 0, 1], 33003)), - "/api/auth/challenge", - json!({}), - None, - ) - .await; - - let nonce = challenge["nonce"].as_str().unwrap().to_string(); - let proof = build_proof_hex(b"test-auth-key", &nonce); - - let verify_response = request_json( - SocketAddr::from(([127, 0, 0, 1], 33003)), - "/api/auth/verify", - json!({ "nonce": nonce, "proof": proof }), - Some("test-agent/1.0"), - ) - .await; - assert_eq!(verify_response.0, StatusCode::OK); - - let second_response = request_with_addr( - SocketAddr::from(([127, 0, 0, 1], 33003)), - Request::builder() - .method(Method::POST) - .uri("/api/auth/verify") - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from( - json!({ "nonce": nonce, "proof": proof }).to_string(), - )) - .unwrap(), - ) - .await; - - assert_eq!(second_response.status(), StatusCode::UNAUTHORIZED); - } - - #[serial] - #[tokio::test] - async fn auth_verify_rejects_bad_proof() { - let _serial = lock_test_mutex(); - let _guard = ShareStateTestGuard::with_auth_enabled(); - - let (_status, challenge) = request_json( - SocketAddr::from(([127, 0, 0, 1], 33005)), - "/api/auth/challenge", - json!({}), - None, - ) - .await; - let nonce = challenge["nonce"].as_str().unwrap().to_string(); - - let response = request_with_addr( - SocketAddr::from(([127, 0, 0, 1], 33005)), - Request::builder() - .method(Method::POST) - .uri("/api/auth/verify") - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from( - json!({ "nonce": nonce, "proof": "00" }).to_string(), - )) - .unwrap(), - ) - .await; - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } - - #[serial] - #[tokio::test] - async fn auth_verify_replaces_stale_sessions_from_same_ip() { - let _serial = lock_test_mutex(); - let _guard = ShareStateTestGuard::with_auth_enabled(); - - CONNECTED_CLIENTS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert( - "stale-session".to_string(), - crate::ConnectedClient { - session_id: "stale-session".to_string(), - ip: "127.0.0.1".to_string(), - user_agent: "old".to_string(), - authenticated_at: "2026-04-12T00:00:00Z".to_string(), - last_active: "2026-04-12T00:00:00Z".to_string(), - ws_connected: false, - }, - ); - AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert("stale-session".to_string()); - - let (_status, challenge) = request_json( - SocketAddr::from(([127, 0, 0, 1], 33004)), - "/api/auth/challenge", - json!({}), - None, - ) - .await; - let nonce = challenge["nonce"].as_str().unwrap().to_string(); - let proof = build_proof_hex(b"test-auth-key", &nonce); - - let (status, body) = request_json( - SocketAddr::from(([127, 0, 0, 1], 33004)), - "/api/auth/verify", - json!({ "nonce": nonce, "proof": proof }), - Some("test-agent/1.0"), - ) - .await; - - assert_eq!(status, StatusCode::OK); - let new_session = body["sessionId"].as_str().unwrap(); - assert!(!AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .contains("stale-session")); - assert!(AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .contains(new_session)); - } - - #[serial] - #[tokio::test] - async fn api_router_smoke_all_routes_exist_and_do_not_500() { - let _serial = lock_test_mutex(); - // Turn on auth so most `/api/*` endpoints short-circuit at middleware and don't - // execute handler logic (avoids IO/network side effects while still proving routing). - let _guard = ShareStateTestGuard::with_auth_enabled(); - - let routes = extract_api_routes_from_routing_source(); - assert!( - !routes.is_empty(), - "expected to extract /api routes from routing.rs" - ); - - let addr = SocketAddr::from(([127, 0, 0, 1], 12345)); - - for (method, path) in routes { - // Build router with cert enabled so `/api/cert.pem` exists. - let make_svc = create_router(Some("dummy-cert-pem".to_string())) - .into_make_service_with_connect_info::(); - - let svc = make_svc - .oneshot(addr) - .await - .expect("make_service should succeed"); - - let mut builder = Request::builder() - .method(method.clone()) - .uri(&path) - .header("x-session-id", "test-session"); - - // For POST routes, send an empty JSON body so Json extractors fail fast where present. - let req = if method == Method::POST { - builder = builder.header(header::CONTENT_TYPE, "application/json"); - builder.body(Body::empty()).unwrap() - } else { - builder.body(Body::empty()).unwrap() - }; - - let resp = svc.oneshot(req).await.expect("request should succeed"); - let status = resp.status(); - - assert_ne!( - status, - StatusCode::NOT_FOUND, - "route missing: {} {}", - method, - path - ); - assert_ne!( - status, - StatusCode::INTERNAL_SERVER_ERROR, - "route crashed (500): {} {}", - method, - path - ); - } - } -} - -pub fn create_router(cert_pem: Option) -> Router { - let cors = build_cors_layer(); - let dist_path = resolve_dist_path(); - let serve_dir = ServeDir::new(&dist_path) - .append_index_html_on_directories(true) - .fallback(ServeFile::new(dist_path.join("index.html"))); - - build_api_router(cert_pem) - .layer(axum::middleware::from_fn(auth_middleware)) - .layer(axum::middleware::from_fn(localhost_only_middleware)) - .layer(axum::middleware::from_fn(security_headers_middleware)) - .layer(RequestBodyLimitLayer::new(1024 * 1024)) - .fallback_service(serve_dir) - .layer(axum::middleware::from_fn(no_cache_html_middleware)) - .layer(cors) -} - -// --------------------------------------------------------------------------- -// Server startup -// --------------------------------------------------------------------------- - -/// Start the server with graceful shutdown support. -/// -/// When `tls_certs` is Some (sharing mode): -/// Single port — localhost connections get plain HTTP, LAN connections get HTTPS. -/// When `tls_certs` is None: -/// Plain HTTP for everyone (e.g. dev mode). -pub async fn start_server( - port: u16, - mut shutdown_rx: tokio::sync::watch::Receiver, - tls_certs: Option, -) { - let addr = SocketAddr::from(([0, 0, 0, 0], port)); - - log::info!("[http-server] Starting server on {}", addr); - let listener = match tokio::net::TcpListener::bind(addr).await { - Ok(l) => l, - Err(e) => { - log::error!("[http-server] Failed to bind server on {}: {}", addr, e); - return; - } - }; - - match tls_certs { - Some(certs) => { - // Dual-protocol: HTTP for localhost, HTTPS for LAN — same port - log::info!( - "[http-server] Server on {} (localhost: HTTP, LAN: HTTPS)", - addr - ); - - let app = create_router(Some(certs.cert_pem.clone())); - - let cert_chain: Vec> = { - let mut reader = std::io::BufReader::new(certs.cert_pem.as_bytes()); - rustls_pemfile::certs(&mut reader) - .filter_map(|r| r.ok()) - .collect() - }; - let key_der = { - let mut reader = std::io::BufReader::new(certs.key_pem.as_bytes()); - match rustls_pemfile::private_key(&mut reader) { - Ok(Some(key)) => key, - Ok(None) => { - log::error!("[http-server] No private key found in TLS key PEM"); - return; - } - Err(e) => { - log::error!("[http-server] Failed to parse TLS private key PEM: {}", e); - return; - } - } - }; - - let mut tls_config = match rustls::ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(cert_chain, key_der) - { - Ok(config) => config, - Err(e) => { - log::error!("[http-server] Failed to build TLS ServerConfig: {}", e); - return; - } - }; - // ALPN: HTTP/1.1 only (h2 doesn't support traditional WebSocket upgrade) - tls_config.alpn_protocols = vec![b"http/1.1".to_vec()]; - let tls_acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config)); - - loop { - tokio::select! { - _ = shutdown_rx.changed() => { - log::info!("[http-server] Server shutting down gracefully"); - break; - } - result = listener.accept() => { - let (tcp_stream, remote_addr) = match result { - Ok(v) => v, - Err(e) => { - log::warn!("TCP accept error: {}", e); - continue; - } - }; - - let app = app.clone(); - - if remote_addr.ip().is_loopback() { - // Localhost → plain HTTP/1.1 (with WebSocket upgrade support) - tokio::spawn(async move { - let io = hyper_util::rt::TokioIo::new(tcp_stream); - let service = hyper::service::service_fn(move |mut req: hyper::Request| { - req.extensions_mut().insert(ConnectInfo(remote_addr)); - let mut app = app.clone(); - async move { - use tower::Service; - app.call(req).await - } - }); - if let Err(e) = hyper::server::conn::http1::Builder::new() - .keep_alive(true) - .serve_connection(io, service) - .with_upgrades() - .await - { - let msg = e.to_string(); - if !msg.contains("connection closed") && !msg.contains("reset") { - log::warn!("HTTP connection error from {}: {}", remote_addr, e); - } - } - }); - } else { - // LAN → TLS handshake → HTTPS (h2 or h1.1 via ALPN) - let acceptor = tls_acceptor.clone(); - tokio::spawn(async move { - let tls_stream = match acceptor.accept(tcp_stream).await { - Ok(s) => s, - Err(e) => { - // Expected when LAN client tries plain HTTP - log::debug!("TLS handshake failed from {}: {}", remote_addr, e); - return; - } - }; - let io = hyper_util::rt::TokioIo::new(tls_stream); - let service = hyper::service::service_fn(move |mut req: hyper::Request| { - req.extensions_mut().insert(ConnectInfo(remote_addr)); - let mut app = app.clone(); - async move { - use tower::Service; - app.call(req).await - } - }); - // HTTPS with HTTP/1.1 keep-alive + WebSocket upgrade support - // (h2 doesn't support traditional WebSocket upgrade) - if let Err(e) = hyper::server::conn::http1::Builder::new() - .keep_alive(true) - .serve_connection(io, service) - .with_upgrades() - .await - { - let msg = e.to_string(); - if !msg.contains("connection closed") && !msg.contains("reset") { - log::warn!("HTTPS connection error from {}: {}", remote_addr, e); - } - } - }); - } - } - } - } - } - None => { - // Pure HTTP mode - log::info!("[http-server] HTTP server listening on http://{}", addr); - let app = create_router(None); - - if let Err(e) = axum::serve( - listener, - app.into_make_service_with_connect_info::(), - ) - .with_graceful_shutdown(async move { - let _ = shutdown_rx.changed().await; - log::info!("[http-server] HTTP server shutting down"); - }) - .await - { - log::error!("[http-server] HTTP server error: {}", e); - } - } - } -} - -// MCP config management -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct McpConfig { - pub version: String, - pub http_port: u16, - pub installed_at: String, - pub capability_level: String, -} - -pub fn get_mcp_config_path() -> PathBuf { - let home = std::env::var("HOME").unwrap_or_default(); - PathBuf::from(home) - .join(".config") - .join("worktree-manager") - .join("mcp.json") -} - -pub fn load_mcp_config() -> Option { - let path = get_mcp_config_path(); - if path.exists() { - let content = std::fs::read_to_string(&path).ok()?; - serde_json::from_str(&content).ok() - } else { - None - } -} - -pub fn save_mcp_config(config: &McpConfig) -> Result<(), String> { - let path = get_mcp_config_path(); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - let content = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?; - std::fs::write(&path, content).map_err(|e| e.to_string())?; - Ok(()) -} - -/// Start the MCP HTTP server on the specified port. -/// This runs as a background task. -pub async fn start_mcp_server(port: u16) -> Result<(), String> { - let addr = SocketAddr::from(([127, 0, 0, 1], port)); - - // Save MCP config - let config = McpConfig { - version: env!("CARGO_PKG_VERSION").to_string(), - http_port: port, - installed_at: chrono::Utc::now().to_rfc3339(), - capability_level: "core".to_string(), - }; - save_mcp_config(&config)?; - - log::info!("[MCP] Starting HTTP server on {}", addr); - - let router = build_api_router(None); - - let listener = TcpListener::bind(addr) - .await - .map_err(|e| format!("Failed to bind MCP server: {}", e))?; - - serve(listener, router) - .await - .map_err(|e| format!("MCP server error: {}", e))?; - - Ok(()) -} - -#[cfg(test)] -mod additional_tests { - use super::*; - use axum::body::to_bytes; - use axum::Json; - use serde_json::json; - use serial_test::serial; - - async fn response_text(response: Response) -> (StatusCode, String) { - let status = response.status(); - let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); - (status, String::from_utf8(bytes.to_vec()).unwrap()) - } - - #[serial] - #[test] - fn mcp_config_round_trips_json_with_concrete_fields() { - let config = McpConfig { - version: "0.1.2".to_string(), - http_port: 42_819, - installed_at: "2026-06-11T00:00:00+00:00".to_string(), - capability_level: "advanced".to_string(), - }; - - let encoded = serde_json::to_string(&config).unwrap(); - let decoded: McpConfig = serde_json::from_str(&encoded).unwrap(); - - assert_eq!(decoded.version, "0.1.2"); - assert_eq!(decoded.http_port, 42_819); - assert_eq!(decoded.installed_at, "2026-06-11T00:00:00+00:00"); - assert_eq!(decoded.capability_level, "advanced"); - } - - #[serial] - #[test] - fn request_structs_deserialize_expected_and_reject_missing_fields() { - let add_workspace: AddWsArgs = - serde_json::from_value(json!({ "name": "Demo", "path": "/tmp/demo" })).unwrap(); - assert_eq!(add_workspace.name, "Demo"); - assert_eq!(add_workspace.path, "/tmp/demo"); - - let path_args: PathArgs = serde_json::from_value(json!({ "path": "/tmp/remove" })).unwrap(); - assert_eq!(path_args.path, "/tmp/remove"); - - let verify: VerifyRequest = - serde_json::from_value(json!({ "proof": "abcd", "nonce": "1234" })).unwrap(); - assert_eq!(verify.proof, "abcd"); - assert_eq!(verify.nonce, "1234"); - - assert!(serde_json::from_value::(json!({ "name": "missing path" })).is_err()); - assert!(serde_json::from_value::(json!({ "proof": "abcd" })).is_err()); - } - - #[serial] - #[test] - fn nonce_cache_generates_hex_nonce_and_consumes_once() { - let mut cache = crate::NonceCache::new(); - - let nonce = cache.generate().unwrap(); - - assert_eq!(nonce.len(), 64); - assert!(nonce.chars().all(|c| c.is_ascii_hexdigit())); - assert_eq!(cache.consume(&nonce).unwrap().len(), 32); - assert!(cache.consume(&nonce).is_none()); - assert!(cache.consume("not-hex-and-not-present").is_none()); - } - - #[serial] - #[test] - fn loopback_request_parser_accepts_only_loopback_addresses() { - assert!(middleware::is_loopback_request(&SocketAddr::from(( - [127, 0, 0, 1], - 42819 - )))); - assert!(middleware::is_loopback_request(&SocketAddr::from(( - std::net::Ipv6Addr::LOCALHOST, - 42819 - )))); - assert!(!middleware::is_loopback_request(&SocketAddr::from(( - [192, 168, 1, 50], - 42819 - )))); - } - - #[serial] - #[tokio::test] - async fn terminate_process_handler_rejects_wrong_pid_type_before_command_logic() { - let (status, body) = response_text( - h_terminate_worktree_locking_process( - HeaderMap::new(), - Json(json!({ - "name": "feature-a", - "pid": "not-a-number", - "processStartTime": "2026-06-11T00:00:00Z" - })), - ) - .await, - ) - .await; - - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!(body, "Invalid pid"); - } - - #[serial] - #[tokio::test] - async fn terminate_process_handler_rejects_missing_process_start_time() { - let (status, body) = response_text( - h_terminate_worktree_locking_process( - HeaderMap::new(), - Json(json!({ - "name": "feature-a", - "pid": 1234 - })), - ) - .await, - ) - .await; - - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!(body, "Invalid processStartTime"); - } -} - -#[cfg(test)] -mod http_server_coverage_tests { - use super::*; - use axum::body::{to_bytes, Body}; - use axum::http::{header, HeaderMap, Method, Request, StatusCode}; - use axum::response::Response; - use futures_util::{SinkExt, StreamExt}; - use once_cell::sync::Lazy; - use serde_json::{json, Value}; - use serial_test::serial; - use std::collections::{HashMap, HashSet}; - use std::net::SocketAddr; - use std::path::PathBuf; - use std::sync::{Mutex, MutexGuard}; - use std::time::Duration; - use tempfile::TempDir; - use tokio_tungstenite::tungstenite::{client::IntoClientRequest, Message as WsClientMessage}; - use tower::ServiceExt; - - static COVERAGE_TEST_MUTEX: Lazy> = Lazy::new(|| Mutex::new(())); - - fn lock_coverage_tests() -> MutexGuard<'static, ()> { - COVERAGE_TEST_MUTEX - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - } - - struct GlobalStateGuard { - prev_global_config_cache: Option, - prev_workspace_config_cache: Option<(String, crate::WorkspaceConfig)>, - prev_share_state: crate::ShareState, - prev_sessions: HashSet, - prev_clients: HashMap, - prev_locks: HashMap<(String, String), String>, - prev_terminal_states: HashMap<(String, String), crate::TerminalState>, - prev_window_workspaces: HashMap, - } - - impl GlobalStateGuard { - fn new() -> Self { - let prev_global_config_cache = { - let mut cache = crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *cache) - }; - let prev_workspace_config_cache = { - let mut cache = crate::state::WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *cache) - }; - let prev_share_state = { - let mut state = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *state) - }; - let prev_sessions = { - let mut sessions = AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *sessions) - }; - let prev_clients = { - let mut clients = CONNECTED_CLIENTS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *clients) - }; - let prev_locks = { - let mut locks = crate::WORKTREE_LOCKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *locks) - }; - let prev_terminal_states = { - let mut states = crate::TERMINAL_STATES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *states) - }; - let prev_window_workspaces = { - let mut windows = crate::WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::take(&mut *windows) - }; - - *crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = - Some(crate::types::GlobalConfig::default()); - crate::commands::cloud::clear_pairing_state_for_test(); - - Self { - prev_global_config_cache, - prev_workspace_config_cache, - prev_share_state, - prev_sessions, - prev_clients, - prev_locks, - prev_terminal_states, - prev_window_workspaces, - } - } - } - - impl Drop for GlobalStateGuard { - fn drop(&mut self) { - *crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = - std::mem::take(&mut self.prev_global_config_cache); - *crate::state::WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = - std::mem::take(&mut self.prev_workspace_config_cache); - *SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = - std::mem::take(&mut self.prev_share_state); - *AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = - std::mem::take(&mut self.prev_sessions); - *CONNECTED_CLIENTS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = - std::mem::take(&mut self.prev_clients); - *crate::WORKTREE_LOCKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = - std::mem::take(&mut self.prev_locks); - *crate::TERMINAL_STATES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = - std::mem::take(&mut self.prev_terminal_states); - *crate::WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = - std::mem::take(&mut self.prev_window_workspaces); - crate::commands::cloud::clear_pairing_state_for_test(); - } - } - - struct EnvVarGuard { - key: &'static str, - previous: Option, - } - - impl EnvVarGuard { - fn set(key: &'static str, value: &str) -> Self { - let previous = std::env::var_os(key); - std::env::set_var(key, value); - Self { key, previous } - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - match &self.previous { - Some(value) => std::env::set_var(self.key, value), - None => std::env::remove_var(self.key), - } - } - } - - struct NamedFileLock { - path: PathBuf, - } - - impl NamedFileLock { - fn acquire(name: &str) -> Self { - let path = std::env::temp_dir().join(name); - loop { - match std::fs::create_dir(&path) { - Ok(()) => return Self { path }, - Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { - std::thread::sleep(Duration::from_millis(10)); - } - Err(err) => panic!("failed to acquire test lock {:?}: {}", path, err), - } - } - } - } - - impl Drop for NamedFileLock { - fn drop(&mut self) { - let _ = std::fs::remove_dir(&self.path); - } - } - - struct TempConfigRootGuard { - _command_lock: NamedFileLock, - _config_lock: NamedFileLock, - temp_home: TempDir, - prev_home: Option, - prev_global_config_cache: Option, - } - - impl TempConfigRootGuard { - fn new(config: crate::types::GlobalConfig) -> Self { - let command_lock = NamedFileLock::acquire("worktree-manager-command-test-global-lock"); - let config_lock = NamedFileLock::acquire("worktree-manager-global-config-cache.lock"); - let temp_home = TempDir::new().unwrap(); - let prev_home = std::env::var_os("HOME"); - std::env::set_var("HOME", temp_home.path()); - let prev_global_config_cache = { - let mut cache = crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::replace(&mut *cache, Some(config)) - }; - - Self { - _command_lock: command_lock, - _config_lock: config_lock, - temp_home, - prev_home, - prev_global_config_cache, - } - } - - fn mcp_config_path(&self) -> PathBuf { - self.temp_home - .path() - .join(".config") - .join("worktree-manager") - .join("mcp.json") - } - } - - impl Drop for TempConfigRootGuard { - fn drop(&mut self) { - match &self.prev_home { - Some(value) => std::env::set_var("HOME", value), - None => std::env::remove_var("HOME"), - } - *crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = - std::mem::take(&mut self.prev_global_config_cache); - } - } - - fn cache_global_config(config: crate::types::GlobalConfig) { - *crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(config); - } - - fn auth_headers(session_id: &str) -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert("x-session-id", session_id.parse().unwrap()); - headers - } - - fn enable_auth(auth_key: &[u8], salt: &[u8], workspace_path: Option) { - let mut state = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state.active = true; - state.port = 42819; - state.auth_key = Some(auth_key.to_vec()); - state.auth_salt = Some(salt.to_vec()); - state.workspace_path = workspace_path; - } - - fn proof_hex(auth_key: &[u8], nonce_hex: &str) -> String { - let nonce_bytes = hex::decode(nonce_hex).unwrap(); - let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, auth_key); - hex::encode(ring::hmac::sign(&key, &nonce_bytes).as_ref()) - } - - async fn text_response(response: Response) -> (StatusCode, String) { - let status = response.status(); - let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); - (status, String::from_utf8(bytes.to_vec()).unwrap()) - } - - async fn json_response(response: Response) -> (StatusCode, Value) { - let status = response.status(); - let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); - let value = if bytes.is_empty() { - Value::Null - } else { - serde_json::from_slice(&bytes).unwrap_or_else(|err| { - panic!( - "failed to parse response body as JSON (status {}): {}; body={}", - status, - err, - String::from_utf8_lossy(&bytes) - ) - }) - }; - (status, value) - } - - async fn assert_text_contains(response: Response, status: StatusCode, expected: &str) { - let (actual_status, body) = text_response(response).await; - assert_eq!( - actual_status, status, - "expected status {status} with body containing {expected:?}, got status {actual_status} and body {body:?}" - ); - assert!( - body.contains(expected), - "expected body to contain {expected:?}, got {body:?}" - ); - } - - async fn request_with_addr(addr: SocketAddr, request: Request) -> Response { - let make_svc = create_router(Some("coverage-cert".to_string())) - .into_make_service_with_connect_info::(); - let svc = make_svc.oneshot(addr).await.unwrap(); - svc.oneshot(request).await.unwrap() - } - - async fn next_ws_json( - socket: &mut tokio_tungstenite::WebSocketStream, - expected_type: &str, - ) -> Value { - let mut seen = Vec::new(); - tokio::time::timeout(Duration::from_secs(10), async { - loop { - let message = socket - .next() - .await - .expect("websocket should produce a message") - .expect("websocket message should be valid"); - if let WsClientMessage::Text(text) = message { - seen.push(text.to_string()); - let value: Value = serde_json::from_str(&text).unwrap(); - if value["type"].as_str() == Some(expected_type) { - return value; - } - } - } - }) - .await - .unwrap_or_else(|_| { - panic!("timed out waiting for websocket JSON message {expected_type:?}; seen={seen:?}") - }) - } - - #[serial] - #[tokio::test] - async fn helper_functions_cover_response_shapes_and_parameter_aliases() { - let (_status, body) = - text_response(result_json::(Err("bad input".to_string()))).await; - assert_eq!(body, "bad input"); - - let (status, body) = json_response(result_json(Ok(json!({"ok": true})))).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(body, json!({"ok": true})); - - let (status, body) = text_response(result_ok(Err("nope".to_string()))).await; - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!(body, "nope"); - - let (status, body) = text_response(result_ok(Ok(()))).await; - assert_eq!(status, StatusCode::NO_CONTENT); - assert!(body.is_empty()); - - let (status, body) = text_response(result_void_ok()).await; - assert_eq!(status, StatusCode::NO_CONTENT); - assert!(body.is_empty()); - - let args = json!({ - "workspacePath": "/tmp/camel", - "base_branch": "main", - "enabled": true, - "rowCount": 42, - "badNumber": "42" - }); - assert_eq!(get_param(&args, "workspace_path"), "/tmp/camel"); - assert_eq!(get_param(&args, "baseBranch"), "main"); - assert_eq!(get_param(&json!(null), "workspace_path"), ""); - assert_eq!( - get_param_opt( - &json!({"customPath": "/Applications/Editor.app"}), - "custom_path" - ), - Some("/Applications/Editor.app".to_string()) - ); - assert_eq!( - get_param_opt(&json!({"customPath": 7}), "custom_path"), - None - ); - assert!(get_param_bool(&args, "enabled", false)); - assert!(get_param_bool(&json!(null), "enabled", true)); - assert_eq!(get_param_u64(&args, "row_count", 1), 42); - assert_eq!(get_param_u64(&args, "bad_number", 7), 7); - assert_eq!(to_camel("base_branch_name"), "baseBranchName"); - assert_eq!(to_snake("workspacePathName"), "workspace_path_name"); - } - - #[serial] - #[tokio::test] - async fn malformed_handler_payloads_return_specific_client_errors() { - let _serial = lock_coverage_tests(); - let _guard = GlobalStateGuard::new(); - let headers = auth_headers("validation-session"); - - assert_text_contains( - h_save_workspace_config(headers.clone(), Json(json!({"config": "bad"}))).await, - StatusCode::BAD_REQUEST, - "Invalid config", - ) - .await; - assert_text_contains( - h_save_workspace_config_by_path(Json(json!({ - "path": "/tmp/workspace", - "config": "bad" - }))) - .await, - StatusCode::BAD_REQUEST, - "Invalid config", - ) - .await; - assert_text_contains( - h_update_worktree_color( - headers.clone(), - Json(json!({"worktreeName": "feature", "color": "not-a-color"})), - ) - .await, - StatusCode::BAD_REQUEST, - "Invalid color", - ) - .await; - assert_text_contains( - h_create_worktree(headers.clone(), Json(json!({"request": {"name": 7}}))).await, - StatusCode::BAD_REQUEST, - "Invalid request", - ) - .await; - assert_text_contains( - h_add_project_to_worktree(headers.clone(), Json(json!({"request": {"x": true}}))).await, - StatusCode::BAD_REQUEST, - "Invalid request", - ) - .await; - assert_text_contains( - h_clone_project(headers.clone(), Json(json!({"request": {"name": "repo"}}))).await, - StatusCode::BAD_REQUEST, - "Invalid request", - ) - .await; - assert_text_contains( - h_switch_branch(Json(json!({"request": {"projectPath": "/tmp/repo"}}))).await, - StatusCode::BAD_REQUEST, - "Invalid request", - ) - .await; - assert_text_contains( - h_open_in_editor(Json(json!({"request": {"path": "/tmp/repo"}}))).await, - StatusCode::BAD_REQUEST, - "Invalid request", - ) - .await; - assert_text_contains( - h_save_custom_mirrors(Json(json!({"mirrors": "bad"}))).await, - StatusCode::BAD_REQUEST, - "Invalid mirrors", - ) - .await; - assert_text_contains( - h_terminate_worktree_locking_process( - headers, - Json(json!({ - "name": "feature", - "pid": u64::from(u32::MAX) + 1, - "processStartTime": "2026-06-11T00:00:00Z" - })), - ) - .await, - StatusCode::BAD_REQUEST, - "Invalid pid", - ) - .await; - } - - #[serial] - #[tokio::test] - async fn workspace_window_handlers_cover_temp_workspace_success_and_error_paths() { - let _serial = lock_coverage_tests(); - let _guard = GlobalStateGuard::new(); - let temp = TempDir::new().unwrap(); - let _config_root = TempConfigRootGuard::new(crate::types::GlobalConfig::default()); - let workspace_path = temp.path().to_string_lossy().to_string(); - let mut config = crate::types::GlobalConfig::default(); - config.workspaces.push(crate::types::WorkspaceRef { - name: "Coverage Workspace".to_string(), - path: workspace_path.clone(), - }); - config.current_workspace = Some(workspace_path.clone()); - cache_global_config(config); - - let headers = auth_headers("window-session"); - let workspace_config = crate::WorkspaceConfig { - name: "Coverage Workspace".to_string(), - ..crate::WorkspaceConfig::default() - }; - crate::save_workspace_config_internal(&workspace_path, &workspace_config).unwrap(); - - assert_eq!( - h_set_window_workspace( - headers.clone(), - Json(json!({"workspacePath": workspace_path})) - ) - .await - .status(), - StatusCode::NO_CONTENT - ); - - let (status, current) = json_response(h_get_current_workspace(headers.clone()).await).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(current["path"], workspace_path); - assert_eq!(current["name"], "Coverage Workspace"); - - let (status, body) = json_response(h_get_workspace_config(headers.clone()).await).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(body["name"], "Coverage Workspace"); - - let updated_config = json!({ - "name": "Updated Workspace", - "worktrees_dir": "worktrees", - "projects": [], - "linked_workspace_items": [], - "vault_linked_workspace_items": [], - "uat_branch": "uat", - "archived_worktrees": [], - "worktree_colors": {}, - "tags": [] - }); - assert_eq!( - h_save_workspace_config( - headers.clone(), - Json(json!({"config": updated_config.clone()})) - ) - .await - .status(), - StatusCode::NO_CONTENT - ); - assert_eq!( - h_save_workspace_config_by_path(Json(json!({ - "path": workspace_path, - "config": updated_config - }))) - .await - .status(), - StatusCode::NO_CONTENT - ); - - let (status, loaded) = json_response( - h_load_workspace_config_by_path(Json(json!({"path": workspace_path}))).await, - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(loaded["name"], "Updated Workspace"); - - let checked_handlers = [ - h_get_config_path_info(headers.clone()).await, - h_list_worktrees(headers.clone(), Json(json!({"includeArchived": true}))).await, - h_get_main_workspace_status(headers.clone()).await, - h_scan_existing_projects(headers.clone()).await, - h_get_main_occupation(headers.clone()).await, - h_check_worktree_status(headers.clone(), Json(json!({"name": "missing"}))).await, - h_get_opened_workspaces().await, - ]; - for response in checked_handlers { - assert_ne!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); - } - - // 注意:不在此处调用 h_start_sharing —— 合法参数会真正启动分享服务器、绑定端口并污染 - // 全局 SHARE_STATE 且不清理,导致其他测试 flaky;分享启动有专门的测试覆盖。 - let error_handlers = [ - h_archive_worktree(headers.clone(), Json(json!({"name": "missing"}))).await, - h_restore_worktree(headers.clone(), Json(json!({"name": "missing"}))).await, - h_delete_archived_worktree(headers.clone(), Json(json!({"name": "missing"}))).await, - h_import_external_project(headers.clone(), Json(json!({"sourcePath": ""}))).await, - h_remove_project_from_config(headers.clone(), Json(json!({"name": "missing"}))).await, - ]; - for response in error_handlers { - assert!( - response.status().is_client_error(), - "expected 4xx client error, got {}", - response.status() - ); - } - } - - #[serial] - #[tokio::test] - async fn cached_config_get_handlers_mask_secrets_and_return_settings() { - let _serial = lock_coverage_tests(); - let _guard = GlobalStateGuard::new(); - let mut config = crate::types::GlobalConfig::default(); - config.workspaces.push(crate::types::WorkspaceRef { - name: "Demo".to_string(), - path: "/tmp/demo".to_string(), - }); - config.ngrok_token = Some("ngrok-token".to_string()); - config.last_share_port = Some(45678); - config.share_password = Some("last-password".to_string()); - config.dashscope_api_key = Some("dashscope-secret-key".to_string()); - config.commit_ai_api_key = Some("commit-secret-key".to_string()); - config.commit_ai_enabled = false; - config.dashscope_base_url = Some("wss://example.test/ws".to_string()); - config.voice_refine_enabled = false; - config.voice_refine_base_url = Some("https://example.test/v1".to_string()); - config.voice_asr_model = Some("asr-model".to_string()); - config.voice_refine_model = Some("refine-model".to_string()); - config.commit_prefix_templates = vec!["feat({{worktree-name}}):".to_string()]; - config.commit_prefix_enabled = false; - config.default_prefix_index = 0; - config.git_user_name = Some("Ada".to_string()); - config.git_user_email = Some("ada@example.test".to_string()); - config.skip_git_hooks = true; - config.shell_integration_enabled = false; - config.custom_mirrors.push(crate::types::CustomMirror { - name: "custom".to_string(), - url: "https://mirror.example/".to_string(), - }); - cache_global_config(config); - - let (status, workspaces) = json_response(h_list_workspaces().await).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(workspaces[0]["name"], "Demo"); - - let assertions = [ - (h_get_ngrok_token().await, json!("ngrok-token")), - (h_get_last_share_port().await, json!(45678)), - (h_get_last_share_password().await, json!("last-password")), - (h_get_dashscope_api_key().await, json!("dash...-key")), - (h_get_commit_ai_api_key().await, json!("comm...-key")), - (h_check_dashscope_api_key().await, json!(true)), - (h_check_commit_ai_api_key().await, json!(true)), - (h_get_commit_ai_enabled().await, json!(false)), - ( - h_get_dashscope_base_url().await, - json!("wss://example.test/ws"), - ), - (h_get_voice_refine_enabled().await, json!(false)), - ( - h_get_voice_refine_base_url().await, - json!("https://example.test/v1"), - ), - (h_get_voice_asr_model().await, json!("asr-model")), - (h_get_voice_refine_model().await, json!("refine-model")), - (h_get_skip_git_hooks().await, json!(true)), - (h_get_shell_integration_enabled().await, json!(false)), - ]; - for (response, expected) in assertions { - let (status, body) = json_response(response).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(body, expected); - } - - let (status, prefix) = json_response(h_get_commit_prefix_config().await).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(prefix["enabled"], false); - assert_eq!(prefix["templates"][0], "feat({{worktree-name}}):"); - - let (status, git_user) = json_response(h_get_git_user_global_config().await).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(git_user["name"], "Ada"); - - let (status, mirrors) = json_response(h_get_mirror_sources().await).await; - assert_eq!(status, StatusCode::OK); - assert!(mirrors - .as_array() - .unwrap() - .iter() - .any(|m| m["name"] == "custom" && m["builtin"] == false)); - } - - #[serial] - #[tokio::test] - async fn git_pty_voice_cloud_handlers_cover_fast_error_paths() { - let _serial = lock_coverage_tests(); - let _guard = GlobalStateGuard::new(); - let temp = TempDir::new().unwrap(); - let path = temp.path().to_string_lossy().to_string(); - - let git_errors = [ - h_get_changed_files(Json(json!({"path": path}))).await, - h_check_remote_branch_exists(Json(json!({"path": path, "branchName": "main"}))).await, - h_fetch_project_remote(Json(json!({"path": path}))).await, - h_sync_with_base_branch(Json(json!({"path": path, "baseBranch": "main"}))).await, - h_push_to_remote(Json(json!({"path": path}))).await, - h_pull_current_branch(Json(json!({"path": path}))).await, - h_merge_to_test_branch(Json(json!({"path": path, "testBranch": "test"}))).await, - h_merge_to_base_branch(Json(json!({"path": path, "baseBranch": "main"}))).await, - h_create_pull_request(Json(json!({ - "path": path, - "baseBranch": "main", - "title": "PR", - "body": "body" - }))) - .await, - h_get_remote_branches(Json(json!({"path": path}))).await, - h_get_git_diff(Json(json!({"path": path}))).await, - h_commit_all(Json(json!({"path": path, "message": "test: commit"}))).await, - ]; - for response in git_errors { - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - } - - let (status, stats) = - json_response(h_get_branch_diff_stats(Json(json!({"path": path}))).await).await; - assert_eq!(status, StatusCode::OK); - assert!(stats.is_object()); - - let (status, diff) = json_response( - h_get_file_diff(Json(json!({"path": path, "filePath": "missing.rs"}))).await, - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(diff["file_path"], "missing.rs"); - assert_eq!(diff["is_new"], true); - - assert_eq!( - h_pty_write(Json(json!({"sessionId": "missing", "data": "x"}))) - .await - .status(), - StatusCode::BAD_REQUEST - ); - assert_eq!( - h_pty_resize(Json( - json!({"sessionId": "missing", "cols": 90, "rows": 25}) - )) - .await - .status(), - StatusCode::BAD_REQUEST - ); - assert_eq!( - h_pty_close(Json(json!({"sessionId": "missing"}))) - .await - .status(), - StatusCode::NO_CONTENT - ); - let (status, exists) = - json_response(h_pty_exists(Json(json!({"sessionId": "missing"}))).await).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(exists, json!(false)); - - assert_text_contains( - h_voice_start(Json(json!({"sampleRate": 8000}))).await, - StatusCode::BAD_REQUEST, - "Dashscope API Key", - ) - .await; - assert_text_contains( - h_voice_send_audio(Json(json!({"data": "not base64"}))).await, - StatusCode::BAD_REQUEST, - "Invalid", - ) - .await; - assert_eq!(h_voice_stop().await.status(), StatusCode::NO_CONTENT); - let (status, active) = json_response(h_voice_is_active().await).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(active, json!(false)); - let (status, refined) = - json_response(h_voice_refine_text(Json(json!({"text": " "}))).await).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(refined, json!("")); - assert_text_contains( - h_list_dashscope_models().await, - StatusCode::BAD_REQUEST, - "Dashscope API Key", - ) - .await; - let (status, cloud_status) = json_response(h_cloud_get_status().await).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(cloud_status["connected"], false); - } - - #[serial] - #[tokio::test] - async fn misc_handlers_cover_no_app_handle_and_simple_success_paths() { - let _serial = lock_coverage_tests(); - let _guard = GlobalStateGuard::new(); - - let (status, terminal_state) = json_response( - h_get_terminal_state(Json(json!({ - "workspacePath": "/tmp/ws-a", - "worktreeName": "feature-a" - }))) - .await, - ) - .await; - assert_eq!(status, StatusCode::OK); - assert!( - terminal_state["terminal_visible"].is_null() - || terminal_state["terminal_visible"] == false - ); - - let (status, window_url) = json_response( - h_open_workspace_window(Json(json!({"workspacePath": "/tmp/ws with spaces"}))).await, - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(window_url, json!("/?workspace=%2Ftmp%2Fws%20with%20spaces")); - - let (status, version) = json_response(h_get_app_version().await).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(version, json!(env!("CARGO_PKG_VERSION"))); - - assert_text_contains( - h_download_update_via_mirror(Json(json!({"mirrorUrl": "https://mirror.invalid/"}))) - .await, - StatusCode::INTERNAL_SERVER_ERROR, - "App handle unavailable", - ) - .await; - assert_text_contains( - h_open_devtools().await, - StatusCode::INTERNAL_SERVER_ERROR, - "App handle unavailable", - ) - .await; - assert_text_contains( - h_broadcast_terminal_state(Json(json!({ - "workspacePath": "/tmp/ws-a", - "worktreeName": "feature-a", - "activatedTerminals": ["main"], - "activeTerminalTab": "main", - "terminalVisible": true, - "clientId": "client-a", - "sessionId": "pty-a" - }))) - .await, - StatusCode::INTERNAL_SERVER_ERROR, - "App handle unavailable", - ) - .await; - - assert_eq!( - h_frontend_log(Json(json!({"level": "info", "message": "coverage"}))) - .await - .status(), - StatusCode::NO_CONTENT - ); - assert_eq!( - h_set_git_path(Json(json!({"path": "/usr/bin/git"}))) - .await - .status(), - StatusCode::NO_CONTENT - ); - - let (status, icon) = - json_response(h_get_app_icon(Json(json!({"path": "/definitely/missing.app"}))).await) - .await; - assert_eq!(status, StatusCode::OK); - assert!(icon.is_null() || icon.is_string()); - - let (status, crash_report) = json_response(h_get_crash_report().await).await; - assert_eq!(status, StatusCode::OK); - assert!(crash_report.is_null() || crash_report.is_object()); - - assert_text_contains( - h_speed_test_single_mirror(Json(json!({"mirrorUrl": "https://missing.example/"}))) - .await, - StatusCode::BAD_REQUEST, - "Mirror not found", - ) - .await; - assert_text_contains( - h_check_mirror_update(Json(json!({"mirrorUrl": "not-a-valid-url://"}))).await, - StatusCode::BAD_REQUEST, - "Failed to fetch mirror manifest", - ) - .await; - } - - #[serial] - #[tokio::test] - async fn lock_connected_client_and_ngrok_handlers_cover_state_changes() { - let _serial = lock_coverage_tests(); - let _guard = GlobalStateGuard::new(); - let workspace_path = "/tmp/http-server-coverage-locks"; - let owner = auth_headers("coverage-owner"); - let other = auth_headers("coverage-other"); - - assert_eq!( - h_lock_worktree( - owner.clone(), - Json(json!({ - "workspacePath": workspace_path, - "worktreeName": "feature-a" - })), - ) - .await - .status(), - StatusCode::NO_CONTENT - ); - assert_text_contains( - h_lock_worktree( - other, - Json(json!({ - "workspacePath": workspace_path, - "worktreeName": "feature-a" - })), - ) - .await, - StatusCode::BAD_REQUEST, - "feature-a", - ) - .await; - - let (status, locks) = json_response( - h_get_locked_worktrees(Json(json!({"workspacePath": workspace_path}))).await, - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(locks["feature-a"], "coverage-owner"); - - assert_eq!( - h_unlock_worktree( - owner.clone(), - Json(json!({ - "workspacePath": workspace_path, - "worktreeName": "feature-a" - })), - ) - .await - .status(), - StatusCode::NO_CONTENT - ); - assert_eq!( - h_unregister_window(owner).await.status(), - StatusCode::NO_CONTENT - ); - - let session_id = "coverage-client"; - CONNECTED_CLIENTS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert( - session_id.to_string(), - crate::ConnectedClient { - session_id: session_id.to_string(), - ip: "127.0.0.1".to_string(), - user_agent: "coverage-test/1.0".to_string(), - authenticated_at: "2026-06-11T00:00:00Z".to_string(), - last_active: "2026-06-11T00:00:00Z".to_string(), - ws_connected: false, - }, - ); - AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert(session_id.to_string()); - - let (status, clients) = json_response(h_get_connected_clients().await).await; - assert_eq!(status, StatusCode::OK); - assert!(clients - .as_array() - .unwrap() - .iter() - .any(|client| client["session_id"] == session_id)); - assert_eq!( - h_kick_client(Json(json!({"sessionId": session_id}))) - .await - .status(), - StatusCode::NO_CONTENT - ); - assert!(!CONNECTED_CLIENTS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .contains_key(session_id)); - assert!(!AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .contains(session_id)); - - SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .ngrok_url = Some("https://coverage.ngrok-free.app".to_string()); - assert_eq!( - h_stop_ngrok_tunnel().await.status(), - StatusCode::NO_CONTENT, - "h_stop_ngrok_tunnel should stop a seeded ngrok task" - ); - assert!(SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .ngrok_url - .is_none()); - } - - #[serial] - #[tokio::test] - async fn vault_mcp_and_share_validation_handlers_return_expected_errors() { - let _serial = lock_coverage_tests(); - let _guard = GlobalStateGuard::new(); - let headers = auth_headers("coverage-vault"); - - assert_text_contains( - h_vault_status(headers.clone()).await, - StatusCode::BAD_REQUEST, - "No workspace bound to window", - ) - .await; - assert_text_contains( - h_vault_link( - headers, - Json(json!({"path": "/tmp/missing-vault", "keepSymlinks": true})), - ) - .await, - StatusCode::BAD_REQUEST, - "No workspace bound to window", - ) - .await; - assert_text_contains( - h_list_vault_item_children(Json(json!({ - "vaultPath": "/tmp/vault", - "relativePath": "../escape" - }))) - .await, - StatusCode::BAD_REQUEST, - "路径越界", - ) - .await; - assert_text_contains( - routing::h_set_mcp_capability(Json(json!({"capability_level": "invalid"}))).await, - StatusCode::BAD_REQUEST, - "Invalid capability level", - ) - .await; - assert_text_contains( - h_update_share_password(Json(json!({"password": "short"}))).await, - StatusCode::BAD_REQUEST, - "至少需要 8 位", - ) - .await; - } - - #[serial] - #[tokio::test] - async fn additional_project_handlers_cover_bound_workspace_error_paths() { - let _serial = lock_coverage_tests(); - let _guard = GlobalStateGuard::new(); - let temp = TempDir::new().unwrap(); - let workspace_path = temp.path().to_string_lossy().to_string(); - let headers = auth_headers("coverage-projects"); - let mut config = crate::types::GlobalConfig::default(); - config.workspaces.push(crate::types::WorkspaceRef { - name: "Coverage Projects".to_string(), - path: workspace_path.clone(), - }); - config.current_workspace = Some(workspace_path.clone()); - cache_global_config(config); - crate::WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert("coverage-projects".to_string(), workspace_path.clone()); - crate::save_workspace_config_internal(&workspace_path, &crate::WorkspaceConfig::default()) - .unwrap(); - - assert_eq!( - h_deploy_to_main(headers.clone(), Json(json!({"worktreeName": "missing"}))) - .await - .status(), - StatusCode::BAD_REQUEST - ); - assert_ne!( - h_add_existing_project( - headers.clone(), - Json(json!({ - "name": "missing-project", - "baseBranch": "main", - "testBranch": "test", - "mergeStrategy": "merge" - })), - ) - .await - .status(), - StatusCode::INTERNAL_SERVER_ERROR - ); - assert_ne!( - h_remove_project_from_config(headers.clone(), Json(json!({"name": "missing"}))) - .await - .status(), - StatusCode::INTERNAL_SERVER_ERROR - ); - let (status, sync_results) = json_response( - h_sync_all_projects_to_base( - headers.clone(), - Json(json!({"projectPaths": ["/definitely/missing"], "baseBranch": "main"})), - ) - .await, - ) - .await; - assert_eq!(status, StatusCode::OK); - assert!(sync_results.is_array()); - let (status, git_user_config) = json_response( - h_get_git_user_config(Json(json!({"path": "/definitely/missing"}))).await, - ) - .await; - assert_eq!(status, StatusCode::OK); - assert!(git_user_config.is_array() || git_user_config.is_object()); - assert_eq!( - h_set_git_user_config(Json(SetGitUserArgs { - path: "/definitely/missing".to_string(), - name: Some("Ada".to_string()), - email: Some("ada@example.test".to_string()), - })) - .await - .status(), - StatusCode::BAD_REQUEST - ); - - assert_eq!( - h_exit_main_occupation(headers, Json(json!({"force": true}))) - .await - .status(), - StatusCode::BAD_REQUEST - ); - crate::WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .remove("coverage-projects"); - } - - #[serial] - #[tokio::test] - async fn full_auth_flow_over_router_oneshot_unlocks_protected_routes() { - let _serial = lock_coverage_tests(); - let _guard = GlobalStateGuard::new(); - enable_auth(b"test-auth-key", b"test-auth-salt", None); - let addr = SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, 33112)); - - let challenge_response = request_with_addr( - addr, - Request::builder() - .method(Method::POST) - .uri("/api/auth/challenge") - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from("{}")) - .unwrap(), - ) - .await; - let (status, challenge) = json_response(challenge_response).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(challenge["salt"], json!(hex::encode(b"test-auth-salt"))); - - let nonce = challenge["nonce"].as_str().unwrap(); - let proof = proof_hex(b"test-auth-key", nonce); - let verify_response = request_with_addr( - addr, - Request::builder() - .method(Method::POST) - .uri("/api/auth/verify") - .header(header::CONTENT_TYPE, "application/json") - .header(header::USER_AGENT, "coverage-router/1.0") - .body(Body::from( - json!({ "nonce": nonce, "proof": proof }).to_string(), - )) - .unwrap(), - ) - .await; - let (status, verify) = json_response(verify_response).await; - assert_eq!(status, StatusCode::OK); - let session_id = verify["sessionId"].as_str().unwrap(); - assert!(AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .contains(session_id)); - - let protected = request_with_addr( - addr, - Request::builder() - .method(Method::POST) - .uri("/api/get_share_state") - .header(header::CONTENT_TYPE, "application/json") - .header("x-session-id", session_id) - .body(Body::from("{}")) - .unwrap(), - ) - .await; - let (status, body) = json_response(protected).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(body["active"], true); - - AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .remove(session_id); - CONNECTED_CLIENTS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .remove(session_id); - } - - #[serial] - #[tokio::test] - async fn websocket_upgrade_extractor_requires_real_upgrade_extension_under_oneshot() { - let _serial = lock_coverage_tests(); - let _guard = GlobalStateGuard::new(); - let response = request_with_addr( - SocketAddr::from(([127, 0, 0, 1], 34001)), - Request::builder() - .method(Method::GET) - .uri("/ws?session_id=s") - .header(header::CONNECTION, "upgrade") - .header(header::UPGRADE, "websocket") - .header(header::ORIGIN, "https://evil.example") - .header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==") - .header("sec-websocket-version", "13") - .body(Body::empty()) - .unwrap(), - ) - .await; - assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED); - } - - #[serial] - #[tokio::test] - async fn websocket_upgrade_over_duplex_covers_authenticated_message_flow() { - let _serial = lock_coverage_tests(); - let _guard = GlobalStateGuard::new(); - let session_id = "coverage-ws-session"; - let temp = TempDir::new().unwrap(); - let workspace_path = temp.path().to_string_lossy().to_string(); - let worktree_name = "feature-ws"; - *crate::state::GLOBAL_CONFIG_CACHE.lock().unwrap() = Some(crate::types::GlobalConfig { - workspaces: vec![crate::types::WorkspaceRef { - name: "WebSocket Workspace".to_string(), - path: workspace_path.clone(), - }], - current_workspace: Some(workspace_path.clone()), - ..crate::types::GlobalConfig::default() - }); - { - let mut share = SHARE_STATE.lock().unwrap(); - share.active = true; - share.workspace_path = Some(workspace_path.to_string()); - share.auth_key = Some(b"coverage-auth-key".to_vec()); - } - AUTHENTICATED_SESSIONS - .lock() - .unwrap() - .insert(session_id.to_string()); - CONNECTED_CLIENTS.lock().unwrap().insert( - session_id.to_string(), - crate::ConnectedClient { - session_id: session_id.to_string(), - ip: "127.0.0.1".to_string(), - user_agent: "duplex-websocket-test".to_string(), - authenticated_at: "2026-06-11T00:00:00Z".to_string(), - last_active: "2026-06-11T00:00:00Z".to_string(), - ws_connected: false, - }, - ); - crate::WORKTREE_LOCKS.lock().unwrap().insert( - (workspace_path.to_string(), worktree_name.to_string()), - "desktop".to_string(), - ); - crate::TERMINAL_STATES.lock().unwrap().insert( - (workspace_path.to_string(), worktree_name.to_string()), - crate::TerminalState { - activated_terminals: vec!["one".to_string()], - active_terminal_tab: Some("one".to_string()), - terminal_visible: true, - client_id: Some("desktop".to_string()), - session_id: Some("desktop-session".to_string()), - }, - ); - - let (client_io, server_io) = tokio::io::duplex(128 * 1024); - let service = create_router(None) - .into_make_service_with_connect_info::() - .oneshot(SocketAddr::from(([127, 0, 0, 1], 37001))) - .await - .unwrap(); - let server = tokio::spawn(async move { - let service = hyper::service::service_fn(move |request| { - let service = service.clone(); - async move { service.oneshot(request).await } - }); - hyper::server::conn::http1::Builder::new() - .serve_connection(hyper_util::rt::TokioIo::new(server_io), service) - .with_upgrades() - .await - .unwrap(); - }); - - let mut request = format!("ws://localhost/ws?session_id={session_id}") - .into_client_request() - .unwrap(); - request.headers_mut().insert( - header::ORIGIN, - axum::http::HeaderValue::from_static("http://localhost:3000"), - ); - let (mut socket, response) = tokio_tungstenite::client_async(request, client_io) - .await - .unwrap(); - assert_eq!( - response.status().as_u16(), - StatusCode::SWITCHING_PROTOCOLS.as_u16() - ); - tokio::time::timeout(Duration::from_secs(10), async { - loop { - let connected = CONNECTED_CLIENTS - .lock() - .unwrap() - .get(session_id) - .map(|client| client.ws_connected) - .unwrap_or(false); - if connected { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("websocket should be marked connected"); - tokio::time::timeout(Duration::from_secs(10), async { - loop { - if crate::config::get_window_workspace_path(session_id) - == Some(workspace_path.to_string()) - { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("websocket should bind session to shared workspace"); - assert_eq!( - crate::config::get_window_workspace_path(session_id), - Some(workspace_path.to_string()) - ); - - socket - .send(WsClientMessage::text( - json!({"type": "subscribe_locks", "workspacePath": workspace_path}).to_string(), - )) - .await - .unwrap(); - let lock_update = next_ws_json(&mut socket, "lock_update").await; - assert_eq!(lock_update["locks"][worktree_name], "desktop"); - let _ = LOCK_BROADCAST.send( - json!({ - "workspacePath": workspace_path, - "locks": { worktree_name: "web-client" } - }) - .to_string(), - ); - let lock_update = next_ws_json(&mut socket, "lock_update").await; - assert_eq!(lock_update["locks"][worktree_name], "web-client"); - - socket - .send(WsClientMessage::Binary(vec![1, 2, 3].into())) - .await - .unwrap(); - socket - .send(WsClientMessage::text("{not-json")) - .await - .unwrap(); - socket - .send(WsClientMessage::text( - json!({"type": "unknown"}).to_string(), - )) - .await - .unwrap(); - socket - .send(WsClientMessage::text( - json!({"type": "pty_subscribe"}).to_string(), - )) - .await - .unwrap(); - socket - .send(WsClientMessage::text( - json!({"type": "pty_subscribe", "sessionId": "missing-pty"}).to_string(), - )) - .await - .unwrap(); - socket - .send(WsClientMessage::text( - json!({"type": "pty_unsubscribe"}).to_string(), - )) - .await - .unwrap(); - socket - .send(WsClientMessage::text( - json!({"type": "pty_write", "sessionId": "missing-pty"}).to_string(), - )) - .await - .unwrap(); - socket - .send(WsClientMessage::text( - json!({"type": "pty_write", "sessionId": "missing-pty", "data": "echo hi\n"}) - .to_string(), - )) - .await - .unwrap(); - socket - .send(WsClientMessage::text( - json!({"type": "pty_resize"}).to_string(), - )) - .await - .unwrap(); - socket - .send(WsClientMessage::text( - json!({"type": "pty_resize", "sessionId": "missing-pty", "cols": 100, "rows": 40}) - .to_string(), - )) - .await - .unwrap(); - - socket - .send(WsClientMessage::text( - json!({"type": "subscribe_locks"}).to_string(), - )) - .await - .unwrap(); - - socket - .send(WsClientMessage::text( - json!({"type": "subscribe_terminal_state", "workspacePath": workspace_path}) - .to_string(), - )) - .await - .unwrap(); - socket - .send(WsClientMessage::text( - json!({ - "type": "subscribe_terminal_state", - "workspacePath": workspace_path, - "worktreeName": worktree_name - }) - .to_string(), - )) - .await - .unwrap(); - let terminal_update = next_ws_json(&mut socket, "terminal_state_update").await; - assert_eq!(terminal_update["workspacePath"], workspace_path); - assert_eq!(terminal_update["worktreeName"], worktree_name); - assert_eq!(terminal_update["activeTerminalTab"], "one"); - let _ = TERMINAL_STATE_BROADCAST.send( - json!({ - "workspacePath": workspace_path, - "worktreeName": worktree_name, - "activatedTerminals": ["two"], - "activeTerminalTab": "two", - "terminalVisible": false, - "clientId": "browser" - }) - .to_string(), - ); - let terminal_update = next_ws_json(&mut socket, "terminal_state_update").await; - assert_eq!(terminal_update["activeTerminalTab"], "two"); - assert_eq!(terminal_update["terminalVisible"], false); - - socket - .send(WsClientMessage::text( - json!({ - "type": "broadcast_terminal_state", - "workspacePath": workspace_path, - "worktreeName": worktree_name, - "activatedTerminals": ["three"], - "activeTerminalTab": "three", - "terminalVisible": true, - "clientId": "browser", - "sessionId": session_id - }) - .to_string(), - )) - .await - .unwrap(); - tokio::time::timeout(Duration::from_secs(2), async { - loop { - let state = crate::TERMINAL_STATES - .lock() - .unwrap() - .get(&(workspace_path.to_string(), worktree_name.to_string())) - .cloned(); - if state - .as_ref() - .and_then(|state| state.active_terminal_tab.as_deref()) - == Some("three") - { - assert_eq!(state.unwrap().client_id.as_deref(), Some("browser")); - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("broadcast_terminal_state should update cache"); - let terminal_update = next_ws_json(&mut socket, "terminal_state_update").await; - assert_eq!(terminal_update["activeTerminalTab"], "three"); - assert_eq!(terminal_update["clientId"], "browser"); - - socket - .send(WsClientMessage::text( - json!({"type": "subscribe_voice_events"}).to_string(), - )) - .await - .unwrap(); - let voice_event_sender = tokio::spawn(async { - let payload = - json!({"event": "recording-started", "payload": {"level": 7}}).to_string(); - for _ in 0..40 { - let _ = crate::state::VOICE_BROADCAST.send(payload.clone()); - tokio::time::sleep(Duration::from_millis(25)).await; - } - }); - let voice_event = next_ws_json(&mut socket, "voice_event").await; - voice_event_sender.abort(); - assert_eq!(voice_event["event"], "recording-started"); - assert_eq!(voice_event["payload"]["level"], 7); - - let _ = crate::state::CLIENT_NOTIFICATION_BROADCAST.send( - json!({"session_id": session_id, "type": "kicked", "reason": "coverage"}).to_string(), - ); - let kicked = next_ws_json(&mut socket, "kicked").await; - assert_eq!(kicked["reason"], "coverage"); - let close = tokio::time::timeout(Duration::from_secs(2), socket.next()) - .await - .expect("websocket should close after kick"); - assert!(matches!(close, Some(Ok(WsClientMessage::Close(_))) | None)); - - drop(socket); - server.abort(); - match server.await { - Ok(()) => {} - Err(err) => assert!(err.is_cancelled()), - } - } - - #[serial] - #[tokio::test] - async fn cors_security_headers_and_certificate_route_are_applied_by_router() { - let _serial = lock_coverage_tests(); - let _guard = GlobalStateGuard::new(); - - let cert_response = request_with_addr( - SocketAddr::from(([127, 0, 0, 1], 35001)), - Request::builder() - .method(Method::GET) - .uri("/api/cert.pem") - .body(Body::empty()) - .unwrap(), - ) - .await; - assert_eq!(cert_response.status(), StatusCode::OK); - assert_eq!( - cert_response.headers()[header::CONTENT_TYPE], - "application/x-pem-file" - ); - assert_eq!(cert_response.headers()["x-content-type-options"], "nosniff"); - - let preflight = request_with_addr( - SocketAddr::from(([127, 0, 0, 1], 35002)), - Request::builder() - .method(Method::OPTIONS) - .uri("/api/get_share_info") - .header(header::ORIGIN, "http://localhost:3000") - .header(header::ACCESS_CONTROL_REQUEST_METHOD, "GET") - .body(Body::empty()) - .unwrap(), - ) - .await; - assert!(preflight.status().is_success()); - assert_eq!( - preflight.headers()[header::ACCESS_CONTROL_ALLOW_ORIGIN], - "http://localhost:3000" - ); - } - - #[serial] - #[tokio::test] - async fn websocket_upgrade_extractor_failure_is_bounded_without_local_bind() { - let _serial = lock_coverage_tests(); - let _guard = GlobalStateGuard::new(); - // TODO: Real WebSocket upgrade coverage is skipped because - // TcpListener::bind("127.0.0.1:0") returns PermissionDenied in this - // sandbox, and axum's WebSocketUpgrade needs Hyper's private upgrade - // extension before h_ws_upgrade can run. - let response = request_with_addr( - SocketAddr::from(([127, 0, 0, 1], 36001)), - Request::builder() - .method(Method::GET) - .uri("/ws?session_id=ws-coverage-session") - .header(header::CONNECTION, "upgrade") - .header(header::UPGRADE, "websocket") - .header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==") - .header("sec-websocket-version", "13") - .body(Body::empty()) - .unwrap(), - ) - .await; - - assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED); - } - - #[serial] - #[tokio::test] - async fn temp_config_setters_workspace_and_mcp_paths_cover_success_shapes() { - let _serial = lock_coverage_tests(); - let mut config = crate::types::GlobalConfig::default(); - config.commit_prefix_templates = vec!["old:".to_string()]; - let guard = TempConfigRootGuard::new(config); - let workspace = TempDir::new().unwrap(); - let workspace_path = workspace.path().join("workspace-a"); - std::fs::create_dir_all(&workspace_path).unwrap(); - let workspace_path = workspace_path.to_string_lossy().to_string(); - - assert_eq!( - h_add_workspace(Json(AddWsArgs { - name: "Workspace A".to_string(), - path: workspace_path.clone(), - })) - .await - .status(), - StatusCode::NO_CONTENT - ); - let (status, workspaces) = json_response(h_list_workspaces().await).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(workspaces[0]["name"], "Workspace A"); - assert_eq!( - h_remove_workspace(Json(PathArgs { - path: workspace_path.clone(), - })) - .await - .status(), - StatusCode::NO_CONTENT - ); - - let created_path = workspace.path().join("created-workspace"); - assert_eq!( - h_create_workspace(Json(AddWsArgs { - name: "Created".to_string(), - path: created_path.to_string_lossy().to_string(), - })) - .await - .status(), - StatusCode::NO_CONTENT - ); - assert!(created_path.join(".worktree-manager.json").exists()); - - assert_eq!( - h_set_commit_prefix_config(Json(SetPrefixArgs { - templates: vec![ - "feat:".to_string(), - "fix:".to_string(), - "docs:".to_string(), - "ignored:".to_string(), - ], - enabled: true, - default_index: 1, - })) - .await - .status(), - StatusCode::NO_CONTENT - ); - let (status, prefix) = json_response(h_get_commit_prefix_config().await).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(prefix["templates"].as_array().unwrap().len(), 3); - assert_eq!(prefix["default_index"], 1); - - assert_eq!( - h_set_git_user_global_config(Json(SetGitUserGlobalArgs { - name: Some("Grace".to_string()), - email: Some("grace@example.test".to_string()), - })) - .await - .status(), - StatusCode::NO_CONTENT - ); - let (status, git_user) = json_response(h_get_git_user_global_config().await).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(git_user["name"], "Grace"); - assert_eq!(git_user["email"], "grace@example.test"); - - assert_eq!( - h_set_skip_git_hooks(Json(SetSkipGitHooksArgs { skip: true })) - .await - .status(), - StatusCode::NO_CONTENT - ); - assert_eq!( - h_set_shell_integration_enabled(Json(SetShellIntegrationEnabledArgs { enabled: true })) - .await - .status(), - StatusCode::NO_CONTENT - ); - assert_eq!( - json_response(h_get_skip_git_hooks().await).await.1, - json!(true) - ); - assert_eq!( - json_response(h_get_shell_integration_enabled().await) - .await - .1, - json!(true) - ); - - assert_eq!( - h_set_ngrok_token(Json(json!({"token": "ngrok-temp-token"}))) - .await - .status(), - StatusCode::NO_CONTENT - ); - assert_eq!( - json_response(h_get_ngrok_token().await).await.1, - json!("ngrok-temp-token") - ); - - assert_eq!( - h_set_commit_ai_api_key(Json(json!({"key": "commit-ai-temp-key"}))) - .await - .status(), - StatusCode::OK - ); - assert_eq!( - h_set_commit_ai_enabled(Json(json!({"enabled": true}))) - .await - .status(), - StatusCode::OK - ); - assert_eq!( - json_response(h_get_commit_ai_enabled().await).await.1, - json!(true) - ); - let (status, commit_key) = json_response(h_get_commit_ai_api_key().await).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(commit_key, json!("comm...-key")); - assert_eq!( - h_generate_commit_message(Json(json!({"diff": ""}))) - .await - .status(), - StatusCode::BAD_REQUEST - ); - - assert_eq!( - h_set_dashscope_api_key(Json(json!({"key": "dashscope-temp-key"}))) - .await - .status(), - StatusCode::NO_CONTENT - ); - assert_eq!( - h_set_dashscope_base_url(Json(json!({"url": "wss://dash.example/ws"}))) - .await - .status(), - StatusCode::NO_CONTENT - ); - assert_eq!( - h_set_voice_refine_enabled(Json(json!({"enabled": true}))) - .await - .status(), - StatusCode::NO_CONTENT - ); - assert_eq!( - h_set_voice_refine_base_url(Json(json!({"url": "https://voice.example/v1"}))) - .await - .status(), - StatusCode::NO_CONTENT - ); - assert_eq!( - h_set_voice_asr_model(Json(json!({"model": "asr-temp"}))) - .await - .status(), - StatusCode::NO_CONTENT - ); - assert_eq!( - h_set_voice_refine_model(Json(json!({"model": "refine-temp"}))) - .await - .status(), - StatusCode::NO_CONTENT - ); - assert_eq!( - json_response(h_get_dashscope_base_url().await).await.1, - json!("wss://dash.example/ws") - ); - assert_eq!( - json_response(h_get_voice_refine_enabled().await).await.1, - json!(true) - ); - assert_eq!( - json_response(h_get_voice_refine_base_url().await).await.1, - json!("https://voice.example/v1") - ); - assert_eq!( - json_response(h_get_voice_asr_model().await).await.1, - json!("asr-temp") - ); - assert_eq!( - json_response(h_get_voice_refine_model().await).await.1, - json!("refine-temp") - ); - - let mcp = McpConfig { - version: "1.2.3".to_string(), - http_port: 49152, - installed_at: "2026-06-11T00:00:00Z".to_string(), - capability_level: "details".to_string(), - }; - save_mcp_config(&mcp).unwrap(); - assert!(guard.mcp_config_path().exists()); - let loaded = load_mcp_config().unwrap(); - assert_eq!(loaded.http_port, 49152); - assert_eq!(loaded.capability_level, "details"); - let (status, mcp_body) = json_response(routing::h_mcp_config().await).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(mcp_body["version"], "1.2.3"); - let (status, capability) = json_response( - routing::h_set_mcp_capability(Json(json!({"capability_level": "advanced"}))).await, - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(capability["success"], true); - assert_eq!(load_mcp_config().unwrap().capability_level, "advanced"); - } - - #[serial] - #[tokio::test] - async fn additional_valid_payload_wrappers_cover_fast_local_error_paths() { - let _serial = lock_coverage_tests(); - let _guard = GlobalStateGuard::new(); - let temp = TempDir::new().unwrap(); - let workspace_path = temp.path().to_string_lossy().to_string(); - let headers = auth_headers("valid-payload-session"); - - assert_eq!( - h_switch_workspace(headers.clone(), Json(json!({"path": workspace_path}))) - .await - .status(), - StatusCode::BAD_REQUEST - ); - assert_eq!( - h_update_worktree_color( - headers.clone(), - Json(json!({"worktreeName": "feature-a", "color": "blue"})), - ) - .await - .status(), - StatusCode::BAD_REQUEST - ); - assert_eq!( - h_update_worktree_color( - headers.clone(), - Json(json!({"worktreeName": "feature-a", "color": null})), - ) - .await - .status(), - StatusCode::BAD_REQUEST - ); - assert_eq!( - h_create_worktree( - headers.clone(), - Json(json!({"request": {"name": "feature-a", "projects": []}})), - ) - .await - .status(), - StatusCode::BAD_REQUEST - ); - assert_eq!( - h_add_project_to_worktree( - headers.clone(), - Json(json!({ - "request": { - "worktreeName": "feature-a", - "projectName": "project-a", - "baseBranch": "main" - } - })), - ) - .await - .status(), - StatusCode::BAD_REQUEST - ); - assert_eq!( - h_clone_project( - headers.clone(), - Json(json!({ - "request": { - "name": "project-a", - "repoUrl": "not-a-valid-url", - "baseBranch": "main", - "testBranch": "test", - "mergeStrategy": "merge", - "linkedFolders": [] - } - })), - ) - .await - .status(), - StatusCode::BAD_REQUEST - ); - assert_eq!( - h_switch_branch(Json(json!({ - "request": { - "projectPath": temp.path().join("missing").to_string_lossy(), - "branch": "main" - } - }))) - .await - .status(), - StatusCode::BAD_REQUEST - ); - assert_eq!( - h_terminate_worktree_locking_process( - headers, - Json(json!({ - "name": "feature-a", - "pid": 1, - "processStartTime": "2026-06-11T00:00:00Z" - })), - ) - .await - .status(), - StatusCode::BAD_REQUEST - ); - - let (status, folders) = - json_response(h_scan_linked_folders(Json(json!({"projectPath": temp.path()}))).await) - .await; - assert_eq!(status, StatusCode::OK); - assert!(folders.is_array()); - - let editor_response = h_open_in_editor(Json(json!({ - "request": { - "editor": "cursor", - "path": workspace_path - }, - "customPath": temp.path().join("missing-editor").to_string_lossy() - }))) - .await; - assert_eq!(editor_response.status(), StatusCode::BAD_REQUEST); - - let (status, tools) = json_response(h_detect_tools().await).await; - assert_eq!(status, StatusCode::OK); - assert!(tools["git"].is_array()); - } - - #[serial] - #[tokio::test] - async fn share_pty_ngrok_and_cloud_handlers_cover_additional_state_branches() { - let _serial = lock_coverage_tests(); - let _guard = GlobalStateGuard::new(); - let temp = TempDir::new().unwrap(); - let workspace_path = temp.path().to_string_lossy().to_string(); - - let (status, inactive_share) = json_response(h_get_share_info().await).await; - assert_eq!(status, StatusCode::OK); - assert!(inactive_share["workspace_path"].is_null()); - assert_text_contains( - h_start_sharing( - auth_headers("no-workspace"), - Json(json!({"port": 0, "password": "abcdefgh"})), - ) - .await, - StatusCode::BAD_REQUEST, - "No workspace selected", - ) - .await; - - crate::save_workspace_config_internal( - &workspace_path, - &crate::WorkspaceConfig { - name: "Shared Workspace".to_string(), - ..crate::WorkspaceConfig::default() - }, - ) - .unwrap(); - { - let mut share = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - share.active = true; - share.workspace_path = Some(workspace_path.clone()); - } - crate::WORKTREE_LOCKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert( - (workspace_path.clone(), "feature-a".to_string()), - "desktop".to_string(), - ); - let (status, active_share) = json_response(h_get_share_info().await).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(active_share["workspace_name"], "Shared Workspace"); - assert_eq!(active_share["current_worktree"], "feature-a"); - SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .active = false; - assert_eq!( - h_stop_sharing().await.status(), - StatusCode::BAD_REQUEST, - "h_stop_sharing should reject inactive share state" - ); - assert_eq!( - h_start_ngrok_tunnel().await.status(), - StatusCode::BAD_REQUEST, - "h_start_ngrok_tunnel should reject inactive share state" - ); - SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .ngrok_task = Some(tokio::spawn(async { - tokio::time::sleep(Duration::from_secs(60)).await; - })); - assert_eq!(h_stop_ngrok_tunnel().await.status(), StatusCode::NO_CONTENT); - - let normalized_temp_path = - crate::normalize_path(&workspace_path).replace(['/', '\\', '#'], "-"); - let pty_session = format!("pty-{normalized_temp_path}-coverage"); - let create_status = h_pty_create(Json(json!({ - "sessionId": pty_session, - "cwd": temp.path(), - "cols": 80, - "rows": 24, - "shell": "sh" - }))) - .await - .status(); - assert!(matches!( - create_status, - StatusCode::NO_CONTENT | StatusCode::BAD_REQUEST - )); - if create_status == StatusCode::NO_CONTENT { - assert_eq!( - h_pty_create(Json(json!({ - "sessionId": pty_session, - "cwd": temp.path(), - "cols": 100, - "rows": 30, - "shell": "sh" - }))) - .await - .status(), - StatusCode::NO_CONTENT, - "h_pty_create should be idempotent for the same shell" - ); - let (status, exists) = - json_response(h_pty_exists(Json(json!({"sessionId": pty_session}))).await).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(exists, json!(true)); - let (status, output) = json_response( - h_pty_read(Json( - json!({"sessionId": pty_session, "clientId": "reader"}), - )) - .await, - ) - .await; - assert_eq!(status, StatusCode::OK); - assert!(output.is_string()); - let (status, closed) = - json_response(h_pty_close_by_path(Json(json!({"pathPrefix": temp.path()}))).await) - .await; - assert_eq!(status, StatusCode::OK); - assert!(closed - .as_array() - .unwrap() - .iter() - .any(|id| id == &pty_session)); - } - - let _cloud_config_root = TempConfigRootGuard::new(crate::types::GlobalConfig::default()); - let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://127.0.0.1:1"); - let _https_proxy_lower = EnvVarGuard::set("https_proxy", "http://127.0.0.1:1"); - let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://127.0.0.1:1"); - let _http_proxy_lower = EnvVarGuard::set("http_proxy", "http://127.0.0.1:1"); - let _all_proxy = EnvVarGuard::set("ALL_PROXY", "http://127.0.0.1:1"); - let _all_proxy_lower = EnvVarGuard::set("all_proxy", "http://127.0.0.1:1"); - let _no_proxy = EnvVarGuard::set("NO_PROXY", ""); - let _no_proxy_lower = EnvVarGuard::set("no_proxy", ""); - crate::commands::cloud::clear_pairing_state_for_test(); - assert_eq!( - h_cloud_start_pairing().await.status(), - StatusCode::BAD_REQUEST, - "h_cloud_start_pairing should fail through the test proxy" - ); - crate::commands::cloud::clear_pairing_state_for_test(); - let (status, cloud_status) = json_response(h_cloud_get_status().await).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(cloud_status["pairing"], false); - assert_text_contains( - h_cloud_check_pairing_status().await, - StatusCode::BAD_REQUEST, - "没有进行中的配对流程", - ) - .await; - assert_eq!( - h_cloud_approve_pairing().await.status(), - StatusCode::BAD_REQUEST, - "h_cloud_approve_pairing should reject missing pairing state" - ); - assert_text_contains( - h_cloud_reject_pairing().await, - StatusCode::BAD_REQUEST, - "没有进行中的配对流程", - ) - .await; - let (status, disconnect_error) = text_response(h_cloud_disconnect().await).await; - match status { - StatusCode::BAD_REQUEST => assert!(!disconnect_error.is_empty()), - StatusCode::NO_CONTENT => assert!(disconnect_error.is_empty()), - unexpected => panic!("unexpected cloud disconnect status: {}", unexpected), - } - } - - #[serial] - #[tokio::test] - async fn mcp_server_start_attempt_saves_config_and_never_hangs() { - let _serial = lock_coverage_tests(); - let guard = TempConfigRootGuard::new(crate::types::GlobalConfig::default()); - let handle = tokio::spawn(start_mcp_server(0)); - tokio::time::sleep(Duration::from_millis(50)).await; - if handle.is_finished() { - let result = handle.await.unwrap(); - assert!(result.unwrap_err().contains("Failed to bind MCP server")); - } else { - handle.abort(); - assert!(guard.mcp_config_path().exists()); - } - } - - #[serial] - #[tokio::test] - async fn start_server_bind_path_returns_or_shutdowns_without_hanging() { - let _serial = lock_coverage_tests(); - let _guard = GlobalStateGuard::new(); - let (tx, rx) = tokio::sync::watch::channel(false); - let handle = tokio::spawn(start_server(0, rx, None)); - tokio::time::sleep(Duration::from_millis(25)).await; - let _ = tx.send(true); - tokio::time::timeout(Duration::from_secs(2), handle) - .await - .expect("start_server should return after bind failure or shutdown") - .unwrap(); - } - - #[serial] - #[tokio::test] - async fn valid_request_wrappers_cover_deserialized_error_paths() { - let _serial = lock_coverage_tests(); - let _guard = GlobalStateGuard::new(); - let temp = TempDir::new().unwrap(); - let headers = auth_headers("valid-deserialize-session"); - let missing_project_path = temp.path().join("missing-project"); - - assert_eq!( - h_add_project_to_worktree( - headers.clone(), - Json(json!({ - "request": { - "worktree_name": "feature-a", - "project_name": "project-a", - "base_branch": "main" - } - })), - ) - .await - .status(), - StatusCode::BAD_REQUEST - ); - assert_eq!( - h_clone_project( - headers, - Json(json!({ - "request": { - "name": "project-a", - "repo_url": "not-a-valid-url", - "base_branch": "main", - "test_branch": "test", - "merge_strategy": "merge", - "linked_folders": [] - } - })), - ) - .await - .status(), - StatusCode::BAD_REQUEST - ); - assert_eq!( - h_switch_branch(Json(json!({ - "request": { - "project_path": missing_project_path, - "branch": "main" - } - }))) - .await - .status(), - StatusCode::BAD_REQUEST - ); - assert_eq!( - h_open_in_editor(Json(json!({ - "request": { - "editor": "custom", - "path": temp.path() - }, - "custom_path": temp.path().join("missing-editor") - }))) - .await - .status(), - StatusCode::BAD_REQUEST - ); - } - - #[serial] - #[tokio::test] - async fn auth_verify_rejects_when_auth_key_missing_after_challenge() { - let _serial = lock_coverage_tests(); - let _guard = GlobalStateGuard::new(); - let addr = SocketAddr::from(([127, 0, 0, 1], 33113)); - { - let mut share = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - share.active = true; - share.auth_salt = Some(b"salt-without-key".to_vec()); - share.auth_key = None; - } - - let (status, challenge) = json_response(h_auth_challenge(ConnectInfo(addr)).await).await; - assert_eq!(status, StatusCode::OK); - let nonce = challenge["nonce"].as_str().unwrap().to_string(); - - assert_text_contains( - h_auth_verify( - ConnectInfo(addr), - HeaderMap::new(), - Json(VerifyRequest { - nonce, - proof: "00".to_string(), - }), - ) - .await, - StatusCode::INTERNAL_SERVER_ERROR, - "No password configured", - ) - .await; - } - - #[serial] - #[tokio::test] - async fn optional_secret_and_ngrok_config_handlers_cover_none_and_clear_paths() { - let _serial = lock_coverage_tests(); - let _guard = TempConfigRootGuard::new(crate::types::GlobalConfig::default()); - - assert_eq!( - json_response(h_get_commit_ai_api_key().await).await.1, - Value::Null - ); - assert_eq!( - json_response(h_get_dashscope_api_key().await).await.1, - Value::Null - ); - assert_eq!( - h_set_ngrok_token(Json(json!({"token": ""}))).await.status(), - StatusCode::NO_CONTENT - ); - assert_eq!( - json_response(h_get_ngrok_token().await).await.1, - Value::Null - ); - } - - #[serial] - #[test] - fn mcp_config_loads_none_for_missing_or_invalid_files_and_reports_save_errors() { - let _serial = lock_coverage_tests(); - let guard = TempConfigRootGuard::new(crate::types::GlobalConfig::default()); - assert!(load_mcp_config().is_none()); - - std::fs::create_dir_all(guard.mcp_config_path().parent().unwrap()).unwrap(); - std::fs::write(guard.mcp_config_path(), "{not-json").unwrap(); - assert!(load_mcp_config().is_none()); - - let bad_home = TempDir::new().unwrap(); - let home_file = bad_home.path().join("home-file"); - std::fs::write(&home_file, "not a directory").unwrap(); - let _home = EnvVarGuard::set("HOME", home_file.to_str().unwrap()); - let config = McpConfig { - version: "1.0.0".to_string(), - http_port: 49200, - installed_at: "2026-06-11T00:00:00Z".to_string(), - capability_level: "core".to_string(), - }; - assert!(save_mcp_config(&config).is_err()); - } - - #[serial] - #[tokio::test] - async fn share_info_and_config_helpers_cover_null_and_default_branches() { - let _serial = lock_coverage_tests(); - let _guard = GlobalStateGuard::new(); - { - let mut share = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - share.active = true; - share.workspace_path = None; - } - - let (status, info) = json_response(h_get_share_info().await).await; - assert_eq!(status, StatusCode::OK); - assert!(info["workspace_path"].is_null()); - assert!(info["current_worktree"].is_null()); - assert_eq!(get_param_u64(&json!(null), "missing_number", 99), 99); - } - - // TODO: websocket/ngrok integration test skipped - // TODO: start_server bind-and-serve success branches are skipped because - // this sandbox rejects local TCP binds with PermissionDenied. The bind - // error path is exercised by the MCP startup test above. -} diff --git a/src-tauri/src/http_server/middleware.rs b/src-tauri/src/http_server/middleware.rs deleted file mode 100644 index 30558fe..0000000 --- a/src-tauri/src/http_server/middleware.rs +++ /dev/null @@ -1,190 +0,0 @@ -use axum::{ - extract::{ConnectInfo, Request}, - http::{HeaderMap, HeaderValue, StatusCode}, - middleware::Next, - response::{IntoResponse, Response}, -}; -use std::net::SocketAddr; - -use crate::{set_window_workspace_impl, AUTHENTICATED_SESSIONS, CONNECTED_CLIENTS, SHARE_STATE}; - -pub(super) fn is_loopback_request(addr: &SocketAddr) -> bool { - addr.ip().is_loopback() -} - -fn is_forwarded_remote_request(headers: &HeaderMap) -> bool { - headers.contains_key("x-forwarded-for") - || headers.contains_key("forwarded") - || headers.contains_key("x-real-ip") -} - -fn is_localhost_only_path(path: &str) -> bool { - matches!( - path, - "/api/get_config_path_info" - | "/api/load_workspace_config_by_path" - | "/api/save_workspace_config_by_path" - | "/api/vault_status" - | "/api/vault_link" - | "/api/list_vault_item_children" - | "/api/open_in_terminal" - | "/api/open_in_editor" - | "/api/reveal_in_finder" - | "/api/open_log_dir" - | "/api/detect_tools" - | "/api/get_crash_report" - | "/api/set_git_path" - | "/api/get_ngrok_token" - | "/api/set_ngrok_token" - | "/api/start_ngrok_tunnel" - | "/api/stop_ngrok_tunnel" - | "/api/get_last_share_password" - | "/api/get_dashscope_api_key" - | "/api/set_dashscope_api_key" - | "/api/get_dashscope_base_url" - | "/api/set_dashscope_base_url" - | "/api/download_update_via_mirror" - | "/api/test_mirror_speed" - | "/api/save_custom_mirrors" - | "/api/open_devtools" - | "/api/terminate_worktree_locking_process" - | "/api/frontend_log" - ) -} - -/// Extract the session ID from headers, falling back to `web-default`. -/// Auto-binds the session to the shared workspace if one is active. -pub(super) fn session_id(headers: &HeaderMap) -> String { - let sid = headers - .get("x-session-id") - .and_then(|v| v.to_str().ok()) - .unwrap_or("web-default") - .to_string(); - - if let Ok(share_state) = SHARE_STATE.lock() { - if let Some(ref ws_path) = share_state.workspace_path { - if share_state.active { - let _ = set_window_workspace_impl(&sid, ws_path.clone()); - } - } - } - - sid -} - -/// Middleware: block dangerous host-only operations from remote (non-localhost) clients. -pub(super) async fn localhost_only_middleware( - ConnectInfo(addr): ConnectInfo, - request: Request, - next: Next, -) -> Response { - let path = request.uri().path().to_string(); - - if is_localhost_only_path(path.as_str()) - && (!is_loopback_request(&addr) || is_forwarded_remote_request(request.headers())) - { - return ( - StatusCode::FORBIDDEN, - "This operation is only available from localhost", - ) - .into_response(); - } - - next.run(request).await -} - -/// Middleware: add security headers to all responses. -pub(super) async fn security_headers_middleware(request: Request, next: Next) -> Response { - let mut response = next.run(request).await; - let headers = response.headers_mut(); - headers.insert( - "x-content-type-options", - HeaderValue::from_static("nosniff"), - ); - headers.insert("x-frame-options", HeaderValue::from_static("DENY")); - headers.insert( - "x-xss-protection", - HeaderValue::from_static("1; mode=block"), - ); - headers.insert( - "referrer-policy", - HeaderValue::from_static("strict-origin-when-cross-origin"), - ); - headers.insert( - "permissions-policy", - HeaderValue::from_static("camera=(), geolocation=()"), - ); - response -} - -/// Middleware: check if the request is authenticated when password is set. -pub(super) async fn auth_middleware( - ConnectInfo(_addr): ConnectInfo, - headers: HeaderMap, - request: Request, - next: Next, -) -> Response { - let path = request.uri().path().to_string(); - - if !path.starts_with("/api/") - || path == "/api/auth/challenge" - || path == "/api/auth/verify" - || path == "/api/get_share_info" - || path == "/api/cert.pem" - || path == "/ws" - { - return next.run(request).await; - } - - let needs_auth = SHARE_STATE - .lock() - .map(|state| state.active && state.auth_key.is_some()) - .unwrap_or(false); - if !needs_auth { - return next.run(request).await; - } - - let sid = headers - .get("x-session-id") - .and_then(|v| v.to_str().ok()) - .unwrap_or("web-default") - .to_string(); - - let is_authenticated = AUTHENTICATED_SESSIONS - .lock() - .map(|sessions| sessions.contains(&sid)) - .unwrap_or(false); - - if is_authenticated { - if let Ok(mut clients) = CONNECTED_CLIENTS.lock() { - if let Some(client) = clients.get_mut(&sid) { - client.last_active = chrono::Utc::now().to_rfc3339(); - } - } - return next.run(request).await; - } - - (StatusCode::UNAUTHORIZED, "Authentication required").into_response() -} - -pub(super) async fn no_cache_html_middleware( - req: axum::http::Request, - next: axum::middleware::Next, -) -> axum::response::Response { - let mut resp = next.run(req).await; - let is_html = resp - .headers() - .get("content-type") - .and_then(|v| v.to_str().ok()) - .map(|ct| ct.contains("text/html")) - .unwrap_or(false); - if is_html { - let headers = resp.headers_mut(); - headers.insert( - "Cache-Control", - "no-cache, no-store, must-revalidate".parse().unwrap(), - ); - headers.insert("Pragma", "no-cache".parse().unwrap()); - } - resp -} diff --git a/src-tauri/src/http_server/routing.rs b/src-tauri/src/http_server/routing.rs deleted file mode 100644 index acba082..0000000 --- a/src-tauri/src/http_server/routing.rs +++ /dev/null @@ -1,394 +0,0 @@ -use axum::{ - http::{header, HeaderValue, Method}, - routing::{get, post}, - Extension, Router, -}; -use std::{path::PathBuf, sync::Arc}; -use tower_http::cors::CorsLayer; - -use super::{ - h_add_existing_project, h_add_project_to_worktree, h_add_workspace, h_archive_worktree, - h_auth_challenge, h_auth_verify, h_broadcast_terminal_state, h_cert_pem, - h_check_commit_ai_api_key, h_check_dashscope_api_key, h_check_mirror_update, - h_check_remote_branch_exists, h_check_worktree_status, h_clone_project, - h_cloud_approve_pairing, h_cloud_check_pairing_status, h_cloud_disconnect, h_cloud_get_status, - h_cloud_reject_pairing, h_cloud_start_pairing, h_commit_all, h_create_pull_request, - h_create_workspace, h_create_worktree, h_delete_archived_worktree, h_deploy_to_main, - h_detect_tools, h_download_update_via_mirror, h_exit_main_occupation, h_fetch_project_remote, - h_frontend_log, h_generate_commit_message, h_get_app_icon, h_get_app_version, - h_get_branch_diff_stats, h_get_changed_files, h_get_commit_ai_api_key, h_get_commit_ai_enabled, - h_get_commit_prefix_config, h_get_config_path_info, h_get_connected_clients, - h_get_crash_report, h_get_current_workspace, h_get_dashscope_api_key, h_get_dashscope_base_url, - h_get_file_diff, h_get_git_diff, h_get_git_user_config, h_get_git_user_global_config, - h_get_last_share_password, h_get_last_share_port, h_get_locked_worktrees, - h_get_main_occupation, h_get_main_workspace_status, h_get_mirror_sources, h_get_ngrok_token, - h_get_opened_workspaces, h_get_remote_branches, h_get_share_info, h_get_share_state, - h_get_shell_integration_enabled, h_get_skip_git_hooks, h_get_terminal_state, - h_get_voice_asr_model, h_get_voice_refine_base_url, h_get_voice_refine_enabled, - h_get_voice_refine_model, h_get_workspace_config, h_import_external_project, h_kick_client, - h_list_dashscope_models, h_list_vault_item_children, h_list_workspaces, h_list_worktrees, - h_load_workspace_config_by_path, h_lock_worktree, h_merge_to_base_branch, - h_merge_to_test_branch, h_open_devtools, h_open_in_editor, h_open_in_terminal, h_open_log_dir, - h_open_workspace_window, h_pty_close, h_pty_close_by_path, h_pty_create, h_pty_exists, - h_pty_read, h_pty_resize, h_pty_write, h_pull_current_branch, h_push_to_remote, - h_remove_project_from_config, h_remove_workspace, h_restore_worktree, h_reveal_in_finder, - h_save_custom_mirrors, h_save_workspace_config, h_save_workspace_config_by_path, - h_scan_existing_projects, h_scan_linked_folders, h_set_commit_ai_api_key, - h_set_commit_ai_enabled, h_set_commit_prefix_config, h_set_dashscope_api_key, - h_set_dashscope_base_url, h_set_git_path, h_set_git_user_config, h_set_git_user_global_config, - h_set_ngrok_token, h_set_shell_integration_enabled, h_set_skip_git_hooks, - h_set_voice_asr_model, h_set_voice_refine_base_url, h_set_voice_refine_enabled, - h_set_voice_refine_model, h_set_window_workspace, h_speed_test_single_mirror, - h_start_ngrok_tunnel, h_start_sharing, h_stop_ngrok_tunnel, h_stop_sharing, h_switch_branch, - h_switch_workspace, h_sync_all_projects_to_base, h_sync_with_base_branch, - h_terminate_worktree_locking_process, h_test_mirror_speed, h_unlock_worktree, - h_unregister_window, h_update_share_password, h_update_worktree_color, h_vault_link, - h_vault_status, h_voice_is_active, h_voice_refine_text, h_voice_send_audio, h_voice_start, - h_voice_stop, h_ws_upgrade, is_allowed_origin, load_mcp_config, save_mcp_config, McpConfig, -}; - -pub(super) fn build_cors_layer() -> CorsLayer { - CorsLayer::new() - .allow_origin(tower_http::cors::AllowOrigin::predicate( - |origin: &HeaderValue, _| origin.to_str().is_ok_and(is_allowed_origin), - )) - .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS]) - .allow_headers([ - header::CONTENT_TYPE, - header::HeaderName::from_static("x-session-id"), - ]) -} - -pub(super) fn resolve_dist_path() -> PathBuf { - if cfg!(debug_assertions) { - let dev_dist = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dist"); - log::info!("Using dev dist path: {:?}", dev_dist); - return dev_dist; - } - - std::env::current_exe() - .ok() - .and_then(|exe| { - let exe_dir = exe.parent()?; - if cfg!(target_os = "macos") { - if let Some(contents_dir) = exe_dir.parent() { - if contents_dir.file_name().and_then(|n| n.to_str()) == Some("Contents") { - let resources_dist = contents_dir.join("Resources").join("dist"); - if resources_dist.exists() { - log::info!("Using dist path from app bundle: {:?}", resources_dist); - return Some(resources_dist); - } - } - } - } - let exe_dist = exe_dir.join("dist"); - if exe_dist.exists() { - log::info!("Using dist path next to executable: {:?}", exe_dist); - return Some(exe_dist); - } - None - }) - .unwrap_or_else(|| { - let fallback = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dist"); - log::info!("Using fallback dist path: {:?}", fallback); - fallback - }) -} - -// MCP handlers -use axum::{ - http::StatusCode, - response::{IntoResponse, Response}, - Json, -}; -use serde_json::json; - -pub async fn h_mcp_config() -> Response { - match load_mcp_config() { - Some(config) => (StatusCode::OK, Json(json!(config))).into_response(), - None => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "MCP not configured"})), - ) - .into_response(), - } -} - -pub async fn h_set_mcp_capability(Json(payload): Json) -> Response { - let level = match payload.get("capability_level").and_then(|v| v.as_str()) { - Some(l) if matches!(l, "core" | "details" | "advanced") => l, - _ => return (StatusCode::BAD_REQUEST, "Invalid capability level").into_response(), - }; - - let mut config = load_mcp_config().unwrap_or(McpConfig { - version: env!("CARGO_PKG_VERSION").to_string(), - http_port: 42819, - installed_at: chrono::Utc::now().to_rfc3339(), - capability_level: "core".to_string(), - }); - - config.capability_level = level.to_string(); - - match save_mcp_config(&config) { - Ok(()) => (StatusCode::OK, Json(json!({"success": true}))).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), - } -} - -pub(super) fn build_api_router(cert_pem: Option) -> Router { - let mut router = Router::new() - .route("/api/list_workspaces", post(h_list_workspaces)) - .route("/api/add_workspace", post(h_add_workspace)) - .route("/api/remove_workspace", post(h_remove_workspace)) - .route("/api/create_workspace", post(h_create_workspace)) - .route("/api/set_window_workspace", post(h_set_window_workspace)) - .route("/api/get_current_workspace", post(h_get_current_workspace)) - .route("/api/switch_workspace", post(h_switch_workspace)) - .route("/api/get_workspace_config", post(h_get_workspace_config)) - .route("/api/save_workspace_config", post(h_save_workspace_config)) - .route( - "/api/load_workspace_config_by_path", - post(h_load_workspace_config_by_path), - ) - .route( - "/api/save_workspace_config_by_path", - post(h_save_workspace_config_by_path), - ) - .route("/api/get_config_path_info", post(h_get_config_path_info)) - .route("/api/list_worktrees", post(h_list_worktrees)) - .route("/api/update_worktree_color", post(h_update_worktree_color)) - .route( - "/api/get_main_workspace_status", - post(h_get_main_workspace_status), - ) - .route("/api/create_worktree", post(h_create_worktree)) - .route("/api/archive_worktree", post(h_archive_worktree)) - .route("/api/check_worktree_status", post(h_check_worktree_status)) - .route( - "/api/terminate_worktree_locking_process", - post(h_terminate_worktree_locking_process), - ) - .route("/api/restore_worktree", post(h_restore_worktree)) - .route( - "/api/delete_archived_worktree", - post(h_delete_archived_worktree), - ) - .route( - "/api/add_project_to_worktree", - post(h_add_project_to_worktree), - ) - .route("/api/deploy_to_main", post(h_deploy_to_main)) - .route("/api/exit_main_occupation", post(h_exit_main_occupation)) - .route("/api/get_main_occupation", post(h_get_main_occupation)) - .route("/api/switch_branch", post(h_switch_branch)) - .route("/api/clone_project", post(h_clone_project)) - .route( - "/api/scan_existing_projects", - post(h_scan_existing_projects), - ) - .route("/api/add_existing_project", post(h_add_existing_project)) - .route( - "/api/import_external_project", - post(h_import_external_project), - ) - .route( - "/api/remove_project_from_config", - post(h_remove_project_from_config), - ) - .route("/api/get_branch_diff_stats", post(h_get_branch_diff_stats)) - .route( - "/api/check_remote_branch_exists", - post(h_check_remote_branch_exists), - ) - .route("/api/fetch_project_remote", post(h_fetch_project_remote)) - .route("/api/sync_with_base_branch", post(h_sync_with_base_branch)) - .route( - "/api/sync_all_projects_to_base", - post(h_sync_all_projects_to_base), - ) - .route("/api/push_to_remote", post(h_push_to_remote)) - .route("/api/pull_current_branch", post(h_pull_current_branch)) - .route("/api/merge_to_test_branch", post(h_merge_to_test_branch)) - .route("/api/merge_to_base_branch", post(h_merge_to_base_branch)) - .route("/api/create_pull_request", post(h_create_pull_request)) - .route("/api/get_remote_branches", post(h_get_remote_branches)) - .route("/api/get_git_diff", post(h_get_git_diff)) - .route("/api/commit_all", post(h_commit_all)) - .route("/api/get_changed_files", post(h_get_changed_files)) - .route("/api/get_file_diff", post(h_get_file_diff)) - .route( - "/api/generate_commit_message", - post(h_generate_commit_message), - ) - .route("/api/scan_linked_folders", post(h_scan_linked_folders)) - .route("/api/open_in_terminal", post(h_open_in_terminal)) - .route("/api/open_in_editor", post(h_open_in_editor)) - .route("/api/reveal_in_finder", post(h_reveal_in_finder)) - .route("/api/open_log_dir", post(h_open_log_dir)) - .route("/api/detect_tools", post(h_detect_tools)) - .route("/api/get_crash_report", get(h_get_crash_report)) - .route("/api/frontend_log", post(h_frontend_log)) - .route("/api/set_git_path", post(h_set_git_path)) - .route("/api/get_opened_workspaces", post(h_get_opened_workspaces)) - .route("/api/unregister_window", post(h_unregister_window)) - .route("/api/lock_worktree", post(h_lock_worktree)) - .route("/api/unlock_worktree", post(h_unlock_worktree)) - .route("/api/get_locked_worktrees", post(h_get_locked_worktrees)) - .route( - "/api/broadcast_terminal_state", - post(h_broadcast_terminal_state), - ) - .route("/api/get_terminal_state", post(h_get_terminal_state)) - .route("/api/open_workspace_window", post(h_open_workspace_window)) - .route("/api/pty_create", post(h_pty_create)) - .route("/api/pty_write", post(h_pty_write)) - .route("/api/pty_read", post(h_pty_read)) - .route("/api/pty_resize", post(h_pty_resize)) - .route("/api/pty_close", post(h_pty_close)) - .route("/api/pty_exists", post(h_pty_exists)) - .route("/api/pty_close_by_path", post(h_pty_close_by_path)) - .route("/api/auth/challenge", post(h_auth_challenge)) - .route("/api/auth/verify", post(h_auth_verify)) - .route("/api/get_share_info", get(h_get_share_info)) - .route("/api/start_sharing", post(h_start_sharing)) - .route("/api/stop_sharing", post(h_stop_sharing)) - .route("/api/get_share_state", post(h_get_share_state)) - .route("/api/update_share_password", post(h_update_share_password)) - .route("/api/get_connected_clients", post(h_get_connected_clients)) - .route("/api/kick_client", post(h_kick_client)) - .route("/api/get_ngrok_token", post(h_get_ngrok_token)) - .route("/api/set_ngrok_token", post(h_set_ngrok_token)) - .route("/api/get_last_share_port", post(h_get_last_share_port)) - .route( - "/api/get_last_share_password", - post(h_get_last_share_password), - ) - .route("/api/start_ngrok_tunnel", post(h_start_ngrok_tunnel)) - .route("/api/stop_ngrok_tunnel", post(h_stop_ngrok_tunnel)) - .route("/api/voice_start", post(h_voice_start)) - .route("/api/voice_send_audio", post(h_voice_send_audio)) - .route("/api/voice_stop", post(h_voice_stop)) - .route("/api/voice_is_active", post(h_voice_is_active)) - .route("/api/voice_refine_text", post(h_voice_refine_text)) - .route("/api/get_dashscope_api_key", post(h_get_dashscope_api_key)) - .route("/api/set_dashscope_api_key", post(h_set_dashscope_api_key)) - .route( - "/api/get_dashscope_base_url", - post(h_get_dashscope_base_url), - ) - .route( - "/api/set_dashscope_base_url", - post(h_set_dashscope_base_url), - ) - .route( - "/api/get_voice_refine_enabled", - post(h_get_voice_refine_enabled), - ) - .route( - "/api/set_voice_refine_enabled", - post(h_set_voice_refine_enabled), - ) - .route( - "/api/get_voice_refine_base_url", - post(h_get_voice_refine_base_url), - ) - .route( - "/api/set_voice_refine_base_url", - post(h_set_voice_refine_base_url), - ) - .route("/api/get_voice_asr_model", post(h_get_voice_asr_model)) - .route("/api/set_voice_asr_model", post(h_set_voice_asr_model)) - .route( - "/api/get_voice_refine_model", - post(h_get_voice_refine_model), - ) - .route( - "/api/set_voice_refine_model", - post(h_set_voice_refine_model), - ) - .route("/api/list_dashscope_models", post(h_list_dashscope_models)) - .route( - "/api/check_dashscope_api_key", - post(h_check_dashscope_api_key), - ) - // Commit AI key - .route("/api/get_commit_ai_api_key", post(h_get_commit_ai_api_key)) - .route("/api/set_commit_ai_api_key", post(h_set_commit_ai_api_key)) - .route("/api/set_commit_ai_enabled", post(h_set_commit_ai_enabled)) - .route("/api/get_commit_ai_enabled", post(h_get_commit_ai_enabled)) - .route( - "/api/check_commit_ai_api_key", - post(h_check_commit_ai_api_key), - ) - .route( - "/api/get_commit_prefix_config", - post(h_get_commit_prefix_config), - ) - .route( - "/api/set_commit_prefix_config", - post(h_set_commit_prefix_config), - ) - .route( - "/api/get_git_user_global_config", - post(h_get_git_user_global_config), - ) - .route( - "/api/set_git_user_global_config", - post(h_set_git_user_global_config), - ) - .route("/api/get_skip_git_hooks", post(h_get_skip_git_hooks)) - .route("/api/set_skip_git_hooks", post(h_set_skip_git_hooks)) - .route( - "/api/get_shell_integration_enabled", - post(h_get_shell_integration_enabled), - ) - .route( - "/api/set_shell_integration_enabled", - post(h_set_shell_integration_enabled), - ) - .route("/api/get_git_user_config", post(h_get_git_user_config)) - .route("/api/set_git_user_config", post(h_set_git_user_config)) - .route("/api/get_app_version", post(h_get_app_version)) - .route("/api/get_app_icon", post(h_get_app_icon)) - .route("/api/check_mirror_update", post(h_check_mirror_update)) - .route( - "/api/download_update_via_mirror", - post(h_download_update_via_mirror), - ) - .route("/api/test_mirror_speed", post(h_test_mirror_speed)) - .route( - "/api/speed_test_single_mirror", - post(h_speed_test_single_mirror), - ) - .route("/api/get_mirror_sources", post(h_get_mirror_sources)) - .route("/api/save_custom_mirrors", post(h_save_custom_mirrors)) - .route("/api/open_devtools", post(h_open_devtools)) - .route("/api/mcp/config", post(h_mcp_config)) - .route("/api/mcp/set_capability", post(h_set_mcp_capability)) - // Cloud - .route("/api/cloud_get_status", post(h_cloud_get_status)) - .route("/api/cloud_start_pairing", post(h_cloud_start_pairing)) - .route( - "/api/cloud_check_pairing_status", - post(h_cloud_check_pairing_status), - ) - .route("/api/cloud_approve_pairing", post(h_cloud_approve_pairing)) - .route("/api/cloud_reject_pairing", post(h_cloud_reject_pairing)) - .route("/api/cloud_disconnect", post(h_cloud_disconnect)) - // Vault - .route("/api/vault_status", post(h_vault_status)) - .route("/api/vault_link", post(h_vault_link)) - .route( - "/api/list_vault_item_children", - post(h_list_vault_item_children), - ) - .route("/ws", get(h_ws_upgrade)); - - if let Some(pem) = cert_pem { - router = router - .route("/api/cert.pem", get(h_cert_pem)) - .layer(Extension(Arc::new(pem))); - } - - router -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs deleted file mode 100644 index 40db0c3..0000000 --- a/src-tauri/src/lib.rs +++ /dev/null @@ -1,1387 +0,0 @@ -pub mod cloud_client; -mod commands; -pub mod config; -mod git_ops; -pub(crate) mod http_origin_policy; -pub mod http_server; -pub mod mirror; -mod pty_manager; -pub mod state; -pub(crate) mod tls; -pub mod types; -pub mod utils; - -// Re-exports used by http_server and other modules -pub use config::*; -pub(crate) use state::*; -pub use types::*; -pub use utils::normalize_path; - -// Re-exports of _impl functions used by http_server -pub use commands::git::{ - add_existing_project_impl, clone_project_impl, import_external_project_impl, - remove_project_from_config_impl, scan_existing_projects_impl, switch_branch_internal, -}; -pub use commands::sharing::{kick_client_internal, start_ngrok_tunnel_internal}; -pub use commands::system::{ - detect_tools_internal, get_app_icon_internal, open_in_editor_internal, - open_in_terminal_internal, open_log_dir_internal, reveal_in_finder_internal, - set_git_path_internal, -}; -pub use commands::window::{ - lock_worktree_impl, set_window_workspace_impl, unlock_worktree_impl, unregister_window_impl, -}; -pub use commands::workspace::{ - add_workspace_internal, create_workspace_internal, get_config_path_info_impl, - get_current_workspace_impl, get_workspace_config_impl, remove_workspace_internal, - save_workspace_config_impl, switch_workspace_impl, -}; -pub use commands::worktree::{ - add_project_to_worktree_impl, archive_worktree_impl, check_worktree_status_impl, - create_worktree_impl, delete_archived_worktree_impl, deploy_to_main_impl, - exit_main_occupation_impl, get_main_occupation_impl, get_main_workspace_status_impl, - list_worktrees_impl, restore_worktree_impl, scan_linked_folders_internal, - terminate_worktree_locking_process_impl, update_worktree_color_impl, -}; - -use commands::cloud::*; -use commands::config::*; -use commands::git::*; -use commands::pty::*; -use commands::sharing::*; -use commands::system::*; -use commands::vault::*; -use commands::voice::*; -use commands::window::*; -use commands::workspace::*; -use commands::worktree::*; -use serde::Serialize; -use std::path::Path; -use std::sync::{Mutex, OnceLock}; - -// ==================== Tauri 入口 ==================== - -const CRASH_LOG_FILE: &str = "crash.log"; -const PENDING_ERROR_LOG_FILE: &str = "worktree-manager-err.log"; -const SESSION_RUNNING_FILE: &str = "session.running"; - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct CrashReport { - abnormal_exit: bool, - crash_detail: Option, - previous_session_info: Option, -} - -pub(crate) static PENDING_CRASH_REPORT: OnceLock>> = OnceLock::new(); - -pub(crate) fn pending_crash_report() -> &'static Mutex> { - PENDING_CRASH_REPORT.get_or_init(|| Mutex::new(None)) -} - -// cfg 分支结构要求每个平台分支显式 return,clippy 的 needless_return 在此为误报 -#[allow(clippy::needless_return)] -fn crash_log_dir() -> Option { - #[cfg(target_os = "macos")] - { - return dirs::home_dir().map(|home| { - home.join("Library") - .join("Logs") - .join("com.guo.worktree-manager") - }); - } - - #[cfg(target_os = "windows")] - { - return dirs::data_local_dir().map(|dir| dir.join("com.guo.worktree-manager").join("logs")); - } - - #[cfg(target_os = "linux")] - { - return dirs::data_local_dir().map(|dir| dir.join("com.guo.worktree-manager").join("logs")); - } - - #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] - { - dirs::data_local_dir().map(|dir| dir.join("com.guo.worktree-manager").join("logs")) - } -} - -fn panic_payload_message(info: &std::panic::PanicHookInfo<'_>) -> String { - if let Some(message) = info.payload().downcast_ref::<&str>() { - (*message).to_string() - } else if let Some(message) = info.payload().downcast_ref::() { - message.clone() - } else { - "".to_string() - } -} - -fn append_crash_log(entry: &str) -> std::io::Result<()> { - use std::io::Write; - - let log_dir = crash_log_dir().ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::NotFound, "cannot resolve crash log dir") - })?; - std::fs::create_dir_all(&log_dir)?; - - let mut file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(log_dir.join(CRASH_LOG_FILE))?; - writeln!(file, "{}", entry)?; - std::fs::write(log_dir.join(PENDING_ERROR_LOG_FILE), entry)?; - Ok(()) -} - -fn read_and_clear_pending_error_log(log_dir: &Path) -> Option { - let path = log_dir.join(PENDING_ERROR_LOG_FILE); - let detail = std::fs::read_to_string(&path).ok(); - - match std::fs::remove_file(&path) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => log::warn!( - "[crash] failed to remove pending crash detail {:?}: {}", - path, - e - ), - } - - detail -} - -fn parse_session_marker_pid(contents: &str) -> Option { - contents.lines().find_map(|line| { - line.strip_prefix("pid:") - .and_then(|pid| pid.trim().parse::().ok()) - }) -} - -#[cfg(any(target_os = "macos", target_os = "linux"))] -fn is_process_running(pid: u32) -> Option { - if pid > i32::MAX as u32 { - return Some(false); - } - - // Signal 0 performs permission/existence checks without sending a signal. - let result = unsafe { libc::kill(pid as libc::pid_t, 0) }; - if result == 0 { - return Some(true); - } - - match std::io::Error::last_os_error().raw_os_error() { - Some(code) if code == libc::ESRCH => Some(false), - Some(code) if code == libc::EPERM => Some(true), - _ => None, - } -} - -#[cfg(target_os = "windows")] -fn is_process_running(pid: u32) -> Option { - use std::os::windows::process::CommandExt; - - const CREATE_NO_WINDOW: u32 = 0x08000000; - let output = std::process::Command::new("tasklist") - .args(["/FI", &format!("PID eq {}", pid), "/NH"]) - .creation_flags(CREATE_NO_WINDOW) - .output() - .ok()?; - - if !output.status.success() { - return None; - } - - let stdout = String::from_utf8_lossy(&output.stdout); - Some(stdout.contains(&pid.to_string())) -} - -#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] -fn is_process_running(_pid: u32) -> Option { - None -} - -fn take_startup_crash_report(log_dir: &Path) -> Option { - let session_path = log_dir.join(SESSION_RUNNING_FILE); - let abnormal_exit = session_path.exists(); - let previous_session_info = if abnormal_exit { - std::fs::read_to_string(&session_path).ok() - } else { - None - }; - - if abnormal_exit { - if let Some(pid) = previous_session_info - .as_deref() - .and_then(parse_session_marker_pid) - { - if is_process_running(pid) == Some(true) { - log::info!( - "[crash] session marker belongs to a running process (pid={}), skipping crash report", - pid - ); - return None; - } - } - - let crash_detail = read_and_clear_pending_error_log(log_dir); - Some(CrashReport { - abnormal_exit, - crash_detail, - previous_session_info, - }) - } else { - let _ = read_and_clear_pending_error_log(log_dir); - None - } -} - -fn detect_startup_crash_report() { - let Some(log_dir) = crash_log_dir() else { - log::warn!("[crash] cannot resolve crash log dir for startup check"); - return; - }; - - let report = take_startup_crash_report(&log_dir); - match pending_crash_report().lock() { - Ok(mut pending) => *pending = report, - Err(e) => log::error!("[crash] failed to lock pending crash report: {}", e), - } -} - -fn session_running_contents() -> String { - format!( - "timestamp: {}\nversion: {}\npid: {}\n", - chrono::Local::now().to_rfc3339(), - env!("CARGO_PKG_VERSION"), - std::process::id() - ) -} - -fn write_session_running_file_at(log_dir: &Path) -> std::io::Result<()> { - std::fs::create_dir_all(log_dir)?; - std::fs::write( - log_dir.join(SESSION_RUNNING_FILE), - session_running_contents(), - ) -} - -fn write_session_running_file() { - let Some(log_dir) = crash_log_dir() else { - log::warn!("[crash] cannot resolve crash log dir for session marker"); - return; - }; - - if let Err(e) = write_session_running_file_at(&log_dir) { - log::warn!("[crash] failed to write session marker: {}", e); - } -} - -fn remove_session_running_file_at(log_dir: &Path) -> std::io::Result<()> { - match std::fs::remove_file(log_dir.join(SESSION_RUNNING_FILE)) { - Ok(()) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(e), - } -} - -fn remove_session_running_file() { - let Some(log_dir) = crash_log_dir() else { - log::warn!("[crash] cannot resolve crash log dir for session cleanup"); - return; - }; - - if let Err(e) = remove_session_running_file_at(&log_dir) { - log::warn!("[crash] failed to remove session marker: {}", e); - } -} - -fn install_panic_hook() { - let default_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |info| { - let timestamp = chrono::Local::now().to_rfc3339(); - let message = panic_payload_message(info); - let location = info - .location() - .map(|loc| format!("{}:{}:{}", loc.file(), loc.line(), loc.column())) - .unwrap_or_else(|| "".to_string()); - let backtrace = std::backtrace::Backtrace::force_capture(); - let entry = format!( - "timestamp: {}\nversion: {}\npanic: {}\nlocation: {}\nbacktrace:\n{}", - timestamp, - env!("CARGO_PKG_VERSION"), - message, - location, - backtrace - ); - - log::error!("[panic] {}", entry); - if let Err(e) = append_crash_log(&entry) { - log::error!("[panic] failed to write crash logs: {}", e); - eprintln!("failed to write crash logs: {}", e); - } - - default_hook(info); - })); -} - -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { - install_panic_hook(); - detect_startup_crash_report(); - write_session_running_file(); - - // Install rustls CryptoProvider before any TLS usage (required by rustls 0.23+) - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - - let app = tauri::Builder::default() - .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_updater::Builder::new().build()) - .plugin(tauri_plugin_process::init()) - .plugin( - tauri_plugin_log::Builder::new() - .level(log::LevelFilter::Info) - .max_file_size(10_000_000) - .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll) - .targets([ - tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout), - tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { - file_name: Some("worktree-manager".into()), - }), - tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview), - ]) - .build(), - ) - .on_window_event(|window, event| { - match event { - tauri::WindowEvent::CloseRequested { api, .. } => { - let is_main = window.label() == "main"; - - // Secondary windows: always allow close without confirmation - if !is_main { - // Let the default close proceed; Destroyed event will handle cleanup - return; - } - - // Main window: check global state before closing - let terminal_count = { - if let Ok(manager) = PTY_MANAGER.lock() { - manager.session_count() - } else { - 0 - } - }; - - let share_active = { - if let Ok(state) = SHARE_STATE.lock() { - state.active - } else { - false - } - }; - - if terminal_count > 0 || share_active { - api.prevent_close(); - let window = window.clone(); - - tauri::async_runtime::spawn(async move { - use tokio::time::{timeout, Duration}; - - if terminal_count > 0 { - use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; - let (tx, rx) = tokio::sync::oneshot::channel(); - window - .dialog() - .message(format!( - "有 {} 个活跃终端会话,关闭将终止所有会话。\n\n确定关闭?", - terminal_count - )) - .title("Worktree Manager") - .buttons(MessageDialogButtons::OkCancelCustom( - "关闭".to_string(), - "取消".to_string(), - )) - .show(move |confirmed| { - let _ = tx.send(confirmed); - }); - // Timeout: force close if dialog doesn't respond within 30s - match timeout(Duration::from_secs(30), rx).await { - Ok(Ok(false)) => return, - Ok(Ok(true)) => {} // user confirmed - Ok(Err(_)) => {} // channel dropped, proceed with close - Err(_) => { - log::warn!( - "Close confirmation dialog timed out, forcing close" - ); - } - } - } - - if share_active { - log::info!("Window closing - stopping sharing first"); - // Timeout: don't let cleanup block close for more than 5s - let cleanup = async { - if let Err(e) = stop_ngrok_tunnel().await { - log::warn!("Failed to stop ngrok tunnel on close: {}", e); - } - if let Err(e) = stop_sharing().await { - log::warn!("Failed to stop sharing on close: {}", e); - } - }; - if timeout(Duration::from_secs(5), cleanup).await.is_err() { - log::warn!("Sharing cleanup timed out, forcing close"); - } else { - log::info!("Sharing stopped, closing window"); - } - } - - let _ = window.destroy(); - }); - } - } - tauri::WindowEvent::Destroyed => { - unregister_window_impl(window.label()); - } - _ => {} - } - }) - .invoke_handler(tauri::generate_handler![ - // Workspace 管理 - list_workspaces, - get_current_workspace, - switch_workspace, - add_workspace, - remove_workspace, - create_workspace, - // Workspace 配置 - get_workspace_config, - save_workspace_config, - load_workspace_config_by_path, - save_workspace_config_by_path, - get_config_path_info, - // Worktree 操作 - list_worktrees, - get_main_workspace_status, - update_worktree_color, - create_worktree, - archive_worktree, - restore_worktree, - delete_archived_worktree, - check_worktree_status, - terminate_worktree_locking_process, - add_project_to_worktree, - deploy_to_main, - exit_main_occupation, - get_main_occupation, - // Git 操作 - switch_branch, - clone_project, - scan_existing_projects, - add_existing_project, - import_external_project, - remove_project_from_config, - sync_with_base_branch, - push_to_remote, - pull_current_branch, - merge_to_test_branch, - merge_to_base_branch, - get_branch_diff_stats, - create_pull_request, - fetch_project_remote, - check_remote_branch_exists, - get_remote_branches, - get_git_diff, - commit_all, - sync_all_projects_to_base, - get_changed_files, - get_file_diff, - generate_commit_message, - // 工具 - open_in_terminal, - open_in_editor, - open_log_dir, - reveal_in_finder, - detect_tools, - frontend_log, - set_git_path, - get_app_icon, - get_crash_report, - get_app_version, - // 多窗口管理 - set_window_workspace, - get_opened_workspaces, - unregister_window, - open_workspace_window, - lock_worktree, - unlock_worktree, - get_locked_worktrees, - broadcast_terminal_state, - get_terminal_state, - // 智能扫描 - scan_linked_folders, - // PTY 终端 - pty_create, - pty_write, - pty_read, - pty_resize, - pty_close, - pty_exists, - pty_close_by_path, - // 分享功能 - start_sharing, - stop_sharing, - get_share_state, - update_share_password, - get_connected_clients, - kick_client, - // ngrok - get_ngrok_token, - set_ngrok_token, - get_last_share_port, - get_last_share_password, - start_ngrok_tunnel, - stop_ngrok_tunnel, - // 语音识别 (Dashscope) - get_dashscope_api_key, - set_dashscope_api_key, - get_dashscope_base_url, - set_dashscope_base_url, - get_voice_refine_enabled, - set_voice_refine_enabled, - get_voice_refine_base_url, - set_voice_refine_base_url, - get_voice_asr_model, - set_voice_asr_model, - get_voice_refine_model, - set_voice_refine_model, - list_dashscope_models, - check_dashscope_api_key, - get_commit_ai_api_key, - set_commit_ai_api_key, - set_commit_ai_enabled, - get_commit_ai_enabled, - check_commit_ai_api_key, - voice_start, - voice_send_audio, - voice_stop, - voice_is_active, - voice_refine_text, - // 提交前缀配置 - get_commit_prefix_config, - set_commit_prefix_config, - // Git 用户全局配置 - get_git_user_global_config, - set_git_user_global_config, - // Git hooks 跳过配置 - get_skip_git_hooks, - set_skip_git_hooks, - // Shell Integration 配置 - get_shell_integration_enabled, - set_shell_integration_enabled, - // Git 用户本地配置 - get_git_user_config, - set_git_user_config, - // 更新镜像 - check_mirror_update, - download_update_via_mirror, - test_mirror_speed, - speed_test_single_mirror, - get_mirror_sources, - save_custom_mirrors, - // DevTools - open_devtools, - // Vault - vault_status, - vault_link, - list_vault_item_children, - // 云端连接 - cloud_get_status, - cloud_start_pairing, - cloud_check_pairing_status, - cloud_approve_pairing, - cloud_reject_pairing, - cloud_disconnect, - ]) - .setup(|app| { - use tauri::Manager; - // Initialize APP_HANDLE for use in WebSocket handlers - *APP_HANDLE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(app.handle().clone()); - - // Initialize shell integration scripts - let resource_dir = app.path().resource_dir().ok().or_else(|| { - // Dev mode fallback: resource_dir() is unavailable, use src-tauri/ directly - let dev_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); - log::info!("[setup] Using dev resource_dir: {:?}", dev_dir); - Some(dev_dir) - }); - if let Some(dir) = resource_dir { - pty_manager::init_shell_integration(dir); - } - - // Start MCP server on the port expected by @worktree-manager/mcp. - let mcp_port = 42819; - tauri::async_runtime::spawn(async move { - if let Err(e) = http_server::start_mcp_server(mcp_port).await { - log::error!("[MCP] Server failed: {}", e); - } - }); - - log::info!( - "=== app started, version {}, os {}/{} ===", - env!("CARGO_PKG_VERSION"), - std::env::consts::OS, - std::env::consts::ARCH - ); - - Ok(()) - }) - .build(tauri::generate_context!()) - .expect("error while building tauri application"); - - app.run(|_app, event| { - if let tauri::RunEvent::Exit = event { - log::info!("=== app exiting normally ==="); - remove_session_running_file(); - } - }); -} - -#[cfg(test)] -mod crash_report_tests { - use serial_test::serial; - use std::fs; - use std::panic::{self, AssertUnwindSafe}; - use std::path::PathBuf; - use std::sync::mpsc; - use std::time::Duration; - - static PANIC_HOOK_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - fn temp_log_dir(test_name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!( - "worktree-manager-{}-{}", - test_name, - std::process::id() - )); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - dir - } - - fn capture_panic_payload_message(panic_fn: F) -> String - where - F: FnOnce() + panic::UnwindSafe, - { - let _guard = PANIC_HOOK_MUTEX - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let previous_hook = panic::take_hook(); - let (tx, rx) = mpsc::channel(); - - panic::set_hook(Box::new(move |info| { - let _ = tx.send(super::panic_payload_message(info)); - })); - - let result = panic::catch_unwind(AssertUnwindSafe(panic_fn)); - panic::set_hook(previous_hook); - - assert!(result.is_err()); - rx.recv_timeout(Duration::from_secs(1)) - .expect("panic hook should capture payload message") - } - - #[serial] - #[test] - fn startup_crash_report_reads_previous_session_and_consumes_error_log() { - let dir = temp_log_dir("startup-crash-report"); - fs::write( - dir.join("session.running"), - "timestamp: old\nversion: 0.1.2\npid: 4294967295", - ) - .unwrap(); - fs::write(dir.join("worktree-manager-err.log"), "panic detail").unwrap(); - - let report = super::take_startup_crash_report(&dir).unwrap(); - - assert!(report.abnormal_exit); - assert_eq!( - report.previous_session_info.as_deref(), - Some("timestamp: old\nversion: 0.1.2\npid: 4294967295") - ); - assert_eq!(report.crash_detail.as_deref(), Some("panic detail")); - assert!(!dir.join("worktree-manager-err.log").exists()); - - let _ = fs::remove_dir_all(&dir); - } - - #[serial] - #[test] - fn startup_crash_report_reports_marker_without_error_detail() { - let temp = tempfile::tempdir().expect("create temp crash dir"); - fs::write( - temp.path().join("session.running"), - "timestamp: old\nversion: 0.1.2\npid: 4294967295\n", - ) - .unwrap(); - - let report = super::take_startup_crash_report(temp.path()).unwrap(); - - assert!(report.abnormal_exit); - assert_eq!( - report.previous_session_info.as_deref(), - Some("timestamp: old\nversion: 0.1.2\npid: 4294967295\n") - ); - assert_eq!(report.crash_detail, None); - } - - #[serial] - #[test] - fn startup_crash_report_skips_marker_when_pid_is_still_running() { - let dir = temp_log_dir("startup-running-pid"); - fs::write( - dir.join("session.running"), - format!( - "timestamp: old\nversion: 0.1.2\npid: {}\n", - std::process::id() - ), - ) - .unwrap(); - fs::write(dir.join("worktree-manager-err.log"), "panic detail").unwrap(); - - assert!(super::take_startup_crash_report(&dir).is_none()); - assert!(dir.join("worktree-manager-err.log").exists()); - - let _ = fs::remove_dir_all(&dir); - } - - #[serial] - #[test] - fn startup_crash_report_clears_stale_error_log_without_marker() { - let dir = temp_log_dir("startup-stale-error"); - fs::write(dir.join("worktree-manager-err.log"), "stale panic detail").unwrap(); - - assert!(super::take_startup_crash_report(&dir).is_none()); - assert!(!dir.join("worktree-manager-err.log").exists()); - - let _ = fs::remove_dir_all(&dir); - } - - #[serial] - #[test] - fn startup_crash_report_without_marker_and_without_error_log_returns_none() { - let temp = tempfile::tempdir().expect("create temp crash dir"); - - assert!(super::take_startup_crash_report(temp.path()).is_none()); - } - - #[serial] - #[test] - fn session_marker_contains_timestamp_version_and_pid() { - let dir = temp_log_dir("session-marker"); - - super::write_session_running_file_at(&dir).unwrap(); - let contents = fs::read_to_string(dir.join("session.running")).unwrap(); - - assert!(contents.contains("timestamp: ")); - assert!(contents.contains("version: ")); - assert!(contents.contains("pid: ")); - - let _ = fs::remove_dir_all(&dir); - } - - #[serial] - #[test] - fn parse_session_marker_pid_reads_pid_line() { - assert_eq!( - super::parse_session_marker_pid("timestamp: old\nversion: 1.0\npid: 4242\n"), - Some(4242) - ); - } - - #[serial] - #[test] - fn parse_session_marker_pid_trims_pid_value_and_ignores_blank_content() { - assert_eq!(super::parse_session_marker_pid(""), None); - assert_eq!(super::parse_session_marker_pid(" \n\t\n"), None); - assert_eq!( - super::parse_session_marker_pid("timestamp: old\npid: 9876 \n"), - Some(9876) - ); - } - - #[serial] - #[test] - fn parse_session_marker_pid_rejects_missing_or_invalid_pid() { - assert_eq!(super::parse_session_marker_pid("timestamp: old"), None); - assert_eq!( - super::parse_session_marker_pid("timestamp: old\npid: not-a-number\n"), - None - ); - assert_eq!(super::parse_session_marker_pid(" pid: 123\n"), None); - } - - #[serial] - #[test] - fn crash_log_dir_returns_a_platform_path() { - let path = super::crash_log_dir().expect("crash log directory should resolve"); - - assert!( - path.to_string_lossy().contains("com.guo.worktree-manager"), - "unexpected crash log path: {:?}", - path - ); - } - - #[serial] - #[test] - fn remove_session_running_file_at_deletes_marker_and_ignores_missing_marker() { - let temp = tempfile::tempdir().expect("create temp crash dir"); - let marker = temp.path().join("session.running"); - fs::write(&marker, "pid: 4294967295\n").expect("write marker"); - - super::remove_session_running_file_at(temp.path()).expect("remove existing marker"); - assert!(!marker.exists()); - - super::remove_session_running_file_at(temp.path()).expect("missing marker is ok"); - } - - #[serial] - #[test] - fn panic_payload_message_handles_str_string_and_unknown_payloads() { - assert_eq!( - capture_panic_payload_message(|| panic::panic_any("borrowed payload")), - "borrowed payload" - ); - assert_eq!( - capture_panic_payload_message(|| panic::panic_any(String::from("owned payload"))), - "owned payload" - ); - assert_eq!( - capture_panic_payload_message(|| panic::panic_any(123_u32)), - "" - ); - } -} - -#[cfg(test)] -mod crash_report_coverage_completion_tests { - use serial_test::serial; - use std::fs; - - #[serial] - #[test] - fn pending_error_log_read_is_consuming_and_idempotent() { - let temp = tempfile::tempdir().expect("create crash temp dir"); - let pending = temp.path().join("worktree-manager-err.log"); - fs::write(&pending, "panic detail").expect("write pending error log"); - - let first = super::read_and_clear_pending_error_log(temp.path()); - let second = super::read_and_clear_pending_error_log(temp.path()); - - assert_eq!(first.as_deref(), Some("panic detail")); - assert_eq!(second, None); - assert!(!pending.exists()); - } - - #[serial] - #[test] - fn session_running_contents_contains_parseable_current_pid() { - let contents = super::session_running_contents(); - - assert_eq!( - super::parse_session_marker_pid(&contents), - Some(std::process::id()) - ); - assert!(contents.contains(env!("CARGO_PKG_VERSION"))); - } - - #[serial] - #[test] - fn pending_crash_report_mutex_stores_and_restores_report() { - let mut pending = super::pending_crash_report() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let previous = pending.take(); - *pending = Some(super::CrashReport { - abnormal_exit: true, - crash_detail: Some("detail".to_string()), - previous_session_info: Some("pid: 4294967295".to_string()), - }); - - let report = pending.as_ref().expect("pending report"); - assert!(report.abnormal_exit); - assert_eq!(report.crash_detail.as_deref(), Some("detail")); - assert_eq!( - report.previous_session_info.as_deref(), - Some("pid: 4294967295") - ); - - *pending = previous; - } - - #[cfg(any(target_os = "macos", target_os = "linux"))] - #[serial] - #[test] - fn process_running_detects_current_pid_and_rejects_impossible_pid() { - assert_eq!(super::is_process_running(std::process::id()), Some(true)); - assert_eq!(super::is_process_running(u32::MAX), Some(false)); - } - - #[serial] - #[test] - fn startup_crash_report_handles_marker_without_pid_line() { - let temp = tempfile::tempdir().expect("create crash temp dir"); - fs::write( - temp.path().join("session.running"), - "timestamp: old\nversion: 0.1.2\n", - ) - .expect("write marker"); - fs::write(temp.path().join("worktree-manager-err.log"), "detail").expect("write detail"); - - let report = super::take_startup_crash_report(temp.path()).expect("crash report"); - - assert!(report.abnormal_exit); - assert_eq!( - report.previous_session_info.as_deref(), - Some("timestamp: old\nversion: 0.1.2\n") - ); - assert_eq!(report.crash_detail.as_deref(), Some("detail")); - assert!(!temp.path().join("worktree-manager-err.log").exists()); - } - - #[serial] - #[test] - fn remove_session_running_file_at_reports_non_file_marker_errors() { - let temp = tempfile::tempdir().expect("create crash temp dir"); - fs::create_dir(temp.path().join("session.running")).expect("create marker directory"); - - let err = super::remove_session_running_file_at(temp.path()) - .expect_err("directory marker cannot be removed as a file"); - - assert_ne!(err.kind(), std::io::ErrorKind::NotFound); - assert!(temp.path().join("session.running").is_dir()); - } - - #[serial] - #[test] - fn parse_session_marker_pid_accepts_later_valid_pid_after_invalid_lines() { - assert_eq!( - super::parse_session_marker_pid("pid: nope\ntimestamp: old\npid: 4242\n"), - Some(4242) - ); - } - - #[serial] - #[test] - fn parse_session_marker_pid_rejects_signed_and_overflow_values() { - assert_eq!(super::parse_session_marker_pid("pid: -1\n"), None); - assert_eq!(super::parse_session_marker_pid("pid: 4294967296\n"), None); - } - - #[serial] - #[test] - fn pending_error_log_read_returns_none_when_parent_is_missing() { - let temp = tempfile::tempdir().expect("create crash temp dir"); - let missing = temp.path().join("missing-parent"); - - assert_eq!(super::read_and_clear_pending_error_log(&missing), None); - assert!(!missing.exists()); - } - - #[serial] - #[test] - fn pending_error_log_directory_is_left_in_place() { - let temp = tempfile::tempdir().expect("create crash temp dir"); - let pending = temp.path().join("worktree-manager-err.log"); - fs::create_dir(&pending).expect("create pending error directory"); - - assert_eq!(super::read_and_clear_pending_error_log(temp.path()), None); - assert!(pending.is_dir()); - } - - #[serial] - #[test] - fn write_session_running_file_at_creates_missing_parent_dirs() { - let temp = tempfile::tempdir().expect("create crash temp dir"); - let nested = temp.path().join("nested").join("logs"); - - super::write_session_running_file_at(&nested).expect("write nested marker"); - - let marker = fs::read_to_string(nested.join("session.running")).expect("read marker"); - assert_eq!( - super::parse_session_marker_pid(&marker), - Some(std::process::id()) - ); - } - - #[serial] - #[test] - fn startup_crash_report_handles_directory_marker_without_session_text() { - let temp = tempfile::tempdir().expect("create crash temp dir"); - fs::create_dir(temp.path().join("session.running")).expect("create marker directory"); - fs::write( - temp.path().join("worktree-manager-err.log"), - "directory marker detail", - ) - .expect("write detail"); - - let report = super::take_startup_crash_report(temp.path()).expect("crash report"); - - assert!(report.abnormal_exit); - assert_eq!(report.previous_session_info, None); - assert_eq!( - report.crash_detail.as_deref(), - Some("directory marker detail") - ); - assert!(!temp.path().join("worktree-manager-err.log").exists()); - } - - #[serial] - #[test] - fn pending_error_log_read_preserves_multiline_detail() { - let temp = tempfile::tempdir().expect("create crash temp dir"); - let detail = "timestamp: old\npanic: boom\nbacktrace:\nframe 1\n"; - fs::write(temp.path().join("worktree-manager-err.log"), detail).expect("write detail"); - - let read = super::read_and_clear_pending_error_log(temp.path()); - - assert_eq!(read.as_deref(), Some(detail)); - assert!(!temp.path().join("worktree-manager-err.log").exists()); - } - - #[serial] - #[test] - fn startup_crash_report_with_invalid_pid_consumes_error_log() { - let temp = tempfile::tempdir().expect("create crash temp dir"); - let marker = "timestamp: old\nversion: 0.1.2\npid: invalid\n"; - fs::write(temp.path().join("session.running"), marker).expect("write marker"); - fs::write( - temp.path().join("worktree-manager-err.log"), - "invalid pid detail", - ) - .expect("write detail"); - - let report = super::take_startup_crash_report(temp.path()).expect("crash report"); - - assert!(report.abnormal_exit); - assert_eq!(report.previous_session_info.as_deref(), Some(marker)); - assert_eq!(report.crash_detail.as_deref(), Some("invalid pid detail")); - assert!(!temp.path().join("worktree-manager-err.log").exists()); - } - - #[serial] - #[test] - fn startup_crash_report_with_empty_marker_keeps_empty_session_info() { - let temp = tempfile::tempdir().expect("create crash temp dir"); - fs::write(temp.path().join("session.running"), "").expect("write empty marker"); - - let report = super::take_startup_crash_report(temp.path()).expect("crash report"); - - assert!(report.abnormal_exit); - assert_eq!(report.previous_session_info.as_deref(), Some("")); - assert_eq!(report.crash_detail, None); - } - - #[serial] - #[test] - fn startup_crash_report_without_marker_consumes_multiline_stale_error() { - let temp = tempfile::tempdir().expect("create crash temp dir"); - let pending = temp.path().join("worktree-manager-err.log"); - fs::write(&pending, "stale\nmultiline\nerror\n").expect("write stale detail"); - - assert!(super::take_startup_crash_report(temp.path()).is_none()); - assert!(!pending.exists()); - } - - #[serial] - #[test] - fn write_session_running_file_at_overwrites_existing_marker() { - let temp = tempfile::tempdir().expect("create crash temp dir"); - let marker_path = temp.path().join("session.running"); - fs::write(&marker_path, "old marker").expect("write old marker"); - - super::write_session_running_file_at(temp.path()).expect("overwrite marker"); - let marker = fs::read_to_string(marker_path).expect("read marker"); - - assert_ne!(marker, "old marker"); - assert_eq!( - super::parse_session_marker_pid(&marker), - Some(std::process::id()) - ); - } - - #[serial] - #[test] - fn session_running_contents_has_timestamp_version_pid_lines_in_order() { - let contents = super::session_running_contents(); - let lines: Vec<&str> = contents.lines().collect(); - - assert_eq!(lines.len(), 3); - assert!(lines[0].starts_with("timestamp: "), "{contents}"); - assert_eq!(lines[1], format!("version: {}", env!("CARGO_PKG_VERSION"))); - assert_eq!(lines[2], format!("pid: {}", std::process::id())); - } - - #[serial] - #[test] - fn append_crash_log_writes_append_log_and_pending_detail_under_temp_home() { - let temp_home = tempfile::tempdir().expect("create temp home"); - let previous_home = std::env::var_os("HOME"); - std::env::set_var("HOME", temp_home.path()); - - let first_entry = "timestamp: first\npanic: one"; - let second_entry = "timestamp: second\npanic: two"; - super::append_crash_log(first_entry).expect("append first crash log entry"); - super::append_crash_log(second_entry).expect("append second crash log entry"); - - let log_dir = super::crash_log_dir().expect("crash dir from temp home"); - let crash_log = fs::read_to_string(log_dir.join("crash.log")).expect("read crash log"); - let pending = - fs::read_to_string(log_dir.join("worktree-manager-err.log")).expect("read pending log"); - - assert!(crash_log.contains(first_entry)); - assert!(crash_log.contains(second_entry)); - assert_eq!(pending, second_entry); - assert!(log_dir.starts_with(temp_home.path())); - - match previous_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } - } - - #[serial] - #[test] - fn detect_write_and_remove_session_marker_use_configured_crash_dir() { - let temp_home = tempfile::tempdir().expect("create temp home"); - let previous_home = std::env::var_os("HOME"); - std::env::set_var("HOME", temp_home.path()); - let pending = super::pending_crash_report(); - let previous_pending = { - let mut guard = pending - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - guard.take() - }; - - super::detect_startup_crash_report(); - assert!(pending - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_none()); - - super::write_session_running_file(); - let log_dir = super::crash_log_dir().expect("crash dir"); - let marker_path = log_dir.join("session.running"); - let marker = fs::read_to_string(&marker_path).expect("session marker exists"); - assert_eq!( - super::parse_session_marker_pid(&marker), - Some(std::process::id()) - ); - - super::remove_session_running_file(); - assert!(!marker_path.exists()); - super::remove_session_running_file(); - assert!(!marker_path.exists()); - - *pending - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous_pending; - match previous_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } - } - - #[serial] - #[test] - fn crash_report_marker_matrix_handles_stale_sessions_and_error_details() { - let cases = [ - ( - "marker-with-detail", - Some("timestamp: old\nversion: 0.1.2\npid: 4294967295\n"), - Some("panic detail"), - true, - Some("timestamp: old\nversion: 0.1.2\npid: 4294967295\n"), - Some("panic detail"), - ), - ( - "marker-without-detail", - Some("timestamp: old\nversion: 0.1.2\npid: 4294967295\n"), - None, - true, - Some("timestamp: old\nversion: 0.1.2\npid: 4294967295\n"), - None, - ), - ( - "marker-with-invalid-pid", - Some("timestamp: old\nversion: 0.1.2\npid: invalid\n"), - Some("invalid pid detail"), - true, - Some("timestamp: old\nversion: 0.1.2\npid: invalid\n"), - Some("invalid pid detail"), - ), - ( - "marker-with-overflow-pid", - Some("timestamp: old\nversion: 0.1.2\npid: 4294967296\n"), - Some("overflow pid detail"), - true, - Some("timestamp: old\nversion: 0.1.2\npid: 4294967296\n"), - Some("overflow pid detail"), - ), - ( - "marker-with-negative-pid", - Some("timestamp: old\nversion: 0.1.2\npid: -1\n"), - Some("negative pid detail"), - true, - Some("timestamp: old\nversion: 0.1.2\npid: -1\n"), - Some("negative pid detail"), - ), - ( - "marker-with-empty-pid", - Some("timestamp: old\nversion: 0.1.2\npid:\n"), - Some("empty pid detail"), - true, - Some("timestamp: old\nversion: 0.1.2\npid:\n"), - Some("empty pid detail"), - ), - ( - "marker-with-spaced-pid", - Some("timestamp: old\nversion: 0.1.2\npid: 4294967295 \n"), - Some("spaced pid detail"), - true, - Some("timestamp: old\nversion: 0.1.2\npid: 4294967295 \n"), - Some("spaced pid detail"), - ), - ( - "marker-with-later-valid-pid", - Some("pid: invalid\ntimestamp: old\npid: 4294967295\n"), - Some("later pid detail"), - true, - Some("pid: invalid\ntimestamp: old\npid: 4294967295\n"), - Some("later pid detail"), - ), - ( - "marker-with-leading-space-pid", - Some("timestamp: old\n pid: 4294967295\n"), - Some("leading space detail"), - true, - Some("timestamp: old\n pid: 4294967295\n"), - Some("leading space detail"), - ), - ( - "marker-with-no-pid", - Some("timestamp: old\nversion: 0.1.2\n"), - Some("missing pid detail"), - true, - Some("timestamp: old\nversion: 0.1.2\n"), - Some("missing pid detail"), - ), - ("marker-empty", Some(""), None, true, Some(""), None), - ( - "marker-empty-with-detail", - Some(""), - Some("empty marker detail"), - true, - Some(""), - Some("empty marker detail"), - ), - ( - "marker-multiline-detail", - Some("timestamp: old\nversion: 0.1.2\npid: 4294967295\n"), - Some("timestamp: old\npanic: boom\nbacktrace:\nframe 1\n"), - true, - Some("timestamp: old\nversion: 0.1.2\npid: 4294967295\n"), - Some("timestamp: old\npanic: boom\nbacktrace:\nframe 1\n"), - ), - ( - "marker-unicode-detail", - Some("timestamp: old\nversion: 0.1.2\npid: 4294967295\n"), - Some("panic: non-ascii detail"), - true, - Some("timestamp: old\nversion: 0.1.2\npid: 4294967295\n"), - Some("panic: non-ascii detail"), - ), - ( - "no-marker-with-detail", - None, - Some("stale detail"), - false, - None, - None, - ), - ("no-marker-without-detail", None, None, false, None, None), - ( - "marker-with-zero-like-invalid-prefix", - Some("timestamp: old\npid : 4294967295\n"), - Some("wrong prefix detail"), - true, - Some("timestamp: old\npid : 4294967295\n"), - Some("wrong prefix detail"), - ), - ( - "marker-with-tabbed-pid", - Some("timestamp: old\npid:\t4294967295\n"), - Some("tab pid detail"), - true, - Some("timestamp: old\npid:\t4294967295\n"), - Some("tab pid detail"), - ), - ( - "marker-with-extra-fields", - Some("timestamp: old\nversion: 0.1.2\nbranch: main\npid: 4294967295\n"), - Some("extra field detail"), - true, - Some("timestamp: old\nversion: 0.1.2\nbranch: main\npid: 4294967295\n"), - Some("extra field detail"), - ), - ( - "marker-with-trailing-text", - Some("timestamp: old\npid: 4294967295\nstatus: interrupted\n"), - Some("trailing detail"), - true, - Some("timestamp: old\npid: 4294967295\nstatus: interrupted\n"), - Some("trailing detail"), - ), - ]; - - for (name, marker, detail, expect_report, expected_session, expected_detail) in cases { - let temp = tempfile::tempdir().expect("create crash temp dir"); - let pending_path = temp.path().join("worktree-manager-err.log"); - if let Some(marker) = marker { - fs::write(temp.path().join("session.running"), marker) - .unwrap_or_else(|err| panic!("{name}: write marker: {err}")); - } - if let Some(detail) = detail { - fs::write(&pending_path, detail) - .unwrap_or_else(|err| panic!("{name}: write pending detail: {err}")); - } - - let report = super::take_startup_crash_report(temp.path()); - - if expect_report { - let report = report.unwrap_or_else(|| panic!("{name}: expected report")); - assert!(report.abnormal_exit, "{name}"); - assert_eq!( - report.previous_session_info.as_deref(), - expected_session, - "{name}" - ); - assert_eq!(report.crash_detail.as_deref(), expected_detail, "{name}"); - assert!( - !pending_path.exists(), - "{name}: pending detail should be consumed" - ); - } else { - assert!(report.is_none(), "{name}: expected no report"); - assert!( - !pending_path.exists(), - "{name}: stale detail should be consumed" - ); - } - } - } -} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs deleted file mode 100644 index a0eab5a..0000000 --- a/src-tauri/src/main.rs +++ /dev/null @@ -1,6 +0,0 @@ -// Prevents additional console window on Windows in release, DO NOT REMOVE!! -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -fn main() { - worktree_manager_lib::run() -} diff --git a/src-tauri/src/mirror.rs b/src-tauri/src/mirror.rs deleted file mode 100644 index 35b5971..0000000 --- a/src-tauri/src/mirror.rs +++ /dev/null @@ -1,1175 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::sync::Mutex; -use std::time::Instant; - -use crate::config::load_global_config; - -// ==================== 类型定义 ==================== - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MirrorSource { - pub name: String, - pub url: String, - pub builtin: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MirrorTestResult { - pub name: String, - pub url: String, - pub bytes_downloaded: u64, - pub speed_mbps: f64, - pub ping_ms: u64, - pub available: bool, -} - -// ==================== 内置镜像源 ==================== - -const BUILTIN_MIRRORS: &[(&str, &str)] = &[ - ("gh-proxy.org", "https://gh-proxy.org/"), - ("ghproxy.net", "https://ghproxy.net/"), - ("mirror.ghproxy.com", "https://mirror.ghproxy.com/"), - ("gh.llkk.cc", "https://gh.llkk.cc/"), - ("github.moeyy.xyz", "https://github.moeyy.xyz/"), - ("ghps.cc", "https://ghps.cc/"), - ("cf.ghproxy.cc", "https://cf.ghproxy.cc/"), - ("gh.noki.icu", "https://gh.noki.icu/"), - ("ghproxy.cn", "https://ghproxy.cn/"), -]; - -/// PING 测试文件:React favicon (~3KB),用于快速过滤不可用源 -const PING_TEST_BASE_URL: &str = - "https://raw.githubusercontent.com/facebook/react/refs/heads/main/fixtures/dom/public/favicon.ico"; - -/// PING 超时(秒) -const PING_TIMEOUT_SECS: u64 = 3; - -/// 吞吐量测速文件:pip 24.3.1 release zip (~4MB) -const SPEED_TEST_BASE_URL: &str = "https://github.com/pypa/pip/archive/refs/tags/24.3.1.zip"; - -/// 测速时长(秒) -const SPEED_TEST_DURATION_SECS: u64 = 10; - -/// PING 最小有效大小(字节)— favicon.ico 约 24KB,低于 10KB 视为返回了错误页 -const PING_MIN_VALID_BYTES: usize = 10_000; - -/// 测速最小有效下载量(字节)— 低于此值说明源返回了错误页而非真实文件 -const SPEED_TEST_MIN_VALID_BYTES: u64 = 100_000; - -/// 缓存有效期(秒)— 预留给未来自动刷新 -#[allow(dead_code)] -const CACHE_TTL_SECS: u64 = 30 * 60; - -// ==================== 缓存 ==================== - -static MIRROR_CACHE: Mutex)>> = Mutex::new(None); - -/// 获取所有镜像源(内置 + 用户自定义) -pub fn get_all_mirrors() -> Vec { - let mut mirrors: Vec = BUILTIN_MIRRORS - .iter() - .map(|(name, url)| MirrorSource { - name: name.to_string(), - url: url.to_string(), - builtin: true, - }) - .collect(); - - let config = load_global_config(); - for cm in &config.custom_mirrors { - mirrors.push(MirrorSource { - name: cm.name.clone(), - url: cm.url.clone(), - builtin: false, - }); - } - - mirrors -} - -/// 清除测速缓存 -pub fn clear_mirror_cache() { - let mut cache = MIRROR_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = None; -} - -// ==================== 测速逻辑 ==================== - -fn make_timestamp() -> u128 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() -} - -fn unavailable_result(mirror: &MirrorSource) -> MirrorTestResult { - MirrorTestResult { - name: mirror.name.clone(), - url: mirror.url.clone(), - bytes_downloaded: 0, - speed_mbps: 0.0, - ping_ms: 0, - available: false, - } -} - -/// 第一阶段:PING 测试,用小文件快速验证镜像是否可用,返回 (mirror, available, ping_ms) -/// 校验 mirror URL 是合法的 http(s) 绝对地址且 host 非空。非法 URL(空、缺 scheme/host、 -/// 格式错误)直接判定不可用,避免与基础路径拼接后误打到某个意外解析出的主机(DNS 偶然性导致测试/行为不确定)。 -fn is_valid_mirror_url(raw: &str) -> bool { - match url::Url::parse(raw) { - Ok(u) => { - matches!(u.scheme(), "http" | "https") - && u.host_str().map(|h| !h.is_empty()).unwrap_or(false) - } - Err(_) => false, - } -} - -async fn ping_mirror(mirror: &MirrorSource) -> (MirrorSource, bool, u64) { - if !is_valid_mirror_url(&mirror.url) { - log::warn!( - "[mirror] PING {} skipped: invalid URL '{}'", - mirror.name, - mirror.url - ); - return (mirror.clone(), false, 0); - } - - let test_url = format!( - "{}{}?t={}", - mirror.url, - PING_TEST_BASE_URL, - make_timestamp() - ); - - log::info!("[mirror] PING {}: {}", mirror.name, test_url); - - let client = match reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(PING_TIMEOUT_SECS)) - .build() - { - Ok(c) => c, - Err(_) => return (mirror.clone(), false, 0), - }; - - let start = Instant::now(); - - match client.get(&test_url).send().await { - Ok(r) if r.status().is_success() => match r.bytes().await { - Ok(body) if body.len() > PING_MIN_VALID_BYTES => { - let ping_ms = start.elapsed().as_millis() as u64; - log::info!( - "[mirror] PING {} OK ({} bytes, {}ms)", - mirror.name, - body.len(), - ping_ms - ); - (mirror.clone(), true, ping_ms) - } - Ok(body) => { - log::warn!( - "[mirror] PING {} returned suspicious body size ({} bytes < {} threshold), likely error page", - mirror.name, body.len(), PING_MIN_VALID_BYTES - ); - (mirror.clone(), false, 0) - } - Err(e) => { - log::warn!("[mirror] PING {} body read failed: {}", mirror.name, e); - (mirror.clone(), false, 0) - } - }, - Ok(r) => { - log::warn!("[mirror] PING {} returned HTTP {}", mirror.name, r.status()); - (mirror.clone(), false, 0) - } - Err(e) => { - log::warn!("[mirror] PING {} failed: {}", mirror.name, e); - (mirror.clone(), false, 0) - } - } -} - -/// 第二阶段:对存活源进行限时下载测速 -async fn speed_test_mirror(mirror: &MirrorSource) -> MirrorTestResult { - if !is_valid_mirror_url(&mirror.url) { - log::warn!( - "[mirror] Speed test {} skipped: invalid URL '{}'", - mirror.name, - mirror.url - ); - return unavailable_result(mirror); - } - - let test_url = format!( - "{}{}?t={}", - mirror.url, - SPEED_TEST_BASE_URL, - make_timestamp() - ); - - log::info!("[mirror] Speed testing {}: {}", mirror.name, test_url); - - let client = match reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(SPEED_TEST_DURATION_SECS + 2)) - .build() - { - Ok(c) => c, - Err(e) => { - log::warn!("[mirror] Failed to build client for {}: {}", mirror.name, e); - return unavailable_result(mirror); - } - }; - - let resp = match client.get(&test_url).send().await { - Ok(r) if r.status().is_success() => r, - Ok(r) => { - log::warn!("[mirror] {} returned HTTP {}", mirror.name, r.status()); - return unavailable_result(mirror); - } - Err(e) => { - log::warn!("[mirror] {} connection failed: {}", mirror.name, e); - return unavailable_result(mirror); - } - }; - - use futures_util::StreamExt; - let start = Instant::now(); - let deadline = start + std::time::Duration::from_secs(SPEED_TEST_DURATION_SECS); - let mut total_bytes: u64 = 0; - let mut stream = resp.bytes_stream(); - - while let Some(chunk_result) = stream.next().await { - if Instant::now() >= deadline { - break; - } - match chunk_result { - Ok(chunk) => { - total_bytes += chunk.len() as u64; - } - Err(e) => { - log::warn!("[mirror] {} stream error: {}", mirror.name, e); - break; - } - } - } - - let elapsed = start.elapsed().as_secs_f64(); - let speed_mbps = if elapsed > 0.0 { - (total_bytes as f64) / elapsed / 1_048_576.0 - } else { - 0.0 - }; - - log::info!( - "[mirror] {} result: {} bytes in {:.1}s = {:.2} MB/s", - mirror.name, - total_bytes, - elapsed, - speed_mbps - ); - - // 保留缓存中已有的 ping_ms - let ping_ms = { - let cache = MIRROR_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - cache - .as_ref() - .and_then(|(_, results)| results.iter().find(|r| r.url == mirror.url)) - .map(|r| r.ping_ms) - .unwrap_or(0) - }; - - MirrorTestResult { - name: mirror.name.clone(), - url: mirror.url.clone(), - bytes_downloaded: total_bytes, - speed_mbps: (speed_mbps * 100.0).round() / 100.0, - ping_ms, - available: total_bytes > SPEED_TEST_MIN_VALID_BYTES, - } -} - -/// 并发 PING 所有镜像源,PING 通过即标记为可用(不做吞吐量测速) -pub async fn ping_all_mirrors() -> Vec { - let mirrors = get_all_mirrors(); - log::info!( - "[mirror] PING testing {} mirrors ({}s timeout)...", - mirrors.len(), - PING_TIMEOUT_SECS - ); - - let ping_handles: Vec<_> = mirrors - .iter() - .map(|m| { - let m = m.clone(); - tokio::spawn(async move { ping_mirror(&m).await }) - }) - .collect(); - - let mut results: Vec = Vec::new(); - - for handle in ping_handles { - if let Ok((mirror, alive, ping_ms)) = handle.await { - if alive { - results.push(MirrorTestResult { - name: mirror.name.clone(), - url: mirror.url.clone(), - bytes_downloaded: 0, - speed_mbps: 0.0, - ping_ms, - available: true, - }); - } else { - results.push(unavailable_result(&mirror)); - } - } - } - - // 可用源按 ping 延迟升序排列,不可用源排末尾 - results.sort_by(|a, b| { - b.available - .cmp(&a.available) - .then(a.ping_ms.cmp(&b.ping_ms)) - }); - - // 更新缓存 - { - let mut cache = MIRROR_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = Some((Instant::now(), results.clone())); - } - - log::info!( - "[mirror] PING complete: {}/{} available", - results.iter().filter(|r| r.available).count(), - results.len() - ); - results -} - -/// 对单个镜像源进行吞吐量测速(10 秒),返回更新后的结果 -pub async fn speed_test_single(mirror_url: &str) -> Option { - let mirrors = get_all_mirrors(); - let mirror = mirrors.iter().find(|m| m.url == mirror_url)?; - let result = speed_test_mirror(mirror).await; - - // 更新缓存中该源的结果 - { - let mut cache = MIRROR_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if let Some((_, ref mut results)) = *cache { - if let Some(existing) = results.iter_mut().find(|r| r.url == mirror_url) { - *existing = result.clone(); - } - } - } - - Some(result) -} - -/// 读取缓存的测速结果(不触发新测速) -pub fn get_cached_results() -> Vec { - let cache = MIRROR_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - match &*cache { - Some((_, results)) => results.clone(), - None => Vec::new(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use serial_test::serial; - use std::path::PathBuf; - - struct ConfigCacheGuard { - previous: Option, - _lock: FileLockGuard, - } - - impl ConfigCacheGuard { - fn with_custom_mirrors(custom_mirrors: Vec) -> Self { - let lock = FileLockGuard::acquire(); - let mut config = crate::types::GlobalConfig::default(); - config.custom_mirrors = custom_mirrors; - let previous = { - let mut cache = crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::replace(&mut *cache, Some(config)) - }; - Self { - previous, - _lock: lock, - } - } - } - - impl Drop for ConfigCacheGuard { - fn drop(&mut self) { - let mut cache = crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = self.previous.take(); - } - } - - struct FileLockGuard { - path: PathBuf, - } - - impl FileLockGuard { - fn acquire() -> Self { - let path = std::env::temp_dir().join("worktree-manager-global-config-cache.lock"); - for _ in 0..500 { - match std::fs::create_dir(&path) { - Ok(()) => return Self { path }, - Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { - std::thread::sleep(std::time::Duration::from_millis(2)); - } - Err(err) => panic!("failed to create test lock {:?}: {}", path, err), - } - } - panic!("timed out waiting for test lock {:?}", path); - } - } - - impl Drop for FileLockGuard { - fn drop(&mut self) { - let _ = std::fs::remove_dir(&self.path); - } - } - - fn sample_result(name: &str, url: &str, available: bool, ping_ms: u64) -> MirrorTestResult { - MirrorTestResult { - name: name.to_string(), - url: url.to_string(), - bytes_downloaded: if available { 120_000 } else { 0 }, - speed_mbps: if available { 1.25 } else { 0.0 }, - ping_ms, - available, - } - } - - #[serial] - #[test] - fn mirror_types_round_trip_json() { - let source = MirrorSource { - name: "custom".to_string(), - url: "https://mirror.example/".to_string(), - builtin: false, - }; - let source_json = serde_json::to_value(&source).unwrap(); - assert_eq!( - source_json, - json!({"name":"custom","url":"https://mirror.example/","builtin":false}) - ); - let decoded_source: MirrorSource = serde_json::from_value(source_json).unwrap(); - assert_eq!(decoded_source.name, "custom"); - assert_eq!(decoded_source.url, "https://mirror.example/"); - assert!(!decoded_source.builtin); - - let result = sample_result("fast", "https://fast.example/", true, 27); - let decoded_result: MirrorTestResult = - serde_json::from_str(&serde_json::to_string(&result).unwrap()).unwrap(); - assert_eq!(decoded_result.name, "fast"); - assert_eq!(decoded_result.bytes_downloaded, 120_000); - assert_eq!(decoded_result.speed_mbps, 1.25); - assert_eq!(decoded_result.ping_ms, 27); - assert!(decoded_result.available); - } - - #[serial] - #[test] - fn get_all_mirrors_appends_custom_mirrors_after_builtins() { - let _config = ConfigCacheGuard::with_custom_mirrors(vec![crate::types::CustomMirror { - name: "office".to_string(), - url: "https://office.example/".to_string(), - }]); - - let mirrors = get_all_mirrors(); - - assert_eq!(mirrors[0].name, "gh-proxy.org"); - assert_eq!(mirrors[0].url, "https://gh-proxy.org/"); - assert!(mirrors[0].builtin); - let custom = mirrors.last().unwrap(); - assert_eq!(custom.name, "office"); - assert_eq!(custom.url, "https://office.example/"); - assert!(!custom.builtin); - } - - #[serial] - #[test] - fn unavailable_result_preserves_mirror_identity_with_zero_metrics() { - let mirror = MirrorSource { - name: "down".to_string(), - url: "https://down.example/".to_string(), - builtin: false, - }; - - let result = unavailable_result(&mirror); - - assert_eq!(result.name, "down"); - assert_eq!(result.url, "https://down.example/"); - assert_eq!(result.bytes_downloaded, 0); - assert_eq!(result.speed_mbps, 0.0); - assert_eq!(result.ping_ms, 0); - assert!(!result.available); - } - - #[serial] - #[test] - fn cache_returns_cloned_results_and_clear_empties_it() { - clear_mirror_cache(); - { - let mut cache = MIRROR_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = Some(( - Instant::now(), - vec![ - sample_result("fast", "https://fast.example/", true, 10), - sample_result("slow", "https://slow.example/", true, 80), - ], - )); - } - - let first_read = get_cached_results(); - assert_eq!(first_read.len(), 2); - assert_eq!(first_read[0].url, "https://fast.example/"); - assert_eq!(first_read[1].ping_ms, 80); - - clear_mirror_cache(); - assert!(get_cached_results().is_empty()); - } - - #[serial] - #[tokio::test] - async fn ping_mirror_returns_unavailable_for_invalid_mirror_url_without_network() { - let mirror = MirrorSource { - name: "invalid".to_string(), - url: "://bad-mirror/".to_string(), - builtin: false, - }; - - let (tested_mirror, available, ping_ms) = ping_mirror(&mirror).await; - - assert_eq!(tested_mirror.name, "invalid"); - assert_eq!(tested_mirror.url, "://bad-mirror/"); - assert!(!available); - assert_eq!(ping_ms, 0); - } - - #[serial] - #[tokio::test] - async fn speed_test_invalid_url_returns_unavailable_without_network() { - let mirror = MirrorSource { - name: "invalid-speed".to_string(), - url: "://bad-mirror/".to_string(), - builtin: false, - }; - { - let mut cache = MIRROR_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = Some(( - Instant::now(), - vec![sample_result("cached", "://bad-mirror/", true, 42)], - )); - } - - let result = speed_test_mirror(&mirror).await; - - assert_eq!(result.name, "invalid-speed"); - assert_eq!(result.url, "://bad-mirror/"); - assert_eq!(result.bytes_downloaded, 0); - assert_eq!(result.speed_mbps, 0.0); - assert_eq!(result.ping_ms, 0); - assert!(!result.available); - } - - #[serial] - #[tokio::test] - async fn speed_test_single_unknown_url_returns_none_without_request() { - let _config = ConfigCacheGuard::with_custom_mirrors(Vec::new()); - - let result = speed_test_single("https://missing.example/").await; - - assert!(result.is_none()); - } - - // URL rewrite capture and full ranking through `ping_all_mirrors` intentionally are not - // tested here: the sandbox denies local socket binds, and built-in mirrors are external. -} - -#[cfg(test)] -mod coverage_completion_tests { - use super::*; - #[cfg(any())] - use axum::{ - extract::State, - http::{StatusCode, Uri}, - response::{IntoResponse, Response}, - routing::get, - Router, - }; - use serial_test::serial; - #[cfg(any())] - use std::collections::VecDeque; - use std::path::PathBuf; - #[cfg(any())] - use std::sync::{Arc, Mutex}; - #[cfg(any())] - use tokio::net::TcpListener; - - struct ConfigCacheGuard { - previous: Option, - _lock: FileLockGuard, - } - - impl ConfigCacheGuard { - fn with_custom_mirrors(custom_mirrors: Vec) -> Self { - let lock = FileLockGuard::acquire(); - let mut config = crate::types::GlobalConfig::default(); - config.custom_mirrors = custom_mirrors; - let previous = { - let mut cache = crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - std::mem::replace(&mut *cache, Some(config)) - }; - Self { - previous, - _lock: lock, - } - } - } - - impl Drop for ConfigCacheGuard { - fn drop(&mut self) { - let mut cache = crate::state::GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = self.previous.take(); - clear_mirror_cache(); - } - } - - struct FileLockGuard { - path: PathBuf, - } - - impl FileLockGuard { - fn acquire() -> Self { - let path = std::env::temp_dir().join("worktree-manager-global-config-cache.lock"); - for _ in 0..500 { - match std::fs::create_dir(&path) { - Ok(()) => return Self { path }, - Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { - std::thread::sleep(std::time::Duration::from_millis(2)); - } - Err(err) => panic!("failed to create test lock {:?}: {}", path, err), - } - } - panic!("timed out waiting for test lock {:?}", path); - } - } - - impl Drop for FileLockGuard { - fn drop(&mut self) { - let _ = std::fs::remove_dir(&self.path); - } - } - - fn custom_mirror(name: &str, url: &str) -> crate::types::CustomMirror { - crate::types::CustomMirror { - name: name.to_string(), - url: url.to_string(), - } - } - - fn result(name: &str, available: bool, ping_ms: u64, speed_mbps: f64) -> MirrorTestResult { - MirrorTestResult { - name: name.to_string(), - url: format!("https://{name}.example/"), - bytes_downloaded: if available { 200_000 } else { 0 }, - speed_mbps, - ping_ms, - available, - } - } - - // Local mock-server coverage is intentionally compiled out in this sandbox: - // binding 127.0.0.1:0 returns PermissionDenied, while public mirrors are external. - #[cfg(any())] - #[derive(Clone)] - struct MockResponse { - status: StatusCode, - bytes: usize, - } - - #[cfg(any())] - #[derive(Default)] - struct MockMirrorState { - responses: VecDeque, - requested_uris: Vec, - } - - #[cfg(any())] - async fn spawn_mirror_server(state: Arc>) -> String { - let app = Router::new() - .route( - "/{*path}", - get( - |uri: Uri, State(state): State>>| async move { - let response = { - let mut state = state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state.requested_uris.push(uri.to_string()); - state.responses.pop_front().expect("queued mirror response") - }; - let body = vec![b'x'; response.bytes]; - Response::from((response.status, body).into_response()) - }, - ), - ) - .with_state(state); - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("bind mirror mock server"); - let addr = listener.local_addr().expect("mirror mock addr"); - tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - format!("http://{}/", addr) - } - - #[serial] - #[test] - fn builtin_and_custom_mirror_selection_preserves_order_and_flags() { - let _config = ConfigCacheGuard::with_custom_mirrors(vec![ - custom_mirror("office-a", "https://office-a.example/"), - custom_mirror("office-b", "https://office-b.example/"), - ]); - - let mirrors = get_all_mirrors(); - - assert_eq!(mirrors.len(), BUILTIN_MIRRORS.len() + 2); - assert_eq!(mirrors[0].name, BUILTIN_MIRRORS[0].0); - assert_eq!(mirrors[0].url, BUILTIN_MIRRORS[0].1); - assert!(mirrors[..BUILTIN_MIRRORS.len()] - .iter() - .all(|mirror| mirror.builtin)); - assert_eq!(mirrors[BUILTIN_MIRRORS.len()].name, "office-a".to_string()); - assert_eq!(mirrors.last().unwrap().name, "office-b"); - assert!(!mirrors.last().unwrap().builtin); - } - - #[serial] - #[test] - fn timestamp_cache_key_component_is_nonzero_and_monotonic() { - let first = make_timestamp(); - let second = make_timestamp(); - - assert!(first > 0); - assert!(second >= first); - } - - #[serial] - #[test] - fn speed_test_sorting_orders_available_by_ping_then_unavailable_last() { - let mut results = vec![ - result("down-fast-ping", false, 1, 0.0), - result("available-slow-ping", true, 80, 1.0), - result("available-fast-ping", true, 12, 0.5), - result("down-slow-ping", false, 99, 0.0), - ]; - - results.sort_by(|a, b| { - b.available - .cmp(&a.available) - .then(a.ping_ms.cmp(&b.ping_ms)) - }); - - let names = results - .iter() - .map(|result| result.name.as_str()) - .collect::>(); - assert_eq!( - names, - vec![ - "available-fast-ping", - "available-slow-ping", - "down-fast-ping", - "down-slow-ping" - ] - ); - } - - #[serial] - #[tokio::test] - async fn speed_test_single_updates_cached_entry_by_mirror_url() { - let _config = ConfigCacheGuard::with_custom_mirrors(vec![custom_mirror( - "invalid-custom", - "://bad-mirror/", - )]); - clear_mirror_cache(); - { - let mut cache = MIRROR_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = Some(( - Instant::now(), - vec![MirrorTestResult { - name: "old-cache-name".to_string(), - url: "://bad-mirror/".to_string(), - bytes_downloaded: 123, - speed_mbps: 9.9, - ping_ms: 55, - available: true, - }], - )); - } - - let result = speed_test_single("://bad-mirror/") - .await - .expect("custom mirror should be selected"); - let cached = get_cached_results(); - - assert_eq!(result.name, "invalid-custom"); - assert_eq!(result.url, "://bad-mirror/"); - assert!(!result.available); - assert_eq!(result.bytes_downloaded, 0); - assert_eq!(cached.len(), 1); - assert_eq!(cached[0].name, "invalid-custom"); - assert_eq!(cached[0].url, "://bad-mirror/"); - assert!(!cached[0].available); - } - - #[serial] - #[test] - fn mirror_source_matrix_preserves_builtin_and_custom_ordering() { - let custom = vec![ - custom_mirror("office-alpha", "https://office-alpha.example/"), - custom_mirror("office-beta", "https://office-beta.example/base/"), - custom_mirror("office-gamma", "https://office-gamma.example/proxy/"), - custom_mirror("office-delta", "https://office-delta.example/"), - custom_mirror("office-epsilon", "https://office-epsilon.example/"), - ]; - let _config = ConfigCacheGuard::with_custom_mirrors(custom); - - let mirrors = get_all_mirrors(); - - assert_eq!(mirrors.len(), BUILTIN_MIRRORS.len() + 5); - assert_eq!(mirrors[0].name, "gh-proxy.org"); - assert_eq!(mirrors[1].name, "ghproxy.net"); - assert_eq!(mirrors[2].name, "mirror.ghproxy.com"); - assert_eq!(mirrors[3].name, "gh.llkk.cc"); - assert_eq!(mirrors[4].name, "github.moeyy.xyz"); - assert_eq!(mirrors[5].name, "ghps.cc"); - assert_eq!(mirrors[6].name, "cf.ghproxy.cc"); - assert_eq!(mirrors[7].name, "gh.noki.icu"); - assert_eq!(mirrors[8].name, "ghproxy.cn"); - assert!(mirrors[..BUILTIN_MIRRORS.len()] - .iter() - .all(|mirror| mirror.builtin)); - assert_eq!(mirrors[BUILTIN_MIRRORS.len()].name, "office-alpha"); - assert_eq!( - mirrors[BUILTIN_MIRRORS.len()].url, - "https://office-alpha.example/" - ); - assert_eq!(mirrors[BUILTIN_MIRRORS.len() + 1].name, "office-beta"); - assert_eq!( - mirrors[BUILTIN_MIRRORS.len() + 1].url, - "https://office-beta.example/base/" - ); - assert_eq!(mirrors[BUILTIN_MIRRORS.len() + 2].name, "office-gamma"); - assert_eq!( - mirrors[BUILTIN_MIRRORS.len() + 2].url, - "https://office-gamma.example/proxy/" - ); - assert_eq!(mirrors[BUILTIN_MIRRORS.len() + 3].name, "office-delta"); - assert_eq!(mirrors[BUILTIN_MIRRORS.len() + 4].name, "office-epsilon"); - assert!(!mirrors[BUILTIN_MIRRORS.len()].builtin); - assert!(!mirrors[BUILTIN_MIRRORS.len() + 4].builtin); - } - - #[serial] - #[test] - fn mirror_cache_matrix_returns_clones_and_replaces_exact_entries() { - clear_mirror_cache(); - let original = vec![ - result("alpha", true, 9, 3.4), - result("beta", true, 20, 1.2), - result("gamma", false, 0, 0.0), - result("delta", true, 45, 0.8), - result("epsilon", false, 0, 0.0), - ]; - { - let mut cache = MIRROR_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = Some((Instant::now(), original.clone())); - } - - let first = get_cached_results(); - let mut mutated = first.clone(); - mutated[0].name = "mutated".to_string(); - mutated[1].available = false; - let second = get_cached_results(); - - assert_eq!(second[0].name, "alpha"); - assert_eq!(second[0].url, "https://alpha.example/"); - assert_eq!(second[0].bytes_downloaded, 200_000); - assert_eq!(second[0].speed_mbps, 3.4); - assert_eq!(second[0].ping_ms, 9); - assert!(second[0].available); - assert_eq!(second[1].name, "beta"); - assert_eq!(second[1].url, "https://beta.example/"); - assert_eq!(second[1].speed_mbps, 1.2); - assert_eq!(second[1].ping_ms, 20); - assert!(second[1].available); - assert_eq!(second[2].name, "gamma"); - assert_eq!(second[2].bytes_downloaded, 0); - assert_eq!(second[2].speed_mbps, 0.0); - assert!(!second[2].available); - assert_eq!(second[3].name, "delta"); - assert_eq!(second[3].ping_ms, 45); - assert!(second[3].available); - assert_eq!(second[4].name, "epsilon"); - assert!(!second[4].available); - - clear_mirror_cache(); - assert!(get_cached_results().is_empty()); - } - - #[serial] - #[test] - fn mirror_sort_matrix_orders_available_sources_by_ping() { - let mut cases = vec![ - result("unavailable-low-ping", false, 1, 0.0), - result("available-medium", true, 30, 1.0), - result("available-low", true, 5, 4.0), - result("available-high", true, 90, 0.2), - result("unavailable-zero", false, 0, 0.0), - result("available-equal-a", true, 30, 2.0), - result("available-equal-b", true, 30, 2.5), - result("unavailable-high-ping", false, 99, 0.0), - ]; - - cases.sort_by(|a, b| { - b.available - .cmp(&a.available) - .then(a.ping_ms.cmp(&b.ping_ms)) - }); - - assert_eq!(cases[0].name, "available-low"); - assert_eq!(cases[0].ping_ms, 5); - assert!(cases[0].available); - assert_eq!(cases[1].name, "available-medium"); - assert_eq!(cases[1].ping_ms, 30); - assert!(cases[1].available); - assert_eq!(cases[2].name, "available-equal-a"); - assert_eq!(cases[2].ping_ms, 30); - assert!(cases[2].available); - assert_eq!(cases[3].name, "available-equal-b"); - assert_eq!(cases[3].ping_ms, 30); - assert!(cases[3].available); - assert_eq!(cases[4].name, "available-high"); - assert_eq!(cases[4].ping_ms, 90); - assert!(cases[4].available); - assert!(!cases[5].available); - assert!(!cases[6].available); - assert!(!cases[7].available); - } - - #[serial] - #[tokio::test] - async fn invalid_mirror_url_matrix_maps_to_unavailable_results() { - let cases = [ - ("empty", ""), - ("bad-scheme", "://bad-mirror/"), - ("missing-scheme", "mirror.example/"), - ("malformed-ipv6", "http://[::1"), - ("missing-host", "http://"), - ]; - - for (name, url) in cases { - let mirror = MirrorSource { - name: name.to_string(), - url: url.to_string(), - builtin: false, - }; - - let (ping_source, available, ping_ms) = ping_mirror(&mirror).await; - let speed = speed_test_mirror(&mirror).await; - - assert_eq!(ping_source.name, name); - assert_eq!(ping_source.url, url); - assert!(!available, "{name} ping should be unavailable"); - assert_eq!(ping_ms, 0); - assert_eq!(speed.name, name); - assert_eq!(speed.url, url); - assert_eq!(speed.bytes_downloaded, 0); - assert_eq!(speed.speed_mbps, 0.0); - assert_eq!(speed.ping_ms, 0); - assert!(!speed.available, "{name} speed should be unavailable"); - } - } - - #[cfg(any())] - #[serial] - #[tokio::test] - async fn ping_mirror_rewrites_to_local_server_and_classifies_body_sizes() { - let state = Arc::new(Mutex::new(MockMirrorState { - responses: VecDeque::from([ - MockResponse { - status: StatusCode::OK, - bytes: PING_MIN_VALID_BYTES + 1, - }, - MockResponse { - status: StatusCode::OK, - bytes: PING_MIN_VALID_BYTES - 1, - }, - MockResponse { - status: StatusCode::BAD_GATEWAY, - bytes: 8, - }, - ]), - ..MockMirrorState::default() - })); - let base_url = spawn_mirror_server(state.clone()).await; - let mirror = MirrorSource { - name: "local-ping".to_string(), - url: base_url, - builtin: false, - }; - - let (first_mirror, first_available, first_ping) = ping_mirror(&mirror).await; - let (_, second_available, second_ping) = ping_mirror(&mirror).await; - let (_, third_available, third_ping) = ping_mirror(&mirror).await; - - let state = state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - assert_eq!(first_mirror.name, "local-ping"); - assert!(first_available); - assert!(first_ping <= PING_TIMEOUT_SECS * 1000); - assert!(!second_available); - assert_eq!(second_ping, 0); - assert!(!third_available); - assert_eq!(third_ping, 0); - assert_eq!(state.requested_uris.len(), 3); - assert!(state.requested_uris[0].contains("raw.githubusercontent.com")); - assert!(state.requested_uris[0].contains("favicon.ico")); - assert!(state.requested_uris[0].contains("?t=")); - } - - #[cfg(any())] - #[serial] - #[tokio::test] - async fn speed_test_mirror_downloads_from_local_server_and_preserves_cached_ping() { - let state = Arc::new(Mutex::new(MockMirrorState { - responses: VecDeque::from([MockResponse { - status: StatusCode::OK, - bytes: SPEED_TEST_MIN_VALID_BYTES as usize + 1, - }]), - ..MockMirrorState::default() - })); - let base_url = spawn_mirror_server(state.clone()).await; - let mirror = MirrorSource { - name: "local-speed".to_string(), - url: base_url.clone(), - builtin: false, - }; - { - let mut cache = MIRROR_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = Some(( - Instant::now(), - vec![MirrorTestResult { - name: "cached-speed".to_string(), - url: base_url.clone(), - bytes_downloaded: 0, - speed_mbps: 0.0, - ping_ms: 37, - available: true, - }], - )); - } - - let result = speed_test_mirror(&mirror).await; - - let state = state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - assert_eq!(result.name, "local-speed"); - assert_eq!(result.url, base_url); - assert_eq!(result.bytes_downloaded, SPEED_TEST_MIN_VALID_BYTES + 1); - assert_eq!(result.ping_ms, 37); - assert!(result.available); - assert!(result.speed_mbps >= 0.0); - assert_eq!(state.requested_uris.len(), 1); - assert!(state.requested_uris[0].contains("github.com/pypa/pip")); - assert!(state.requested_uris[0].contains("24.3.1.zip")); - } - - #[cfg(any())] - #[serial] - #[tokio::test] - async fn speed_test_single_selects_custom_local_mirror_and_updates_cache() { - let state = Arc::new(Mutex::new(MockMirrorState { - responses: VecDeque::from([MockResponse { - status: StatusCode::OK, - bytes: SPEED_TEST_MIN_VALID_BYTES as usize + 5, - }]), - ..MockMirrorState::default() - })); - let base_url = spawn_mirror_server(state).await; - let _config = - ConfigCacheGuard::with_custom_mirrors(vec![custom_mirror("local-custom", &base_url)]); - clear_mirror_cache(); - { - let mut cache = MIRROR_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *cache = Some(( - Instant::now(), - vec![MirrorTestResult { - name: "old-custom".to_string(), - url: base_url.clone(), - bytes_downloaded: 1, - speed_mbps: 0.01, - ping_ms: 19, - available: false, - }], - )); - } - - let result = speed_test_single(&base_url) - .await - .expect("custom mirror should be found"); - let cached = get_cached_results(); - - assert_eq!(result.name, "local-custom"); - assert_eq!(result.bytes_downloaded, SPEED_TEST_MIN_VALID_BYTES + 5); - assert_eq!(result.ping_ms, 19); - assert!(result.available); - assert_eq!(cached.len(), 1); - assert_eq!(cached[0].name, "local-custom"); - assert_eq!(cached[0].url, base_url); - assert_eq!(cached[0].bytes_downloaded, SPEED_TEST_MIN_VALID_BYTES + 5); - assert!(cached[0].available); - } -} diff --git a/src-tauri/src/pty_manager.rs b/src-tauri/src/pty_manager.rs deleted file mode 100644 index 3e839f8..0000000 --- a/src-tauri/src/pty_manager.rs +++ /dev/null @@ -1,1588 +0,0 @@ -use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; -use std::collections::{HashMap, VecDeque}; -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex, OnceLock, RwLock}; -use std::time::{Duration, Instant}; -use tauri::Emitter; -use tokio::sync::broadcast; - -/// Max replay buffer size per session (64 KB) -const REPLAY_BUFFER_CAP: usize = 64 * 1024; -/// Keep up to 8 MB of desktop PTY output so remounted terminals can replay recent data -/// without letting abandoned sessions grow without bound. -const DESKTOP_PENDING_BUFFER_CAP: usize = 8 * 1024 * 1024; -/// Drop desktop reader cursors that have stopped polling so they no longer pin backlog in memory. -const DESKTOP_READER_TTL: Duration = Duration::from_secs(10); - -/// Shell integration script directory (set once during app setup) -static SHELL_INTEGRATION_DIR: OnceLock = OnceLock::new(); - -#[cfg(target_os = "windows")] -fn resolve_git_bash_path() -> Option { - let candidates = [ - r"C:\Program Files\Git\bin\bash.exe", - r"C:\Program Files (x86)\Git\bin\bash.exe", - ]; - for path in &candidates { - if std::path::Path::new(path).exists() { - return Some(path.to_string()); - } - } - - if let Ok(local) = std::env::var("LOCALAPPDATA") { - let path = format!(r"{}\Programs\Git\bin\bash.exe", local); - if std::path::Path::new(&path).exists() { - return Some(path); - } - } - - None -} - -fn resolve_shell_from_path_lookup(id: &str) -> Option { - // First check if it's an absolute path - if std::path::Path::new(id).is_absolute() && std::path::Path::new(id).exists() { - return Some(id.to_string()); - } - - #[cfg(not(target_os = "windows"))] - { - let output = std::process::Command::new("which") - .arg(id) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) - .output(); - if let Ok(out) = output { - if out.status.success() { - let path = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if !path.is_empty() { - return Some(path); - } - } - } - } - - #[cfg(target_os = "windows")] - { - use std::os::windows::process::CommandExt; - - const CREATE_NO_WINDOW: u32 = 0x08000000; - let mut command = std::process::Command::new("where"); - command - .arg(id) - .creation_flags(CREATE_NO_WINDOW) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()); - let output = command.output(); - if let Ok(out) = output { - if out.status.success() { - if let Some(path) = String::from_utf8_lossy(&out.stdout).lines().next() { - let path = path.trim().to_string(); - if !path.is_empty() { - return Some(path); - } - } - } - } - } - - None -} - -/// Get the default shell for the current platform. -/// Windows: COMSPEC -> PowerShell -> cmd.exe -/// Unix: SHELL -> /bin/zsh -> /bin/bash -fn get_default_shell() -> String { - #[cfg(target_os = "windows")] - { - if let Ok(comspec) = std::env::var("COMSPEC") { - return comspec; - } - // Try PowerShell - let ps_paths = [ - "C:\\Program Files\\PowerShell\\7\\pwsh.exe", - "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", - ]; - for ps in &ps_paths { - if std::path::Path::new(ps).exists() { - return ps.to_string(); - } - } - "cmd.exe".to_string() - } - #[cfg(not(target_os = "windows"))] - { - std::env::var("SHELL").unwrap_or_else(|_| { - if std::path::Path::new("/bin/zsh").exists() { - "/bin/zsh".to_string() - } else { - "/bin/bash".to_string() - } - }) - } -} - -/// Resolve a terminal preference ID to a shell executable path. -/// IDs like "cmd", "powershell", "gitbash", "windowsterminal" come from the frontend settings. -/// If the ID is "auto" or empty, falls back to get_default_shell(). -fn resolve_shell_from_id(id: &str) -> String { - match id { - "auto" | "" => get_default_shell(), - #[cfg(target_os = "windows")] - "cmd" => "cmd.exe".to_string(), - #[cfg(target_os = "windows")] - "powershell" => { - let ps_paths = [ - "C:\\Program Files\\PowerShell\\7\\pwsh.exe", - "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", - ]; - for ps in &ps_paths { - if std::path::Path::new(ps).exists() { - return ps.to_string(); - } - } - "powershell.exe".to_string() - } - #[cfg(target_os = "windows")] - "gitbash" => resolve_git_bash_path().unwrap_or_else(|| "bash.exe".to_string()), - #[cfg(target_os = "windows")] - "bash" => { - if let Some(path) = resolve_git_bash_path() { - return path; - } - if let Some(path) = resolve_shell_from_path_lookup("bash") { - return path; - } - log::warn!("[pty] Shell 'bash' not found, using default shell"); - get_default_shell() - } - #[cfg(not(target_os = "windows"))] - "pwsh" | "powershell" => { - log::warn!( - "[pty] PowerShell shells are only supported on Windows, using default shell" - ); - get_default_shell() - } - // Shell IDs: zsh, bash, fish, nu, pwsh (on Windows) — resolve via PATH lookup. - // Note: "pwsh" intentionally uses PATH lookup rather than a hardcoded path because - // PowerShell 7 has many install locations (system-wide, per-user, scoop, winget). - // "powershell" uses hardcoded paths because Windows PowerShell 5.x is always in - // System32 and resolving it via `where` is slower and less reliable. - other => { - if let Some(path) = resolve_shell_from_path_lookup(other) { - return path; - } - log::warn!("[pty] Shell '{}' not found, using default shell", other); - get_default_shell() - } - } -} - -pub(crate) fn requested_shell_path(shell: Option<&str>) -> String { - match shell { - Some(s) if !s.is_empty() => resolve_shell_from_id(s), - _ => get_default_shell(), - } -} - -fn shell_program_name(shell_path: &str) -> String { - std::path::Path::new(shell_path) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(shell_path) - .trim_end_matches(".exe") - .to_ascii_lowercase() -} - -fn shell_startup_args(shell_path: &str) -> &'static [&'static str] { - match shell_program_name(shell_path).as_str() { - "zsh" | "bash" | "sh" => &["-i"], - _ => &[], - } -} - -fn shell_escape_single_quote(s: &str) -> String { - s.replace('\'', "'\\''") -} - -fn append_bash_integration_args(args: &mut Vec, init_file: String) { - args.retain(|arg| arg != "-i"); - args.push("--init-file".to_string()); - args.push(init_file); - args.push("-i".to_string()); -} - -/// Convert a Windows path string to a Git Bash-compatible Unix-style path. -/// Strips the `\\?\` long-path prefix then converts `C:\...` to `/c/...`. -fn windows_path_to_git_bash(path_str: &str) -> String { - // Remove Windows long path prefix \\?\ which Git Bash cannot handle. - let path_str = path_str.strip_prefix(r"\\?\").unwrap_or(path_str); - - // Convert Windows drive path to Unix-style path for Git Bash. - // C:\path\to\file -> /c/path/to/file - let mut chars = path_str.char_indices(); - if let (Some((_, drive)), Some((colon_idx, ':'))) = (chars.next(), chars.next()) { - if drive.is_ascii_alphabetic() { - let rest_start = colon_idx + ':'.len_utf8(); - let rest = path_str[rest_start..].replace('\\', "/"); - return format!("/{}{}", drive.to_ascii_lowercase(), rest); - } - } - - path_str.replace('\\', "/") -} - -fn bash_integration_init_path(integration_dir: &Path) -> Option { - let init_file = integration_dir.join("bash-init.sh"); - log::info!( - "[shell-integration] bash init_file exists: {}, path: {:?}", - init_file.exists(), - init_file - ); - if !init_file.exists() { - return None; - } - - let path_str = init_file.to_str()?; - Some(windows_path_to_git_bash(path_str)) -} - -fn get_zsh_integration_dir() -> PathBuf { - dirs::config_dir() - .unwrap_or_else(|| PathBuf::from("/tmp")) - .join("worktree-manager") - .join("shell-integration") - .join("zsh") -} - -#[cfg(target_os = "windows")] -fn powershell_integration_args(script: &Path) -> Option> { - let path_str = script.to_str()?; - // Remove Windows long path prefix \\?\ — neither PowerShell 5.x nor 7+ - // reliably supports it inside dot-source (. operator) invocations. - let path_str = path_str.strip_prefix(r"\\?\").unwrap_or(path_str); - let escaped = path_str.replace('\'', "''"); - Some(vec![ - "-noexit".to_string(), - "-nologo".to_string(), - "-ExecutionPolicy".to_string(), - "Bypass".to_string(), - "-command".to_string(), - format!(". '{}'", escaped), - ]) -} - -/// Initialize shell integration: store resource path and generate zsh ZDOTDIR wrappers. -/// Called once during app setup. -pub fn init_shell_integration(resource_dir: PathBuf) { - let integration_dir = resource_dir.join("shell-integration"); - if !integration_dir.exists() { - log::warn!( - "[shell-integration] Resource directory not found: {:?}", - integration_dir - ); - return; - } - - SHELL_INTEGRATION_DIR.set(integration_dir.clone()).ok(); - - // Generate zsh ZDOTDIR wrapper files - let zsh_dir = get_zsh_integration_dir(); - if let Err(e) = std::fs::create_dir_all(&zsh_dir) { - log::warn!( - "[shell-integration] Failed to create zsh wrapper dir: {}", - e - ); - return; - } - - let escaped_path = shell_escape_single_quote(integration_dir.to_str().unwrap_or_default()); - - // Generate .zshenv — sources user's original .zshenv - let zshenv_content = "# worktree-manager zsh env wrapper\n\ - # Source user's .zshenv from original ZDOTDIR\n\ - [ -f \"${_WM_ORIG_ZDOTDIR}/.zshenv\" ] && source \"${_WM_ORIG_ZDOTDIR}/.zshenv\"\n"; - if let Err(e) = std::fs::write(zsh_dir.join(".zshenv"), zshenv_content) { - log::warn!("[shell-integration] Failed to write .zshenv wrapper: {}", e); - return; - } - - // Generate .zshrc — sources user's .zshrc then shell integration - let zshrc_content = format!( - "# worktree-manager zsh init wrapper\n\ - # Restore original ZDOTDIR and source user's config\n\n\ - _WM_ZDOTDIR=\"${{ZDOTDIR}}\"\n\ - ZDOTDIR=\"${{_WM_ORIG_ZDOTDIR}}\"\n\n\ - # Source user's .zshrc from original ZDOTDIR\n\ - [ -f \"$ZDOTDIR/.zshrc\" ] && source \"$ZDOTDIR/.zshrc\"\n\n\ - # Source shell integration from Tauri resource directory\n\ - source '{}/zsh-integration.sh' 2>/dev/null\n", - escaped_path - ); - if let Err(e) = std::fs::write(zsh_dir.join(".zshrc"), zshrc_content) { - log::warn!("[shell-integration] Failed to write .zshrc wrapper: {}", e); - } - - log::info!( - "[shell-integration] Initialized, scripts at {:?}", - integration_dir - ); -} - -/// Configure a PTY command for shell integration based on shell type. -fn setup_shell_integration(cmd: &mut CommandBuilder, shell_path: &str, args: &mut Vec) { - let integration_dir = match SHELL_INTEGRATION_DIR.get() { - Some(dir) if dir.exists() => dir, - _ => return, - }; - - let config = crate::config::load_global_config(); - if !config.shell_integration_enabled { - return; - } - - cmd.env("TERM_PROGRAM", "worktree-manager"); - cmd.env("WORKTREE_MANAGER_SHELL_INTEGRATION", "1"); - - match shell_program_name(shell_path).as_str() { - "bash" => { - if let Some(unix_path) = bash_integration_init_path(integration_dir) { - log::info!( - "[shell-integration] Adding bash args: --init-file {} -i", - unix_path - ); - append_bash_integration_args(args, unix_path); - } else { - log::warn!("[shell-integration] Failed to resolve bash init file path"); - } - } - "zsh" => { - let zdotdir = get_zsh_integration_dir(); - if zdotdir.exists() { - let orig_zdotdir = std::env::var("ZDOTDIR") - .unwrap_or_else(|_| std::env::var("HOME").unwrap_or_default()); - cmd.env("_WM_ORIG_ZDOTDIR", &orig_zdotdir); - if let Some(dir_str) = zdotdir.to_str() { - cmd.env("ZDOTDIR", dir_str); - } - } - } - #[cfg(target_os = "windows")] - "pwsh" | "powershell" => { - let script = integration_dir.join("pwsh-integration.ps1"); - if script.exists() { - if let Some(script_args) = powershell_integration_args(&script) { - args.extend(script_args); - } - } - } - _ => {} - } -} - -/// Split raw bytes into valid UTF-8 text + incomplete trailing bytes. -/// -/// Invalid bytes in the middle are replaced with U+FFFD (same as `from_utf8_lossy`). -/// Incomplete multi-byte sequences at the very end are returned as pending bytes -/// to be prepended to the next chunk. -pub(crate) fn bytes_to_utf8_with_pending(data: &[u8]) -> (String, Vec) { - if data.is_empty() { - return (String::new(), vec![]); - } - - // Fast path: all valid UTF-8 - if let Ok(s) = std::str::from_utf8(data) { - return (s.to_string(), vec![]); - } - - let mut result = String::with_capacity(data.len()); - let mut remaining = data; - - loop { - match std::str::from_utf8(remaining) { - Ok(s) => { - result.push_str(s); - return (result, vec![]); - } - Err(e) => { - let valid_up_to = e.valid_up_to(); - // from_utf8 already validated this range, unwrap cannot panic - result.push_str(std::str::from_utf8(&remaining[..valid_up_to]).unwrap()); - - match e.error_len() { - Some(invalid_len) => { - // Genuinely invalid byte(s) — replace with U+FFFD and continue - result.push('\u{FFFD}'); - remaining = &remaining[valid_up_to + invalid_len..]; - } - None => { - // Incomplete multi-byte sequence at end — carry over - return (result, remaining[valid_up_to..].to_vec()); - } - } - } - } - } -} - -struct DesktopReaderState { - offset: u64, - utf8_pending: Vec, - last_read_at: Instant, -} - -impl DesktopReaderState { - fn new(offset: u64, now: Instant) -> Self { - Self { - offset, - utf8_pending: Vec::new(), - last_read_at: now, - } - } -} - -struct DesktopPendingBuffer { - bytes: VecDeque, - start_offset: u64, - end_offset: u64, - readers: HashMap, -} - -impl DesktopPendingBuffer { - fn new() -> Self { - Self { - bytes: VecDeque::new(), - start_offset: 0, - end_offset: 0, - readers: HashMap::new(), - } - } - - fn append(&mut self, data: &[u8]) { - if data.is_empty() { - return; - } - - self.cleanup_stale_readers(); - self.bytes.extend(data.iter().copied()); - self.end_offset += data.len() as u64; - self.compact(); - } - - fn read_for_reader(&mut self, reader_id: &str) -> Vec { - self.cleanup_stale_readers(); - let now = Instant::now(); - let start_offset = self.start_offset; - let end_offset = self.end_offset; - let reader = self - .readers - .entry(reader_id.to_string()) - .or_insert_with(|| DesktopReaderState::new(start_offset, now)); - - if reader.offset < start_offset { - reader.offset = start_offset; - reader.utf8_pending.clear(); - } - - let skip = (reader.offset.saturating_sub(start_offset)) as usize; - let mut result = std::mem::take(&mut reader.utf8_pending); - result.extend(self.bytes.iter().skip(skip).copied()); - reader.offset = end_offset; - reader.last_read_at = now; - - self.compact(); - result - } - - fn store_utf8_pending(&mut self, reader_id: &str, pending: Vec) { - if let Some(reader) = self.readers.get_mut(reader_id) { - reader.utf8_pending = pending; - reader.last_read_at = Instant::now(); - } - } - - fn cleanup_stale_readers(&mut self) { - self.readers - .retain(|_, reader| reader.last_read_at.elapsed() < DESKTOP_READER_TTL); - } - - fn compact(&mut self) { - let retain_from = self - .readers - .values() - .map(|reader| reader.offset) - .min() - .unwrap_or_else(|| { - self.end_offset - .saturating_sub(DESKTOP_PENDING_BUFFER_CAP as u64) - }); - - if retain_from > self.start_offset { - let drop_len = (retain_from - self.start_offset) as usize; - self.bytes.drain(..drop_len); - self.start_offset = retain_from; - } - - if self.bytes.len() > DESKTOP_PENDING_BUFFER_CAP { - let drop_len = self.bytes.len() - DESKTOP_PENDING_BUFFER_CAP; - self.bytes.drain(..drop_len); - self.start_offset += drop_len as u64; - } - - for reader in self.readers.values_mut() { - if reader.offset < self.start_offset { - reader.offset = self.start_offset; - reader.utf8_pending.clear(); - } - } - } -} - -struct PtyReader { - desktop_buffer: Arc>, -} - -pub struct PtySession { - master: Box, - writer: Box, - reader: PtyReader, - child: Box, - shell_path: String, - broadcast_tx: broadcast::Sender>, - /// Ring buffer of recent PTY output for replaying to new subscribers. - replay_buffer: Arc>>, -} - -impl PtySession { - /// Kill the child process and wait for it to exit with a timeout. - /// Uses try_wait to avoid blocking the calling thread indefinitely. - fn kill_and_wait(&mut self) { - let _ = self.child.kill(); - let start = Instant::now(); - let timeout = Duration::from_secs(2); - while start.elapsed() < timeout { - match self.child.try_wait() { - Ok(Some(_)) => return, - Ok(None) => std::thread::sleep(Duration::from_millis(50)), - Err(_) => return, - } - } - log::warn!("[pty] kill_and_wait timed out after 2s"); - } -} - -impl Drop for PtySession { - fn drop(&mut self) { - // Do NOT block on wait() here — Drop may run on the main thread. - // Just send the kill signal; the process will exit on its own. - let _ = self.child.kill(); - } -} - -pub struct PtyManager { - sessions: RwLock>>>, -} - -impl PtyManager { - pub fn new() -> Self { - Self { - sessions: RwLock::new(HashMap::new()), - } - } - - pub fn create_session( - &mut self, - id: &str, - cwd: &str, - cols: u16, - rows: u16, - shell: Option<&str>, - ) -> Result<(), String> { - // Properly close existing session if any - if self.has_session(id) { - log::warn!( - "[pty] create_session: session '{}' already exists, closing before re-create", - id - ); - self.close_session(id, "create_session: replacing existing")?; - } - - let pty_system = native_pty_system(); - - let pair = pty_system - .openpty(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(|e| format!("Failed to open PTY: {}", e))?; - - // Use user-specified shell or fall back to default - let shell_path = requested_shell_path(shell); - log::info!("PTY session '{}' using shell: {}", id, shell_path); - - let mut cmd = CommandBuilder::new(&shell_path); - - // Collect all args first, then add them together - let mut args = Vec::new(); - for arg in shell_startup_args(&shell_path) { - args.push(arg.to_string()); - } - - cmd.cwd(cwd); - setup_shell_integration(&mut cmd, &shell_path, &mut args); - - // Add all args at once - for arg in &args { - cmd.arg(arg); - } - - // Set environment variables for better terminal support - cmd.env("TERM", "xterm-256color"); - cmd.env("COLORTERM", "truecolor"); - cmd.env( - "LANG", - std::env::var("LANG").unwrap_or_else(|_| "en_US.UTF-8".to_string()), - ); - - // Preserve important env vars - if let Ok(path) = std::env::var("PATH") { - cmd.env("PATH", path); - } - if let Ok(home) = std::env::var("HOME") { - cmd.env("HOME", home); - } - if let Ok(user) = std::env::var("USER") { - cmd.env("USER", user); - } - - // Windows-specific environment variables - #[cfg(target_os = "windows")] - { - for var in &[ - "USERPROFILE", - "HOMEDRIVE", - "HOMEPATH", - "APPDATA", - "LOCALAPPDATA", - "TEMP", - "TMP", - "SystemRoot", - "COMPUTERNAME", - "PSModulePath", - "PATHEXT", - "OS", - ] { - if let Ok(val) = std::env::var(var) { - cmd.env(var, val); - } - } - } - - let child = pair - .slave - .spawn_command(cmd) - .map_err(|e| format!("Failed to spawn shell: {}", e))?; - - // Drop slave to avoid blocking - drop(pair.slave); - - let writer = pair - .master - .take_writer() - .map_err(|e| format!("Failed to get writer: {}", e))?; - - let mut reader = pair - .master - .try_clone_reader() - .map_err(|e| format!("Failed to get reader: {}", e))?; - - let desktop_pending_buffer = Arc::new(Mutex::new(DesktopPendingBuffer::new())); - let desktop_pending_clone = desktop_pending_buffer.clone(); - - // Create broadcast channel for WebSocket subscribers - let (broadcast_tx, _) = broadcast::channel::>(256); - let broadcast_tx_clone = broadcast_tx.clone(); - - // Replay buffer shared with reader thread - let replay_buffer: Arc>> = - Arc::new(Mutex::new(VecDeque::with_capacity(REPLAY_BUFFER_CAP))); - let replay_buf_clone = replay_buffer.clone(); - let session_id = id.to_string(); - - // Spawn a thread to read from PTY - std::thread::spawn(move || { - let mut buf = [0u8; 4096]; - let mut event_utf8_pending: Vec = Vec::new(); - loop { - match reader.read(&mut buf) { - Ok(0) => break, // EOF - Ok(n) => { - let data = buf[..n].to_vec(); - // Send to broadcast (for WS subscribers); ignore errors (no receivers) - let _ = broadcast_tx_clone.send(data.clone()); - // Append to replay buffer - if let Ok(mut rb) = replay_buf_clone.lock() { - rb.extend(&data); - // Trim from front if over capacity - if rb.len() > REPLAY_BUFFER_CAP { - let excess = rb.len() - REPLAY_BUFFER_CAP; - rb.drain(..excess); - } - } - // Desktop readers consume per-client cursors from a shared backlog - // so multiple windows do not steal output from each other. - if let Ok(mut pending) = desktop_pending_clone.lock() { - pending.append(&data); - } - - // Desktop event push path: emit UTF-8 text chunks to all windows. - let combined = if event_utf8_pending.is_empty() { - data - } else { - let mut combined = std::mem::take(&mut event_utf8_pending); - combined.extend(data); - combined - }; - let (text, pending) = bytes_to_utf8_with_pending(&combined); - event_utf8_pending = pending; - if !text.is_empty() { - if let Some(handle) = - crate::state::APP_HANDLE.lock().ok().and_then(|h| h.clone()) - { - let _ = handle.emit( - "pty-output", - serde_json::json!({ - "sessionId": session_id, - "data": text, - }), - ); - } - } - } - Err(_) => break, - } - } - }); - - let session = PtySession { - master: pair.master, - writer, - reader: PtyReader { - desktop_buffer: desktop_pending_buffer, - }, - child, - shell_path, - broadcast_tx, - replay_buffer, - }; - - self.sessions - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert(id.to_string(), Arc::new(Mutex::new(session))); - log::info!( - "[pty] Session '{}' created successfully. Total active sessions: {}", - id, - self.sessions - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .len() - ); - Ok(()) - } - - pub fn write_to_session(&self, id: &str, data: &str) -> Result<(), String> { - let sessions = self - .sessions - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let session = sessions.get(id).ok_or_else(|| { - let active: Vec<&String> = sessions.keys().collect(); - log::warn!( - "[pty] write_to_session: session '{}' not found. Active sessions ({}) = {:?}", - id, - active.len(), - active - ); - "Session not found".to_string() - })?; - - let mut session = session.lock().map_err(|e| format!("Lock error: {}", e))?; - session - .writer - .write_all(data.as_bytes()) - .map_err(|e| format!("Write error: {}", e))?; - session - .writer - .flush() - .map_err(|e| format!("Flush error: {}", e))?; - Ok(()) - } - - pub fn read_from_session(&self, id: &str, reader_id: Option<&str>) -> Result { - let sessions = self - .sessions - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let session_arc = sessions.get(id).ok_or_else(|| { - let active: Vec<&String> = sessions.keys().collect(); - log::warn!( - "[pty] read_from_session: session '{}' not found. Active sessions ({}) = {:?}", - id, - active.len(), - active - ); - "Session not found".to_string() - })?; - - let session = session_arc - .lock() - .map_err(|e| format!("Lock error: {}", e))?; - let reader_key = reader_id.unwrap_or(id); - - // Non-blocking: replay only this reader's unread bytes. - let mut pending = session - .reader - .desktop_buffer - .lock() - .map_err(|e| format!("Lock error: {}", e))?; - let result = pending.read_for_reader(reader_key); - drop(pending); - - let (text, pending) = bytes_to_utf8_with_pending(&result); - session - .reader - .desktop_buffer - .lock() - .map_err(|e| format!("Lock error: {}", e))? - .store_utf8_pending(reader_key, pending); - Ok(text) - } - - pub fn resize_session(&self, id: &str, cols: u16, rows: u16) -> Result<(), String> { - let sessions = self - .sessions - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let session_arc = sessions.get(id).ok_or_else(|| { - log::warn!("[pty] resize_session: session '{}' not found", id); - "Session not found".to_string() - })?; - - let session = session_arc - .lock() - .map_err(|e| format!("Lock error: {}", e))?; - session - .master - .resize(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(|e| format!("Resize error: {}", e))?; - Ok(()) - } - - pub fn close_session(&mut self, id: &str, reason: &str) -> Result<(), String> { - let removed = self - .sessions - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .remove(id); - if let Some(session) = removed { - let remaining = self - .sessions - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .len(); - log::info!( - "[pty] Closing session '{}', reason: '{}'. Remaining sessions: {}", - id, - reason, - remaining - ); - // Spawn a background thread so the main thread never blocks on child.wait(). - std::thread::spawn(move || { - if let Ok(mut session) = session.lock() { - session.kill_and_wait(); - } - // session Arc is dropped here → Drop::drop runs, but only does kill() - }); - } else { - log::warn!( - "[pty] close_session called for '{}' but not found, reason: '{}'", - id, - reason - ); - } - Ok(()) - } - - pub fn has_session(&self, id: &str) -> bool { - self.sessions - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .contains_key(id) - } - - pub fn session_shell_path(&self, id: &str) -> Option { - let sessions = self - .sessions - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - sessions - .get(id) - .and_then(|session| session.lock().ok()) - .map(|session| session.shell_path.clone()) - } - - pub fn session_count(&self) -> usize { - self.sessions - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .len() - } - - /// Get a broadcast receiver and replay buffer snapshot for a PTY session (used by WebSocket subscribers). - /// Returns (replay_data, broadcast_receiver). - pub fn subscribe_session(&self, id: &str) -> Option<(Vec, broadcast::Receiver>)> { - let sessions = self - .sessions - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let session_arc = sessions.get(id)?; - let session = session_arc.lock().ok()?; - let replay = session - .replay_buffer - .lock() - .ok() - .map(|rb| rb.iter().copied().collect::>()) - .unwrap_or_default(); - let rx = session.broadcast_tx.subscribe(); - Some((replay, rx)) - } - - pub fn close_sessions_by_path_prefix( - &mut self, - path_prefix: &str, - reason: &str, - ) -> Vec { - let normalized_prefix = path_prefix.replace(['/', '\\', '#'], "-"); - // NOTE: session IDs are created by frontend as `pty-{normalized-path}` (no trailing -#) - let session_prefix = format!("pty-{}", normalized_prefix); - - // Collect IDs under read lock, then drop guard before write lock - let sessions_to_close: Vec = { - let sessions = self - .sessions - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - sessions - .keys() - .filter(|id| { - // Exact match or followed by '-' to avoid matching /path/project-extra - // when closing /path/project - **id == session_prefix || id.starts_with(&format!("{}-", session_prefix)) - }) - .cloned() - .collect() - }; // read guard dropped here - - if !sessions_to_close.is_empty() { - log::info!( - "[pty] Closing {} sessions by path prefix '{}' (normalized: '{}'), reason: '{}', sessions: {:?}", - sessions_to_close.len(), - path_prefix, - normalized_prefix, - reason, - sessions_to_close - ); - } - - // Now acquire write lock separately and move cleanup to background threads - for id in &sessions_to_close { - if let Some(session) = self - .sessions - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .remove(id) - { - std::thread::spawn(move || { - if let Ok(mut session) = session.lock() { - session.kill_and_wait(); - } - }); - } - } - - sessions_to_close - } -} - -impl Default for PtyManager { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - #[cfg(target_os = "windows")] - use super::powershell_integration_args; - use super::{ - append_bash_integration_args, bash_integration_init_path, bytes_to_utf8_with_pending, - get_default_shell, requested_shell_path, resolve_shell_from_id, - resolve_shell_from_path_lookup, shell_escape_single_quote, shell_program_name, - shell_startup_args, windows_path_to_git_bash, DesktopPendingBuffer, PtyManager, - DESKTOP_PENDING_BUFFER_CAP, DESKTOP_READER_TTL, - }; - use serial_test::serial; - #[cfg(target_os = "windows")] - use std::path::Path; - use std::time::{Duration, Instant}; - - #[serial] - #[test] - fn empty_input() { - let (text, pending) = bytes_to_utf8_with_pending(&[]); - assert_eq!(text, ""); - assert!(pending.is_empty()); - } - - #[serial] - #[test] - fn valid_ascii() { - let (text, pending) = bytes_to_utf8_with_pending(b"hello world"); - assert_eq!(text, "hello world"); - assert!(pending.is_empty()); - } - - #[serial] - #[test] - fn valid_multibyte() { - let input = "你好世界🚀".as_bytes(); - let (text, pending) = bytes_to_utf8_with_pending(input); - assert_eq!(text, "你好世界🚀"); - assert!(pending.is_empty()); - } - - #[serial] - #[test] - fn incomplete_2byte_at_end() { - // 'é' = 0xC3 0xA9 — send only the leading byte - let (text, pending) = bytes_to_utf8_with_pending(&[b'a', 0xC3]); - assert_eq!(text, "a"); - assert_eq!(pending, vec![0xC3]); - } - - #[serial] - #[test] - fn incomplete_3byte_at_end() { - // '你' = 0xE4 0xBD 0xA0 — send first 2 bytes - let (text, pending) = bytes_to_utf8_with_pending(&[b'a', 0xE4, 0xBD]); - assert_eq!(text, "a"); - assert_eq!(pending, vec![0xE4, 0xBD]); - } - - #[serial] - #[test] - fn incomplete_4byte_at_end() { - // '🚀' = 0xF0 0x9F 0x9A 0x80 — send first 3 bytes - let (text, pending) = bytes_to_utf8_with_pending(&[b'x', 0xF0, 0x9F, 0x9A]); - assert_eq!(text, "x"); - assert_eq!(pending, vec![0xF0, 0x9F, 0x9A]); - } - - #[serial] - #[test] - fn invalid_byte_in_middle() { - // 0xFF is never valid UTF-8 - let (text, pending) = bytes_to_utf8_with_pending(&[b'a', 0xFF, b'b']); - assert_eq!(text, "a\u{FFFD}b"); - assert!(pending.is_empty()); - } - - #[serial] - #[test] - fn invalid_middle_and_incomplete_end() { - // Invalid byte in middle + incomplete 3-byte at end - let (text, pending) = bytes_to_utf8_with_pending(&[b'a', 0xFF, b'b', 0xE4, 0xBD]); - assert_eq!(text, "a\u{FFFD}b"); - assert_eq!(pending, vec![0xE4, 0xBD]); - } - - #[serial] - #[test] - fn sequential_chunks_reassemble() { - // Simulate '你' (0xE4 0xBD 0xA0) split across two chunks - let (text1, pending1) = bytes_to_utf8_with_pending(&[0xE4, 0xBD]); - assert_eq!(text1, ""); - assert_eq!(pending1, vec![0xE4, 0xBD]); - - // Second chunk: prepend pending + remaining byte - let mut chunk2 = pending1; - chunk2.push(0xA0); - let (text2, pending2) = bytes_to_utf8_with_pending(&chunk2); - assert_eq!(text2, "你"); - assert!(pending2.is_empty()); - } - - #[serial] - #[test] - fn multiple_invalid_bytes_consecutive() { - let (text, pending) = bytes_to_utf8_with_pending(&[0xFF, 0xFE, b'a']); - assert_eq!(text, "\u{FFFD}\u{FFFD}a"); - assert!(pending.is_empty()); - } - - #[serial] - #[test] - fn only_incomplete_bytes() { - // Just one leading byte, nothing else - let (text, pending) = bytes_to_utf8_with_pending(&[0xE4]); - assert_eq!(text, ""); - assert_eq!(pending, vec![0xE4]); - } - - #[serial] - #[test] - fn zsh_uses_interactive_startup() { - assert_eq!(shell_startup_args("/bin/zsh"), &["-i"]); - } - - #[serial] - #[test] - fn bash_uses_interactive_startup() { - assert_eq!(shell_startup_args("/bin/bash"), &["-i"]); - } - - #[serial] - #[test] - fn pwsh_uses_default_startup_args() { - assert_eq!(shell_startup_args("pwsh"), &[] as &[&str]); - } - - #[cfg(target_os = "windows")] - #[serial] - #[test] - fn powershell_integration_args_bypass_process_execution_policy() { - let script = Path::new(r"\\?\C:\Users\O'Brien\pwsh-integration.ps1"); - let args = powershell_integration_args(script).unwrap(); - // \\?\ prefix must be stripped: neither PS 5.x nor 7+ handles it - // reliably inside dot-source invocations. - assert_eq!( - args, - vec![ - "-noexit", - "-nologo", - "-ExecutionPolicy", - "Bypass", - "-command", - r". 'C:\Users\O''Brien\pwsh-integration.ps1'", - ] - ); - } - - #[serial] - #[test] - fn requested_shell_keeps_explicit_existing_absolute_path() { - let exe = std::env::current_exe().unwrap(); - let exe_str = exe.to_string_lossy().to_string(); - assert_eq!(requested_shell_path(Some(&exe_str)), exe_str); - } - - #[serial] - #[test] - fn shell_resolution_handles_auto_missing_and_path_lookup_cases() { - let default_shell = get_default_shell(); - - assert_eq!(requested_shell_path(None), default_shell); - assert_eq!(resolve_shell_from_id(""), get_default_shell()); - assert_eq!(resolve_shell_from_id("auto"), get_default_shell()); - assert_eq!( - resolve_shell_from_id("__worktree_manager_missing_shell__"), - get_default_shell() - ); - assert!(resolve_shell_from_path_lookup("__worktree_manager_missing_shell__").is_none()); - - #[cfg(not(target_os = "windows"))] - assert_eq!( - resolve_shell_from_path_lookup("/bin/sh"), - Some("/bin/sh".to_string()) - ); - } - - #[cfg(target_os = "windows")] - #[serial] - #[test] - fn requested_shell_uses_git_bash_path_for_bash_id_when_available() { - if let Some(git_bash) = super::resolve_git_bash_path() { - assert_eq!(requested_shell_path(Some("bash")), git_bash); - } - } - - #[serial] - #[test] - fn bash_integration_args_put_init_file_before_interactive_flag() { - let mut args = vec!["-i".to_string()]; - append_bash_integration_args(&mut args, "/tmp/bash-init.sh".to_string()); - assert_eq!(args, vec!["--init-file", "/tmp/bash-init.sh", "-i"]); - } - - #[serial] - #[test] - fn bash_integration_init_path_requires_existing_file_and_returns_shell_path() { - let temp = tempfile::tempdir().expect("temp integration dir"); - - assert_eq!(bash_integration_init_path(temp.path()), None); - - let init_file = temp.path().join("bash-init.sh"); - std::fs::write(&init_file, "echo init").expect("write bash init"); - let path = bash_integration_init_path(temp.path()).expect("bash init path"); - - assert!(path.ends_with("/bash-init.sh") || path.ends_with("\\bash-init.sh")); - assert!(!path.contains(r"\\?\")); - } - - #[serial] - #[test] - fn windows_path_strips_long_path_prefix_and_converts_drive() { - assert_eq!( - windows_path_to_git_bash(r"\\?\C:\Users\test\bash-init.sh"), - "/c/Users/test/bash-init.sh" - ); - } - - #[serial] - #[test] - fn windows_path_converts_drive_without_long_prefix() { - assert_eq!( - windows_path_to_git_bash(r"C:\Users\test\bash-init.sh"), - "/c/Users/test/bash-init.sh" - ); - } - - #[serial] - #[test] - fn windows_path_preserves_unix_style_path_unchanged() { - assert_eq!( - windows_path_to_git_bash("/tmp/bash-init.sh"), - "/tmp/bash-init.sh" - ); - } - - #[serial] - #[test] - fn windows_path_converts_uppercase_drive_letter_to_lowercase() { - assert_eq!( - windows_path_to_git_bash(r"D:\Work\project\bash-init.sh"), - "/d/Work/project/bash-init.sh" - ); - } - - #[serial] - #[test] - fn windows_path_with_multibyte_first_character_does_not_panic() { - let converted = std::panic::catch_unwind(|| windows_path_to_git_bash(r"好:\Users\test")); - - assert_eq!(converted.unwrap(), "好:/Users/test"); - } - - #[serial] - #[test] - fn new_manager_reports_empty_session_state_without_spawning() { - let manager = PtyManager::new(); - - assert_eq!(manager.session_count(), 0); - assert!(!manager.has_session("missing")); - assert_eq!(manager.session_shell_path("missing"), None); - assert!(manager.subscribe_session("missing").is_none()); - } - - #[serial] - #[test] - fn missing_session_operations_return_errors_and_keep_count_zero() { - let mut manager = PtyManager::new(); - - let write_err = manager - .write_to_session("missing", "input") - .expect_err("write should fail"); - let read_err = manager - .read_from_session("missing", Some("reader")) - .expect_err("read should fail"); - let resize_err = manager - .resize_session("missing", 80, 24) - .expect_err("resize should fail"); - manager - .close_session("missing", "unit test") - .expect("closing missing session is allowed"); - - assert_eq!(write_err, "Session not found"); - assert_eq!(read_err, "Session not found"); - assert_eq!(resize_err, "Session not found"); - assert_eq!(manager.session_count(), 0); - } - - #[serial] - #[test] - fn close_sessions_by_path_prefix_empty_manager_returns_no_sessions() { - let mut manager = PtyManager::new(); - - let closed = manager.close_sessions_by_path_prefix("/tmp/worktree", "unit test"); - - assert!(closed.is_empty()); - assert_eq!(manager.session_count(), 0); - } - - #[serial] - #[test] - fn desktop_pending_buffer_compacts_to_capacity_without_readers() { - let mut buffer = DesktopPendingBuffer::new(); - let data = vec![b'x'; DESKTOP_PENDING_BUFFER_CAP + 17]; - - buffer.append(&data); - - assert_eq!(buffer.bytes.len(), DESKTOP_PENDING_BUFFER_CAP); - assert_eq!(buffer.start_offset, 17); - assert_eq!(buffer.end_offset, (DESKTOP_PENDING_BUFFER_CAP + 17) as u64); - assert!(buffer.readers.is_empty()); - } - - #[serial] - #[test] - fn desktop_pending_buffer_resets_reader_that_fell_behind_start_offset() { - let mut buffer = DesktopPendingBuffer::new(); - assert!(buffer.read_for_reader("reader").is_empty()); - buffer.append(b"abcdef"); - buffer.store_utf8_pending("reader", vec![0xE4, 0xBD]); - - buffer.bytes.drain(..3); - buffer.start_offset = 3; - buffer.readers.get_mut("reader").unwrap().offset = 1; - let replay = buffer.read_for_reader("reader"); - - assert_eq!(replay, b"def"); - assert!(buffer - .readers - .get("reader") - .unwrap() - .utf8_pending - .is_empty()); - } - - #[serial] - #[test] - fn desktop_pending_buffer_discards_stale_reader_before_append_and_compact() { - let mut buffer = DesktopPendingBuffer::new(); - assert!(buffer.read_for_reader("stale").is_empty()); - buffer.append(b"old"); - buffer.readers.get_mut("stale").unwrap().last_read_at = - Instant::now() - DESKTOP_READER_TTL - Duration::from_secs(1); - - buffer.append(b"new"); - - assert!(!buffer.readers.contains_key("stale")); - assert_eq!(buffer.start_offset, 0); - assert_eq!(buffer.end_offset, 6); - assert_eq!(buffer.bytes.iter().copied().collect::>(), b"oldnew"); - } - - #[serial] - #[test] - fn desktop_pending_buffer_tracks_independent_reader_offsets() { - let mut buffer = DesktopPendingBuffer::new(); - - assert!(buffer.read_for_reader("reader-a").is_empty()); - assert!(buffer.read_for_reader("reader-b").is_empty()); - buffer.append(b"abc"); - let first_a = buffer.read_for_reader("reader-a"); - let first_b = buffer.read_for_reader("reader-b"); - let second_a = buffer.read_for_reader("reader-a"); - buffer.append(b"de"); - let third_a = buffer.read_for_reader("reader-a"); - let second_b = buffer.read_for_reader("reader-b"); - - assert_eq!(first_a, b"abc"); - assert_eq!(first_b, b"abc"); - assert!(second_a.is_empty()); - assert_eq!(third_a, b"de"); - assert_eq!(second_b, b"de"); - } - - #[serial] - #[test] - fn desktop_pending_buffer_prepends_pending_utf8_bytes_for_reader() { - let mut buffer = DesktopPendingBuffer::new(); - - buffer.append(&[0xE4, 0xBD]); - let first = buffer.read_for_reader("reader"); - buffer.store_utf8_pending("reader", first); - buffer.append(&[0xA0, b'!']); - let second = buffer.read_for_reader("reader"); - - assert_eq!(second, vec![0xE4, 0xBD, 0xA0, b'!']); - } - - #[serial] - #[test] - fn shell_name_and_quote_helpers_handle_paths_and_single_quotes() { - assert_eq!(shell_program_name("/bin/zsh"), "zsh"); - assert_eq!(shell_program_name("/opt/pwsh.exe"), "pwsh"); - assert_eq!( - shell_escape_single_quote("/tmp/O'Brien/init.sh"), - "/tmp/O'\\''Brien/init.sh" - ); - } - - #[cfg(not(target_os = "windows"))] - fn unique_session_id(name: &str) -> String { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - format!("pty-manager-test-{}-{}-{nanos}", std::process::id(), name) - } - - #[cfg(not(target_os = "windows"))] - fn wait_for_output(manager: &PtyManager, id: &str, reader: &str, needle: &str) -> String { - let deadline = Instant::now() + Duration::from_secs(3); - let mut collected = String::new(); - while Instant::now() < deadline { - let chunk = manager - .read_from_session(id, Some(reader)) - .expect("read pty session"); - collected.push_str(&chunk); - if collected.contains(needle) { - return collected; - } - std::thread::sleep(Duration::from_millis(25)); - } - panic!("timed out waiting for {needle:?}; collected {collected:?}"); - } - - #[cfg(not(target_os = "windows"))] - #[serial] - #[test] - fn pty_manager_creates_reads_subscribes_resizes_and_closes_short_shell() { - if !std::path::Path::new("/bin/sh").exists() { - // Unix CI images should have /bin/sh; skip only on unusual local systems. - return; - } - let mut manager = PtyManager::new(); - let cwd = tempfile::tempdir().expect("pty cwd"); - let id = unique_session_id("lifecycle"); - - manager - .create_session(&id, cwd.path().to_str().unwrap(), 80, 24, Some("/bin/sh")) - .expect("create pty session"); - assert_eq!(manager.session_count(), 1); - assert!(manager.has_session(&id)); - assert_eq!(manager.session_shell_path(&id), Some("/bin/sh".to_string())); - assert!(manager.subscribe_session(&id).is_some()); - - manager - .write_to_session(&id, "printf WM_PTY_OK; exit\n") - .expect("write shell command"); - let output = wait_for_output(&manager, &id, "reader-a", "WM_PTY_OK"); - assert!(output.contains("WM_PTY_OK")); - manager - .resize_session(&id, 100, 30) - .expect("resize session"); - - let (replay, _) = manager.subscribe_session(&id).expect("subscribe session"); - assert!( - String::from_utf8_lossy(&replay).contains("WM_PTY_OK"), - "replay was {:?}", - String::from_utf8_lossy(&replay) - ); - manager - .close_session(&id, "unit test complete") - .expect("close session"); - assert_eq!(manager.session_count(), 0); - assert!(!manager.has_session(&id)); - } - - #[cfg(not(target_os = "windows"))] - #[serial] - #[test] - fn pty_manager_replaces_existing_session_with_same_id() { - if !std::path::Path::new("/bin/sh").exists() { - // Unix CI images should have /bin/sh; skip only on unusual local systems. - return; - } - let mut manager = PtyManager::new(); - let cwd = tempfile::tempdir().expect("pty cwd"); - let id = unique_session_id("replace"); - - manager - .create_session(&id, cwd.path().to_str().unwrap(), 80, 24, Some("/bin/sh")) - .expect("create first session"); - manager - .create_session(&id, cwd.path().to_str().unwrap(), 90, 25, Some("/bin/sh")) - .expect("replace session"); - - assert_eq!(manager.session_count(), 1); - assert!(manager.has_session(&id)); - manager.close_session(&id, "unit test cleanup").unwrap(); - } - - #[cfg(not(target_os = "windows"))] - #[serial] - #[test] - fn close_sessions_by_path_prefix_closes_exact_and_child_session_ids() { - if !std::path::Path::new("/bin/sh").exists() { - // Unix CI images should have /bin/sh; skip only on unusual local systems. - return; - } - let mut manager = PtyManager::new(); - let cwd = tempfile::tempdir().expect("pty cwd"); - let prefix = cwd.path().to_string_lossy().replace(['/', '\\', '#'], "-"); - let exact_id = format!("pty-{}", prefix); - let child_id = format!("pty-{}-extra", prefix); - let unrelated_id = unique_session_id("unrelated"); - - manager - .create_session( - &exact_id, - cwd.path().to_str().unwrap(), - 80, - 24, - Some("/bin/sh"), - ) - .expect("create exact session"); - manager - .create_session( - &child_id, - cwd.path().to_str().unwrap(), - 80, - 24, - Some("/bin/sh"), - ) - .expect("create child session"); - manager - .create_session( - &unrelated_id, - cwd.path().to_str().unwrap(), - 80, - 24, - Some("/bin/sh"), - ) - .expect("create unrelated session"); - - let mut closed = - manager.close_sessions_by_path_prefix(cwd.path().to_str().unwrap(), "unit test"); - closed.sort(); - - assert_eq!(closed, vec![exact_id.clone(), child_id.clone()]); - assert!(!manager.has_session(&exact_id)); - assert!(!manager.has_session(&child_id)); - assert!(manager.has_session(&unrelated_id)); - manager - .close_session(&unrelated_id, "unit test cleanup") - .unwrap(); - } -} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs deleted file mode 100644 index 72001a4..0000000 --- a/src-tauri/src/state.rs +++ /dev/null @@ -1,522 +0,0 @@ -use once_cell::sync::Lazy; -use std::collections::HashMap; -use std::sync::Mutex; - -use crate::pty_manager::PtyManager; -use crate::types::{ - AuthRateLimiter, ConnectedClient, GlobalConfig, NonceCache, ShareState, TerminalState, - WorkspaceConfig, -}; - -// PTY Manager 全局实例 -pub(crate) static PTY_MANAGER: Lazy> = - Lazy::new(|| Mutex::new(PtyManager::new())); - -// 多窗口 workspace 绑定:window_label -> workspace_path -pub(crate) static WINDOW_WORKSPACES: Lazy>> = - Lazy::new(|| Mutex::new(HashMap::new())); - -// 多窗口 worktree 锁定:(workspace_path, worktree_name) -> window_label -// 同一 worktree 只能被一个窗口独占选中 -pub(crate) static WORKTREE_LOCKS: Lazy>> = - Lazy::new(|| Mutex::new(HashMap::new())); - -// Worktree 生命周期串行锁:每个 workspace 一把锁,串行化该 workspace 的 -// create / archive / delete / restore / add_project / deploy 操作,防止并发竞态 -// 破坏 workspace 配置文件、mapping.json 和 git worktree 注册状态。 -pub(crate) static WORKTREE_LIFECYCLE_LOCKS: Lazy< - Mutex>>>, -> = Lazy::new(|| Mutex::new(HashMap::new())); - -/// 获取指定 workspace 的生命周期锁句柄。调用方需对返回的 Arc 调用 `.lock()` 并持有 guard -/// 到整个生命周期操作结束,从而串行化同一 workspace 的 worktree 增删改。 -pub(crate) fn workspace_lifecycle_lock(workspace_path: &str) -> std::sync::Arc> { - WORKTREE_LIFECYCLE_LOCKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .entry(workspace_path.to_string()) - .or_insert_with(|| std::sync::Arc::new(Mutex::new(()))) - .clone() -} - -// ==================== 分享状态 ==================== - -pub(crate) static SHARE_STATE: Lazy> = - Lazy::new(|| Mutex::new(ShareState::default())); - -// 已认证的 session 集合 -pub(crate) static AUTHENTICATED_SESSIONS: Lazy>> = - Lazy::new(|| Mutex::new(std::collections::HashSet::new())); - -// 已连接的客户端追踪 -pub(crate) static CONNECTED_CLIENTS: Lazy>> = - Lazy::new(|| Mutex::new(HashMap::new())); - -pub(crate) static TOKIO_RT: Lazy = Lazy::new(|| { - tokio::runtime::Runtime::new().expect("Failed to create tokio runtime for sharing") -}); - -// Broadcast channel for lock state changes (WebSocket push) -// Increased capacity from 64 to 256 to reduce message lag and drops -pub(crate) static LOCK_BROADCAST: Lazy> = Lazy::new(|| { - let (tx, _) = tokio::sync::broadcast::channel(256); - tx -}); - -// Broadcast channel for terminal state changes (WebSocket push) -// Increased capacity from 64 to 256 to reduce message lag and drops -pub(crate) static TERMINAL_STATE_BROADCAST: Lazy> = - Lazy::new(|| { - let (tx, _) = tokio::sync::broadcast::channel(256); - tx - }); - -// Terminal state cache: (workspace_path, worktree_name) -> TerminalState -pub(crate) static TERMINAL_STATES: Lazy>> = - Lazy::new(|| Mutex::new(HashMap::new())); - -// Global AppHandle for emitting events from anywhere -pub(crate) static APP_HANDLE: Lazy>> = - Lazy::new(|| Mutex::new(None)); - -// Auth rate limiter -pub(crate) static AUTH_RATE_LIMITER: Lazy> = - Lazy::new(|| Mutex::new(AuthRateLimiter::new())); - -// Nonce cache for challenge-response authentication -pub(crate) static NONCE_CACHE: Lazy> = - Lazy::new(|| Mutex::new(NonceCache::new())); - -// Broadcast channel for voice events (WebSocket push to browser clients) -pub(crate) static VOICE_BROADCAST: Lazy> = Lazy::new(|| { - let (tx, _) = tokio::sync::broadcast::channel(64); - tx -}); - -// Broadcast channel for per-client notifications (kick events, etc.) -// Messages are JSON strings with a "session_id" field for filtering. -pub(crate) static CLIENT_NOTIFICATION_BROADCAST: Lazy> = - Lazy::new(|| { - let (tx, _) = tokio::sync::broadcast::channel(64); - tx - }); - -// ==================== 全局配置缓存 ==================== - -pub(crate) static GLOBAL_CONFIG_CACHE: Lazy>> = - Lazy::new(|| Mutex::new(None)); -pub(crate) static WORKSPACE_CONFIG_CACHE: Lazy>> = - Lazy::new(|| Mutex::new(None)); - -#[cfg(test)] -mod tests { - use super::*; - use crate::types::{ConnectedClient, TerminalState, WorkspaceConfig}; - use serial_test::serial; - use std::path::PathBuf; - use std::time::Duration; - - struct NamedTestLock { - path: PathBuf, - } - - impl NamedTestLock { - fn acquire(name: &str) -> Self { - let path = std::env::temp_dir().join(name); - loop { - match std::fs::create_dir(&path) { - Ok(()) => return Self { path }, - Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { - std::thread::sleep(Duration::from_millis(10)); - } - Err(err) => panic!("failed to acquire state test lock {:?}: {}", path, err), - } - } - } - } - - impl Drop for NamedTestLock { - fn drop(&mut self) { - let _ = std::fs::remove_dir(&self.path); - } - } - - struct StateGuard { - _command_lock: NamedTestLock, - _config_lock: NamedTestLock, - previous_windows: HashMap, - previous_worktree_locks: HashMap<(String, String), String>, - previous_terminal_states: HashMap<(String, String), TerminalState>, - previous_app_handle: Option, - previous_global_config: Option, - previous_workspace_config: Option<(String, WorkspaceConfig)>, - } - - impl StateGuard { - fn isolated() -> Self { - let command_lock = NamedTestLock::acquire("worktree-manager-command-test-global-lock"); - let config_lock = NamedTestLock::acquire("worktree-manager-global-config-cache.lock"); - - let previous_windows = std::mem::take( - &mut *WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()), - ); - let previous_worktree_locks = std::mem::take( - &mut *WORKTREE_LOCKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()), - ); - let previous_terminal_states = std::mem::take( - &mut *TERMINAL_STATES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()), - ); - let previous_app_handle = APP_HANDLE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .take(); - let previous_global_config = std::mem::take( - &mut *GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()), - ); - let previous_workspace_config = std::mem::take( - &mut *WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()), - ); - - Self { - _command_lock: command_lock, - _config_lock: config_lock, - previous_windows, - previous_worktree_locks, - previous_terminal_states, - previous_app_handle, - previous_global_config, - previous_workspace_config, - } - } - } - - impl Drop for StateGuard { - fn drop(&mut self) { - *WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = - self.previous_workspace_config.take(); - *GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = - self.previous_global_config.take(); - *APP_HANDLE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = self.previous_app_handle.take(); - *TERMINAL_STATES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = - std::mem::take(&mut self.previous_terminal_states); - *WORKTREE_LOCKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = - std::mem::take(&mut self.previous_worktree_locks); - *WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = - std::mem::take(&mut self.previous_windows); - } - } - - #[serial] - #[test] - fn global_maps_and_config_caches_store_and_return_exact_values() { - let _guard = StateGuard::isolated(); - - WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert("window-a".to_string(), "/workspace/a".to_string()); - WORKTREE_LOCKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert( - ("/workspace/a".to_string(), "feature-a".to_string()), - "window-a".to_string(), - ); - TERMINAL_STATES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert( - ("/workspace/a".to_string(), "feature-a".to_string()), - TerminalState { - activated_terminals: vec!["shell".to_string()], - active_terminal_tab: Some("shell".to_string()), - terminal_visible: true, - client_id: Some("client-a".to_string()), - session_id: Some("pty-a".to_string()), - }, - ); - let mut global = GlobalConfig::default(); - global.current_workspace = Some("/workspace/a".to_string()); - *GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(global); - *WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(( - "/workspace/a".to_string(), - WorkspaceConfig { - name: "Workspace A".to_string(), - ..WorkspaceConfig::default() - }, - )); - - assert_eq!( - WINDOW_WORKSPACES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .get("window-a"), - Some(&"/workspace/a".to_string()) - ); - assert_eq!( - WORKTREE_LOCKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .get(&("/workspace/a".to_string(), "feature-a".to_string())), - Some(&"window-a".to_string()) - ); - assert_eq!( - TERMINAL_STATES - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .get(&("/workspace/a".to_string(), "feature-a".to_string())) - .expect("terminal state") - .session_id - .as_deref(), - Some("pty-a") - ); - { - let mut sessions = AUTHENTICATED_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let had_session = sessions.insert("state-test-session-a".to_string()); - assert!(sessions.contains("state-test-session-a")); - if !had_session { - sessions.remove("state-test-session-a"); - } - } - { - let mut clients = CONNECTED_CLIENTS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let previous = clients.insert( - "state-test-session-a".to_string(), - ConnectedClient { - session_id: "state-test-session-a".to_string(), - ip: "127.0.0.1".to_string(), - user_agent: "unit-test".to_string(), - authenticated_at: "2026-06-11T00:00:00Z".to_string(), - last_active: "2026-06-11T00:01:00Z".to_string(), - ws_connected: true, - }, - ); - assert!( - clients - .get("state-test-session-a") - .expect("connected client") - .ws_connected - ); - match previous { - Some(client) => { - clients.insert("state-test-session-a".to_string(), client); - } - None => { - clients.remove("state-test-session-a"); - } - } - } - assert_eq!( - GLOBAL_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .as_ref() - .and_then(|config| config.current_workspace.as_deref()), - Some("/workspace/a") - ); - assert_eq!( - WORKSPACE_CONFIG_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .as_ref() - .map(|(_, config)| config.name.as_str()), - Some("Workspace A") - ); - } - - #[serial] - #[test] - fn share_auth_nonce_runtime_and_broadcast_state_are_accessible() { - let _guard = StateGuard::isolated(); - let (shutdown_tx, _) = tokio::sync::watch::channel(false); - { - let mut share = SHARE_STATE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let mut previous = std::mem::take(&mut *share); - share.active = true; - share.workspace_path = Some("/workspace/a".to_string()); - share.port = 42819; - share.auth_key = Some(vec![1; 32]); - share.auth_salt = Some(vec![2; 16]); - share.shutdown_tx = Some(shutdown_tx); - share.ngrok_url = Some("https://example.ngrok-free.app".to_string()); - assert!(share.active); - assert_eq!(share.workspace_path.as_deref(), Some("/workspace/a")); - assert_eq!(share.port, 42819); - assert_eq!(share.auth_key.as_ref().map(Vec::len), Some(32)); - assert_eq!(share.auth_salt.as_ref().map(Vec::len), Some(16)); - assert!(share.shutdown_tx.is_some()); - assert_eq!( - share.ngrok_url.as_deref(), - Some("https://example.ngrok-free.app") - ); - *share = std::mem::take(&mut previous); - } - - { - let mut limiter = AUTH_RATE_LIMITER - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let mut previous = std::mem::replace(&mut *limiter, AuthRateLimiter::new()); - for attempt in 1..=6 { - assert_eq!(limiter.check_and_record("127.0.0.1"), attempt <= 5); - } - *limiter = std::mem::replace(&mut previous, AuthRateLimiter::new()); - } - - { - let mut cache = NONCE_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let mut previous = std::mem::replace(&mut *cache, NonceCache::new()); - let nonce = cache.generate().expect("generate nonce"); - let consumed = cache.consume(&nonce); - assert_eq!(consumed.as_ref().map(Vec::len), Some(32)); - *cache = std::mem::replace(&mut previous, NonceCache::new()); - } - - let runtime_value = TOKIO_RT.block_on(async { 42_u8 }); - assert_eq!(runtime_value, 42); - assert_eq!( - PTY_MANAGER - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .session_count(), - 0 - ); - - let mut lock_rx = LOCK_BROADCAST.subscribe(); - let mut terminal_rx = TERMINAL_STATE_BROADCAST.subscribe(); - let mut voice_rx = VOICE_BROADCAST.subscribe(); - let mut client_rx = CLIENT_NOTIFICATION_BROADCAST.subscribe(); - - assert_eq!(LOCK_BROADCAST.send("lock".to_string()).unwrap(), 1); - assert_eq!( - TERMINAL_STATE_BROADCAST - .send("terminal".to_string()) - .unwrap(), - 1 - ); - assert_eq!(VOICE_BROADCAST.send("voice".to_string()).unwrap(), 1); - assert_eq!( - CLIENT_NOTIFICATION_BROADCAST - .send("{\"session_id\":\"session-a\"}".to_string()) - .unwrap(), - 1 - ); - - assert_eq!(lock_rx.try_recv().unwrap(), "lock"); - assert_eq!(terminal_rx.try_recv().unwrap(), "terminal"); - assert_eq!(voice_rx.try_recv().unwrap(), "voice"); - assert_eq!( - client_rx.try_recv().unwrap(), - "{\"session_id\":\"session-a\"}" - ); - } - - #[serial] - #[test] - fn lifecycle_lock_is_per_workspace_same_handle_and_distinct_across_workspaces() { - // 同一 workspace 必须返回指向同一把锁的句柄(两个调用者会互斥), - // 不同 workspace 必须是不同的锁(彼此不阻塞)。 - let a1 = workspace_lifecycle_lock("/workspace/a"); - let a2 = workspace_lifecycle_lock("/workspace/a"); - let b = workspace_lifecycle_lock("/workspace/b"); - - assert!( - std::sync::Arc::ptr_eq(&a1, &a2), - "same workspace must share one lock" - ); - assert!( - !std::sync::Arc::ptr_eq(&a1, &b), - "different workspaces must have distinct locks" - ); - - // 不同 workspace 的锁不互斥:持有 a 的锁时仍能拿到 b 的锁。 - let _a_guard = a1.lock().unwrap_or_else(|p| p.into_inner()); - assert!( - b.try_lock().is_ok(), - "holding workspace A lock must not block workspace B" - ); - } - - #[serial] - #[test] - fn lifecycle_lock_serializes_concurrent_critical_sections() { - // 证明:多个线程在同一 workspace 锁下对共享计数器做 read-modify-write, - // 串行化后不丢更新(这正是修复要解决的配置文件 lost-update 竞态的缩影)。 - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::Arc; - - let workspace = "/workspace/race-test"; - let counter = Arc::new(AtomicUsize::new(0)); - let max_seen = Arc::new(AtomicUsize::new(0)); - let in_section = Arc::new(AtomicUsize::new(0)); - - let mut handles = Vec::new(); - for _ in 0..8 { - let counter = counter.clone(); - let max_seen = max_seen.clone(); - let in_section = in_section.clone(); - handles.push(std::thread::spawn(move || { - for _ in 0..50 { - let lock = workspace_lifecycle_lock(workspace); - let _guard = lock.lock().unwrap_or_else(|p| p.into_inner()); - // 进入临界区的并发数必须始终为 1(证明真正串行)。 - let now = in_section.fetch_add(1, Ordering::SeqCst) + 1; - max_seen.fetch_max(now, Ordering::SeqCst); - // 非原子的 read-modify-write,无锁会丢更新。 - let v = counter.load(Ordering::Relaxed); - std::thread::yield_now(); - counter.store(v + 1, Ordering::Relaxed); - in_section.fetch_sub(1, Ordering::SeqCst); - } - })); - } - for h in handles { - h.join().expect("thread panicked"); - } - - assert_eq!( - counter.load(Ordering::Relaxed), - 8 * 50, - "lost update: lock did not serialize critical sections" - ); - assert_eq!( - max_seen.load(Ordering::SeqCst), - 1, - "more than one thread was in the critical section at once" - ); - } -} diff --git a/src-tauri/src/tls.rs b/src-tauri/src/tls.rs deleted file mode 100644 index a3b97d2..0000000 --- a/src-tauri/src/tls.rs +++ /dev/null @@ -1,85 +0,0 @@ -use rcgen::{CertificateParams, DnType, KeyPair, SanType}; -use std::net::IpAddr; -use std::time::Duration; - -pub struct TlsCerts { - pub cert_pem: String, - pub key_pem: String, -} - -/// Generate a self-signed TLS certificate. -/// SAN includes all provided IPs, plus localhost and 127.0.0.1. Valid for 365 days. -pub fn generate_self_signed(ips: &[IpAddr]) -> Result { - let mut params = CertificateParams::default(); - params - .distinguished_name - .push(DnType::CommonName, "Worktree Manager"); - params.not_before = time::OffsetDateTime::now_utc(); - params.not_after = time::OffsetDateTime::now_utc() + Duration::from_secs(365 * 24 * 60 * 60); - - let mut sans: Vec = ips.iter().map(|ip| SanType::IpAddress(*ip)).collect(); - sans.push(SanType::DnsName( - "localhost" - .try_into() - .map_err(|e| format!("Invalid DNS name: {}", e))?, - )); - sans.push(SanType::IpAddress(IpAddr::V4( - std::net::Ipv4Addr::LOCALHOST, - ))); - params.subject_alt_names = sans; - - let key_pair = - KeyPair::generate().map_err(|e| format!("Failed to generate key pair: {}", e))?; - let cert = params - .self_signed(&key_pair) - .map_err(|e| format!("Failed to generate certificate: {}", e))?; - - Ok(TlsCerts { - cert_pem: cert.pem(), - key_pem: key_pair.serialize_pem(), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use serial_test::serial; - use std::io::BufReader; - use std::net::{Ipv4Addr, Ipv6Addr}; - - #[serial] - #[test] - fn generate_self_signed_returns_non_empty_pem_blocks() { - let certs = generate_self_signed(&[]).expect("generate localhost cert"); - - assert!(certs.cert_pem.starts_with("-----BEGIN CERTIFICATE-----")); - assert!(certs.cert_pem.ends_with("-----END CERTIFICATE-----\n")); - assert!(certs.key_pem.contains("-----BEGIN PRIVATE KEY-----")); - assert!(certs.key_pem.ends_with("-----END PRIVATE KEY-----\n")); - } - - #[serial] - #[test] - fn generated_self_signed_certificate_and_key_parse_as_pem() { - let certs = generate_self_signed(&[ - IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10)), - IpAddr::V6(Ipv6Addr::LOCALHOST), - ]) - .expect("generate cert for provided IPs"); - - let mut cert_reader = BufReader::new(certs.cert_pem.as_bytes()); - let parsed_certs = rustls_pemfile::certs(&mut cert_reader) - .collect::, _>>() - .expect("parse generated certificate PEM"); - - assert_eq!(parsed_certs.len(), 1); - assert!(!parsed_certs[0].as_ref().is_empty()); - - let mut key_reader = BufReader::new(certs.key_pem.as_bytes()); - let parsed_key = rustls_pemfile::private_key(&mut key_reader) - .expect("parse generated private key PEM") - .expect("generated PEM should contain a private key"); - - assert!(!parsed_key.secret_der().is_empty()); - } -} diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs deleted file mode 100644 index 3288eb6..0000000 --- a/src-tauri/src/types.rs +++ /dev/null @@ -1,563 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::time::{Duration, Instant}; - -// ==================== 分享状态 ==================== - -#[derive(Default)] -pub struct ShareState { - pub active: bool, - pub workspace_path: Option, - pub port: u16, - pub auth_key: Option>, // PBKDF2 derived key (32 bytes) - pub auth_salt: Option>, // PBKDF2 salt (16 bytes) - pub shutdown_tx: Option>, - pub ngrok_url: Option, - pub ngrok_task: Option>, -} - -#[derive(Debug, Serialize, Clone)] -pub struct ConnectedClient { - pub session_id: String, - pub ip: String, - pub user_agent: String, - pub authenticated_at: String, - pub last_active: String, - pub ws_connected: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TerminalState { - pub activated_terminals: Vec, - pub active_terminal_tab: Option, - pub terminal_visible: bool, - pub client_id: Option, - pub session_id: Option, // PTY session ID for per-session resize gating -} - -#[derive(Debug, Serialize, Clone)] -pub struct ShareStateInfo { - pub active: bool, - pub urls: Vec, - pub ngrok_url: Option, - pub workspace_path: Option, - pub current_workspace_name: Option, -} - -// Auth rate limiter: per-IP sliding window (max 5 attempts per 60 seconds) -pub struct AuthRateLimiter { - attempts: HashMap>, -} - -impl Default for AuthRateLimiter { - fn default() -> Self { - Self::new() - } -} - -impl AuthRateLimiter { - pub fn new() -> Self { - Self { - attempts: HashMap::new(), - } - } - - /// Returns true if the request is allowed, false if rate-limited. - pub fn check_and_record(&mut self, ip: &str) -> bool { - let window = Duration::from_secs(60); - let max_attempts = 5; - let now = Instant::now(); - - let attempts = self.attempts.entry(ip.to_string()).or_default(); - // Remove expired entries - attempts.retain(|t| now.duration_since(*t) < window); - - if attempts.len() >= max_attempts { - return false; - } - attempts.push(now); - true - } - - /// Clean up stale entries (call periodically) - pub fn cleanup(&mut self) { - let window = Duration::from_secs(60); - let now = Instant::now(); - self.attempts.retain(|_, attempts| { - attempts.retain(|t| now.duration_since(*t) < window); - !attempts.is_empty() - }); - } -} - -// Nonce cache for challenge-response authentication (one-time use, 60s TTL) -pub struct NonceCache { - entries: HashMap)>, // nonce_hex -> (created_at, nonce_bytes) -} - -impl Default for NonceCache { - fn default() -> Self { - Self::new() - } -} - -impl NonceCache { - pub fn new() -> Self { - Self { - entries: HashMap::new(), - } - } - - /// Generate new nonce, store and return hex encoding - pub fn generate(&mut self) -> Result { - use ring::rand::{SecureRandom, SystemRandom}; - let rng = SystemRandom::new(); - let mut nonce = vec![0u8; 32]; - rng.fill(&mut nonce) - .map_err(|_| "Failed to generate nonce")?; - let nonce_hex = hex::encode(&nonce); - self.entries - .insert(nonce_hex.clone(), (Instant::now(), nonce)); - Ok(nonce_hex) - } - - /// Consume nonce (one-time use), return bytes - pub fn consume(&mut self, nonce_hex: &str) -> Option> { - self.cleanup(); - self.entries.remove(nonce_hex).map(|(_, bytes)| bytes) - } - - /// Clean up expired nonces (TTL: 60 seconds) - pub fn cleanup(&mut self) { - let now = Instant::now(); - self.entries - .retain(|_, (created, _)| now.duration_since(*created) < Duration::from_secs(60)); - } -} - -// ==================== 配置结构 ==================== - -#[derive(Debug, Serialize, Deserialize, Clone, Default)] -pub struct CloudConfig { - #[serde(default)] - pub server_url: Option, - #[serde(default)] - pub access_token: Option, - #[serde(default)] - pub refresh_token: Option, - #[serde(default)] - pub device_name: Option, -} - -// 全局配置:存储在 ~/.config/worktree-manager/global.json -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct GlobalConfig { - pub workspaces: Vec, - pub current_workspace: Option, // 当前选中的 workspace 路径 - #[serde(default)] - pub ngrok_token: Option, - #[serde(default)] - pub last_share_port: Option, // 上次使用的分享端口 - #[serde(default)] - pub share_password: Option, // 上次使用的分享口令 - #[serde(default)] - pub dashscope_api_key: Option, - #[serde(default)] - pub dashscope_base_url: Option, - #[serde(default = "default_true")] - pub voice_refine_enabled: bool, - #[serde(default)] - pub voice_refine_base_url: Option, - #[serde(default)] - pub voice_asr_model: Option, - #[serde(default)] - pub voice_refine_model: Option, - #[serde(default = "default_prefix_templates")] - pub commit_prefix_templates: Vec, - #[serde(default = "default_true")] - pub commit_prefix_enabled: bool, - #[serde(default)] - pub default_prefix_index: usize, - #[serde(default)] - pub git_user_name: Option, - #[serde(default)] - pub git_user_email: Option, - #[serde(default)] - pub skip_git_hooks: bool, - #[serde(default = "default_true")] - pub shell_integration_enabled: bool, - #[serde(default)] - pub custom_mirrors: Vec, - #[serde(default)] - pub cloud: CloudConfig, - // NEW: commit AI 独立key - #[serde(default)] - pub commit_ai_api_key: Option, - // NEW: AI生成开关 - #[serde(default = "default_true")] - pub commit_ai_enabled: bool, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct CustomMirror { - pub name: String, - pub url: String, // 前缀,如 "https://ghproxy.net/" -} - -fn default_true() -> bool { - true -} - -pub fn default_prefix_templates() -> Vec { - vec!["[{{worktree-name}}]".to_string()] -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct WorkspaceRef { - pub name: String, - pub path: String, -} - -impl Default for GlobalConfig { - fn default() -> Self { - Self { - workspaces: vec![], - current_workspace: None, - ngrok_token: None, - last_share_port: None, - share_password: None, - dashscope_api_key: None, - dashscope_base_url: None, - voice_refine_enabled: true, - voice_refine_base_url: None, - voice_asr_model: None, - voice_refine_model: None, - commit_prefix_templates: default_prefix_templates(), - commit_prefix_enabled: true, - default_prefix_index: 0, - git_user_name: None, - git_user_email: None, - skip_git_hooks: false, - shell_integration_enabled: true, - custom_mirrors: vec![], - cloud: CloudConfig::default(), - commit_ai_api_key: None, - commit_ai_enabled: true, - } - } -} - -// Workspace 配置:存储在 {workspace_root}/.worktree-manager.json -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct WorkspaceConfig { - pub name: String, - pub worktrees_dir: String, // 相对路径,如 "worktrees" - pub projects: Vec, - #[serde(default = "default_linked_workspace_items")] - pub linked_workspace_items: Vec, // 要链接到每个 worktree 的全局文件/文件夹 - #[serde(default)] - pub vault_linked_workspace_items: Vec, // vault 挂载时自动填充,也需链接到 worktree - #[serde(default = "default_uat_branch")] - pub uat_branch: String, // UAT 分支名,默认 "uat" - #[serde(default)] - pub archived_worktrees: Vec, // archived worktree names - #[serde(default)] - pub worktree_colors: HashMap, // worktree_name -> color - #[serde(default)] - pub tags: Vec, -} - -pub fn default_uat_branch() -> String { - "uat".to_string() -} - -pub fn default_linked_workspace_items() -> Vec { - vec![] -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum WorktreeColor { - Red, - Orange, - Yellow, - Green, - Blue, - Purple, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct TagDefinition { - pub id: String, - pub name: String, - pub color: String, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ProjectConfig { - pub name: String, - pub base_branch: String, - pub test_branch: String, - pub merge_strategy: String, - #[serde(default)] - pub linked_folders: Vec, // 要链接的文件夹列表 - #[serde(default)] - pub commit_prefix_index: Option, - #[serde(default)] - pub git_user_name: Option, - #[serde(default)] - pub git_user_email: Option, - #[serde(default)] - pub tags: Vec, -} - -impl Default for WorkspaceConfig { - fn default() -> Self { - Self { - name: "New Workspace".to_string(), - worktrees_dir: "worktrees".to_string(), - projects: vec![], - linked_workspace_items: default_linked_workspace_items(), - vault_linked_workspace_items: vec![], - uat_branch: default_uat_branch(), - archived_worktrees: vec![], - worktree_colors: HashMap::new(), - tags: vec![], - } - } -} - -// ==================== 数据结构 ==================== - -#[derive(Debug, Serialize)] -pub struct WorktreeListItem { - pub name: String, - /// Display name from mapping.json (for non-ASCII aliased worktrees) - pub display_name: Option, - pub path: String, - pub is_archived: bool, - pub color: Option, - pub projects: Vec, -} - -#[derive(Debug, Serialize)] -pub struct ProjectStatus { - pub name: String, - pub path: String, - pub current_branch: String, - pub base_branch: String, - pub test_branch: String, - pub has_uncommitted: bool, - pub uncommitted_count: usize, - pub is_merged_to_test: bool, - pub is_merged_to_base: bool, - pub ahead_of_base: usize, - pub behind_base: usize, - pub ahead_of_test: usize, - pub unpushed_commits: usize, - pub remote_url: String, -} - -#[derive(Debug, Serialize)] -pub struct MainWorkspaceStatus { - pub path: String, - pub name: String, - pub projects: Vec, -} - -#[derive(Debug, Serialize)] -pub struct MainProjectStatus { - pub name: String, - pub path: String, - pub current_branch: String, - pub has_uncommitted: bool, - pub uncommitted_count: usize, - pub is_merged_to_test: bool, - pub is_merged_to_base: bool, - pub ahead_of_base: usize, - pub behind_base: usize, - pub ahead_of_test: usize, - pub unpushed_commits: usize, - pub base_branch: String, - pub test_branch: String, - pub linked_folders: Vec, -} - -// ==================== Vault 子项 ==================== - -#[derive(Debug, Serialize)] -pub struct VaultItemChild { - pub name: String, - pub item_type: String, // "file" | "directory" -} - -// ==================== 智能软链接扫描 ==================== - -#[derive(Debug, Serialize, Clone)] -pub struct ScannedFolder { - pub relative_path: String, // e.g. "packages/web/node_modules" - pub display_name: String, // e.g. "node_modules" - pub size_bytes: u64, - pub size_display: String, // e.g. "256.3 MB" - pub is_recommended: bool, // 推荐预选 -} - -// ==================== Worktree 操作数据结构 ==================== - -#[derive(Debug, Serialize, Deserialize)] -pub struct CreateWorktreeRequest { - pub name: String, - /// Optional English folder alias (for non-ASCII names that may break IDEs) - #[serde(default, alias = "folderName")] - pub folder_name: Option, - pub projects: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct CreateProjectRequest { - pub name: String, - pub base_branch: String, -} - -#[derive(Debug, Serialize)] -pub struct WorktreeArchiveStatus { - pub name: String, - pub can_archive: bool, - pub warnings: Vec, - pub errors: Vec, - pub projects: Vec, - #[serde(default)] - pub locked_processes: Vec, - #[serde(default)] - pub lock_check_supported: bool, - #[serde(default)] - pub lock_check_error: Option, -} - -#[derive(Debug, Serialize, Clone)] -pub struct LockedProcessInfo { - pub pid: u32, - pub process_start_time: String, - pub name: String, - pub application_type: String, - pub restartable: bool, -} - -// ==================== 向已有 Worktree 添加项目 ==================== - -#[derive(Debug, Serialize, Deserialize)] -pub struct AddProjectToWorktreeRequest { - pub worktree_name: String, - pub project_name: String, - pub base_branch: String, -} - -// ==================== Git 操作 ==================== - -#[derive(Debug, Serialize, Deserialize)] -pub struct SwitchBranchRequest { - pub project_path: String, - pub branch: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct CloneProjectRequest { - pub name: String, - pub repo_url: String, - pub base_branch: String, - pub test_branch: String, - pub merge_strategy: String, - pub linked_folders: Vec, -} - -// ==================== 扫描已有项目 ==================== - -#[derive(Debug, Serialize)] -pub struct ExistingProjectInfo { - pub name: String, - pub current_branch: String, - pub is_registered: bool, -} - -// ==================== 编辑器 ==================== - -#[derive(Debug, Serialize, Deserialize)] -pub struct OpenEditorRequest { - pub path: String, - pub editor: String, -} - -// ==================== 部署到主工作区 ==================== - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct MainWorkspaceOccupation { - pub worktree_name: String, - pub original_branches: HashMap, // project_name → original_branch (main) - #[serde(default)] - pub worktree_branches: HashMap, // project_name → branch (worktree) - pub deployed_at: String, // ISO8601 -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct DeployToMainResult { - pub success: bool, - pub switched_projects: Vec, - pub failed_projects: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct DeployProjectError { - pub project_name: String, - pub error: String, -} - -#[cfg(test)] -mod tests { - use super::{AuthRateLimiter, NonceCache, WorkspaceConfig}; - use serial_test::serial; - - #[serial] - #[test] - fn auth_rate_limiter_allows_first_five_attempts_and_blocks_sixth() { - let mut limiter = AuthRateLimiter::new(); - - for attempt in 1..=6 { - let allowed = limiter.check_and_record("127.0.0.1"); - assert_eq!( - allowed, - attempt < 6, - "unexpected result at attempt {attempt}" - ); - } - } - - #[serial] - #[test] - fn nonce_cache_consumes_nonce_only_once() { - let mut cache = NonceCache::new(); - let nonce = cache.generate().unwrap(); - - let first = cache.consume(&nonce); - let second = cache.consume(&nonce); - - assert!(first.is_some()); - assert!(second.is_none()); - } - - #[serial] - #[test] - fn workspace_config_deserializes_missing_vault_linked_items_as_empty() { - let config: WorkspaceConfig = serde_json::from_str( - r#"{ - "name": "demo", - "worktrees_dir": "worktrees", - "projects": [], - "linked_workspace_items": ["CLAUDE.md"] - }"#, - ) - .unwrap(); - - assert_eq!(config.linked_workspace_items, vec!["CLAUDE.md"]); - assert!(config.vault_linked_workspace_items.is_empty()); - } -} diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs deleted file mode 100644 index 5bb7bbf..0000000 --- a/src-tauri/src/utils.rs +++ /dev/null @@ -1,1089 +0,0 @@ -use std::collections::HashMap; -use std::fs; -use std::path::Path; -use std::process::{Command, Output}; -use std::sync::{Mutex, OnceLock}; -use std::time::{Duration, Instant}; -use wait_timeout::ChildExt; - -use crate::types::ScannedFolder; - -// Git command timeout (30 seconds) -pub(crate) const GIT_COMMAND_TIMEOUT_SECS: u64 = 30; - -// Custom git path set by user (empty = auto-detect) -static CUSTOM_GIT_PATH: Mutex = Mutex::new(String::new()); - -/// Set a custom git executable path. Empty string reverts to auto-detect. -pub(crate) fn set_custom_git_path(path: &str) { - if let Ok(mut p) = CUSTOM_GIT_PATH.lock() { - *p = path.to_string(); - log::info!("[git] Custom git path set to: '{}'", path); - } -} - -/// Get the resolved git executable path. -fn resolve_git_path() -> String { - // Check custom path first - if let Ok(custom) = CUSTOM_GIT_PATH.lock() { - if !custom.is_empty() { - return custom.clone(); - } - } - - #[cfg(target_os = "windows")] - { - use std::sync::OnceLock; - static DETECTED_GIT: OnceLock = OnceLock::new(); - DETECTED_GIT - .get_or_init(|| { - // Try "git" from PATH first - #[allow(unused_mut)] - let mut check = std::process::Command::new("git"); - check - .arg("--version") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; - check.creation_flags(CREATE_NO_WINDOW); - } - if check.status().is_ok() { - return "git".to_string(); - } - let candidates = [ - r"C:\Program Files\Git\cmd\git.exe", - r"C:\Program Files (x86)\Git\cmd\git.exe", - ]; - for path in &candidates { - if std::path::Path::new(path).exists() { - log::info!("[git] Found git at: {}", path); - return path.to_string(); - } - } - if let Ok(local) = std::env::var("LOCALAPPDATA") { - let p = format!(r"{}\Programs\Git\cmd\git.exe", local); - if std::path::Path::new(&p).exists() { - log::info!("[git] Found git at: {}", p); - return p; - } - } - log::warn!("[git] git not found in PATH or common locations, using 'git'"); - "git".to_string() - }) - .clone() - } - - #[cfg(not(target_os = "windows"))] - { - "git".to_string() - } -} - -/// Get the user's login shell path. -#[allow(dead_code)] -fn get_login_shell() -> String { - std::env::var("SHELL").unwrap_or_else(|_| { - if Path::new("/bin/zsh").exists() { - "/bin/zsh".to_string() - } else if Path::new("/bin/bash").exists() { - "/bin/bash".to_string() - } else { - "/bin/sh".to_string() - } - }) -} - -/// Cache of environment variables loaded from the user's login shell. -/// Only populated on Unix systems where the app may not inherit the user's shell env. -#[allow(dead_code)] -static USER_ENV_CACHE: OnceLock> = OnceLock::new(); - -/// Load environment variables by running the user's login shell with `-l -c env`. -/// This captures PATH and other variables set in ~/.zshrc, ~/.bash_profile, etc. -#[allow(dead_code)] -fn load_user_env_from_shell() -> HashMap { - let shell = get_login_shell(); - let output = Command::new(&shell) - .args(["-l", "-c", "env -0"]) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) - .output(); - - let mut env = HashMap::new(); - - match output { - Ok(out) if out.status.success() => { - let stdout = String::from_utf8_lossy(&out.stdout); - for entry in stdout.split('\0') { - if let Some((key, value)) = entry.split_once('=') { - env.insert(key.to_string(), value.to_string()); - } - } - log::info!( - "[git] Loaded {} env vars from login shell {}", - env.len(), - shell - ); - } - Ok(out) => { - log::warn!( - "[git] Login shell env dump failed (status: {:?})", - out.status.code() - ); - } - Err(e) => { - log::warn!("[git] Failed to spawn login shell for env: {}", e); - } - } - - env -} - -/// Get the full user environment from the login shell (cached). -#[allow(dead_code)] -pub(crate) fn get_user_env() -> &'static HashMap { - USER_ENV_CACHE.get_or_init(load_user_env_from_shell) -} - -/// Create a `Command` for git that: -/// - Uses custom path if set, otherwise auto-detects -/// - On Unix: merges user's login shell PATH so hooks can find tools like cargo -/// - Hides the console window on Windows (CREATE_NO_WINDOW) -pub(crate) fn git_command() -> Command { - let git = resolve_git_path(); - #[cfg(target_os = "windows")] - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; - let mut cmd = Command::new(&git); - cmd.creation_flags(CREATE_NO_WINDOW); - cmd - } - #[cfg(not(target_os = "windows"))] - { - let mut cmd = Command::new(&git); - // Merge user's shell PATH so git hooks can find tools (cargo, node, etc.) - let user_env = get_user_env(); - if let Some(shell_path) = user_env.get("PATH") { - let current_path = std::env::var("PATH").unwrap_or_default(); - if !shell_path.is_empty() && shell_path != ¤t_path { - cmd.env("PATH", format!("{}:{}", shell_path, current_path)); - } - } - cmd - } -} - -pub(crate) fn truncate_log_text(text: &str, max_chars: usize) -> String { - text.chars().take(max_chars).collect() -} - -pub(crate) fn validate_git_ref_name(name: &str) -> Result<(), String> { - if name.trim().is_empty() - || name.starts_with('-') - || name.contains("..") - || name.ends_with(".lock") - || name.chars().any(|ch| ch.is_control() || ch.is_whitespace()) - || name - .chars() - .any(|ch| matches!(ch, '~' | '^' | ':' | '?' | '*' | '[' | '\\')) - { - return Err("无效的分支名".to_string()); - } - - Ok(()) -} - -pub(crate) fn mask_url_credentials(text: &str) -> String { - let mut masked = String::with_capacity(text.len()); - let mut cursor = 0; - - while let Some(relative_scheme_end) = text[cursor..].find("://") { - let scheme_end = cursor + relative_scheme_end; - let authority_start = scheme_end + 3; - masked.push_str(&text[cursor..authority_start]); - - let authority_and_rest = &text[authority_start..]; - let authority_len = authority_and_rest - .char_indices() - .find(|(_, ch)| *ch == '/' || ch.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(authority_and_rest.len()); - let authority = &authority_and_rest[..authority_len]; - - if let Some(at_idx) = authority.rfind('@') { - masked.push_str("***@"); - masked.push_str(&authority[at_idx + 1..]); - } else { - masked.push_str(authority); - } - - cursor = authority_start + authority_len; - } - - masked.push_str(&text[cursor..]); - masked -} - -fn stderr_for_log(stderr: &[u8]) -> String { - truncate_log_text( - &mask_url_credentials(&String::from_utf8_lossy(stderr)), - 2000, - ) -} - -fn command_cwd_for_log(cmd: &Command) -> String { - cmd.get_current_dir() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| { - std::env::current_dir() - .map(|path| path.display().to_string()) - .unwrap_or_else(|_| "".to_string()) - }) -} - -fn command_for_log(cmd: &Command) -> String { - let mut parts = Vec::new(); - parts.push(cmd.get_program().to_string_lossy().to_string()); - parts.extend(cmd.get_args().map(|arg| arg.to_string_lossy().to_string())); - mask_url_credentials(&parts.join(" ")) -} - -fn git_args_for_log(args: &[&str]) -> Vec { - args.iter().map(|arg| mask_url_credentials(arg)).collect() -} - -fn exit_code_for_log(status: &std::process::ExitStatus) -> String { - status - .code() - .map(|code| code.to_string()) - .unwrap_or_else(|| "".to_string()) -} - -pub(crate) fn run_git_logged(cmd: &mut Command, label: &str) -> std::io::Result { - let command = command_for_log(cmd); - let cwd = command_cwd_for_log(cmd); - let start = Instant::now(); - - log::info!( - "[git:{}] starting: command='{}', cwd='{}'", - label, - command, - cwd - ); - - match cmd.output() { - Ok(output) => { - let elapsed_ms = start.elapsed().as_millis(); - let exit_code = exit_code_for_log(&output.status); - log::info!( - "[git:{}] finished: elapsed_ms={}, exit_code={}", - label, - elapsed_ms, - exit_code - ); - - if !output.status.success() { - log::error!( - "[git:{}] failed: command='{}', cwd='{}', elapsed_ms={}, exit_code={}, stderr='{}'", - label, - command, - cwd, - elapsed_ms, - exit_code, - stderr_for_log(&output.stderr) - ); - } - - Ok(output) - } - Err(e) => { - log::error!( - "[git:{}] spawn failed: command='{}', cwd='{}', elapsed_ms={}, stderr=''", - label, - command, - cwd, - start.elapsed().as_millis(), - e - ); - Err(e) - } - } -} - -pub(crate) fn run_git_command_with_timeout( - args: &[&str], - cwd: &str, -) -> Result { - let start = Instant::now(); - let args_for_log = git_args_for_log(args); - log::info!( - "[git:timeout] starting: args={:?}, cwd='{}'", - args_for_log, - cwd - ); - - let mut child = git_command() - .args(args) - .current_dir(cwd) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .map_err(|e| { - log::error!( - "[git:timeout] spawn failed: args={:?}, cwd='{}', elapsed_ms={}, stderr=''", - args_for_log, - cwd, - start.elapsed().as_millis(), - e - ); - format!("Failed to spawn git command: {}", e) - })?; - - let timeout = Duration::from_secs(GIT_COMMAND_TIMEOUT_SECS); - match child.wait_timeout(timeout) { - Ok(Some(status)) => { - let exit_code = exit_code_for_log(&status); - let stdout = child - .stdout - .take() - .map(|mut s| { - let mut buf = Vec::new(); - std::io::Read::read_to_end(&mut s, &mut buf).ok(); - buf - }) - .unwrap_or_default(); - let stderr = child - .stderr - .take() - .map(|mut s| { - let mut buf = Vec::new(); - std::io::Read::read_to_end(&mut s, &mut buf).ok(); - buf - }) - .unwrap_or_default(); - let output = std::process::Output { - status, - stdout, - stderr, - }; - let elapsed_ms = start.elapsed().as_millis(); - log::info!( - "[git:timeout] finished: args={:?}, cwd='{}', elapsed_ms={}, exit_code={}", - args_for_log, - cwd, - elapsed_ms, - exit_code - ); - if !output.status.success() { - log::error!( - "[git:timeout] failed: args={:?}, cwd='{}', elapsed_ms={}, exit_code={}, stderr='{}'", - args_for_log, - cwd, - elapsed_ms, - exit_code, - stderr_for_log(&output.stderr) - ); - } - Ok(output) - } - Ok(None) => { - let _ = child.kill(); - let _ = child.wait(); - let stderr = child - .stderr - .take() - .map(|mut s| { - let mut buf = Vec::new(); - std::io::Read::read_to_end(&mut s, &mut buf).ok(); - buf - }) - .unwrap_or_default(); - log::error!( - "[git:timeout] timed out: args={:?}, cwd='{}', elapsed_ms={}, stderr='{}'", - args_for_log, - cwd, - start.elapsed().as_millis(), - stderr_for_log(&stderr) - ); - Err(format!( - "Git command timed out after {} seconds", - GIT_COMMAND_TIMEOUT_SECS - )) - } - Err(e) => { - let _ = child.kill(); - let _ = child.wait(); - let stderr = child - .stderr - .take() - .map(|mut s| { - let mut buf = Vec::new(); - std::io::Read::read_to_end(&mut s, &mut buf).ok(); - buf - }) - .unwrap_or_default(); - log::error!( - "[git:timeout] wait failed: args={:?}, cwd='{}', elapsed_ms={}, stderr='{}', error={}", - args_for_log, - cwd, - start.elapsed().as_millis(), - stderr_for_log(&stderr), - e - ); - Err(format!("Failed to wait for git command: {}", e)) - } - } -} - -/// Normalize path separators for the current platform. -/// On Windows, replaces forward slashes with backslashes and collapses -/// consecutive backslashes (e.g. `D:\\\\Folder` → `D:\Folder`), -/// while preserving UNC prefixes (`\\server\share`). -pub fn normalize_path(path: &str) -> String { - #[cfg(target_os = "windows")] - { - let p = path.replace('/', "\\"); - let is_unc = p.starts_with("\\\\"); - // Collapse all consecutive backslashes into single ones - let collapsed = collapse_backslashes(&p); - if is_unc && !collapsed.starts_with("\\\\") { - // UNC/extended-length path: restore the \\ prefix - // After collapse, collapsed starts with single '\', add one more - format!("\\{}", collapsed) - } else { - collapsed - } - } - #[cfg(not(target_os = "windows"))] - { - path.to_string() - } -} - -/// Replace runs of consecutive backslashes with a single backslash. -#[cfg(target_os = "windows")] -fn collapse_backslashes(s: &str) -> String { - let mut result = String::with_capacity(s.len()); - let mut prev_backslash = false; - for ch in s.chars() { - if ch == '\\' { - if !prev_backslash { - result.push(ch); - } - prev_backslash = true; - } else { - result.push(ch); - prev_backslash = false; - } - } - result -} - -pub(crate) fn format_size(bytes: u64) -> String { - const KB: u64 = 1024; - const MB: u64 = 1024 * KB; - const GB: u64 = 1024 * MB; - - if bytes >= GB { - format!("{:.1} GB", bytes as f64 / GB as f64) - } else if bytes >= MB { - format!("{:.1} MB", bytes as f64 / MB as f64) - } else if bytes >= KB { - format!("{:.1} KB", bytes as f64 / KB as f64) - } else { - format!("{} B", bytes) - } -} - -pub(crate) fn calculate_dir_size(path: &Path) -> u64 { - let mut total: u64 = 0; - - let entries = match fs::read_dir(path) { - Ok(entries) => entries, - Err(_) => return 0, - }; - - for entry in entries.flatten() { - let entry_path = entry.path(); - - // Skip symlinks - if entry_path.is_symlink() { - continue; - } - - if entry_path.is_file() { - total += entry.metadata().map(|m| m.len()).unwrap_or(0); - } else if entry_path.is_dir() { - total += calculate_dir_size(&entry_path); - } - } - - total -} - -pub(crate) const KNOWN_LINKABLE_FOLDERS: &[&str] = &[ - // JS/Node - "node_modules", - ".next", - ".nuxt", - ".yarn", - ".pnpm-store", - // Python - "venv", - ".venv", - "__pycache__", - ".pytest_cache", - ".mypy_cache", - // Rust - "target", - // Go - "vendor", - // Java/Kotlin - ".gradle", - ".m2", - "build", - // General - "dist", - ".cache", - ".parcel-cache", - ".turbo", -]; - -pub(crate) const RECOMMENDED_LINKABLE_FOLDERS: &[&str] = &[ - "node_modules", - ".next", - ".nuxt", - ".pnpm-store", - "venv", - ".venv", - "target", - ".gradle", -]; - -pub(crate) const SKIP_DIRS: &[&str] = &[".git", ".svn", ".hg"]; - -pub(crate) fn scan_dir_for_linkable_folders( - base: &Path, - current: &Path, - results: &mut Vec, -) { - let entries = match fs::read_dir(current) { - Ok(entries) => entries, - Err(_) => return, - }; - - for entry in entries.flatten() { - let entry_path = entry.path(); - - // Skip symlinks - if entry_path.is_symlink() { - continue; - } - - // Skip non-directories - if !entry_path.is_dir() { - continue; - } - - let dir_name = match entry_path.file_name().and_then(|n| n.to_str()) { - Some(name) => name.to_string(), - None => continue, - }; - - // Check if it's a known linkable folder - if KNOWN_LINKABLE_FOLDERS.contains(&dir_name.as_str()) { - let size_bytes = calculate_dir_size(&entry_path); - let relative_path = entry_path - .strip_prefix(base) - .unwrap_or(&entry_path) - .to_string_lossy() - .to_string(); - - results.push(ScannedFolder { - relative_path, - display_name: dir_name.clone(), - size_bytes, - size_display: format_size(size_bytes), - is_recommended: RECOMMENDED_LINKABLE_FOLDERS.contains(&dir_name.as_str()), - }); - continue; // Don't recurse into matched folders - } - - // Skip configured skip dirs - if SKIP_DIRS.contains(&dir_name.as_str()) { - continue; - } - - // Skip other hidden directories (those starting with '.' but not in KNOWN list) - if dir_name.starts_with('.') { - continue; - } - - // Only scan depth=1: do not recurse into subdirectories like src/, vendor/, etc. - } -} - -// Parse different repo URL formats -pub(crate) fn parse_repo_url(url: &str) -> Result { - let url = url.trim(); - - // GitHub shorthand: gh:owner/repo or owner/repo - if url.starts_with("gh:") || (!url.contains("://") && !url.starts_with("git@")) { - let repo = url.strip_prefix("gh:").unwrap_or(url); - return Ok(format!("https://github.com/{}.git", repo)); - } - - // SSH format: git@github.com:owner/repo.git or ssh://git@host:port/path - if url.starts_with("git@") || url.starts_with("ssh://") { - return Ok(url.to_string()); - } - - // HTTPS format: https://github.com/owner/repo.git - if url.starts_with("https://") || url.starts_with("http://") { - return Ok(url.to_string()); - } - - Err(format!("Invalid repository URL format: {}", url)) -} - -/// Translate a raw `std::io::Error` into a user-friendly message. -/// -/// The returned string describes the symptom and suggests a fix when possible. -/// The original OS error detail is appended in parentheses for support/debugging. -pub(crate) fn friendly_io_error(e: &std::io::Error) -> String { - use std::io::ErrorKind; - - // First try Rust's cross-platform ErrorKind - match e.kind() { - ErrorKind::NotFound => { - return "文件或目录不存在,请检查路径是否正确".to_string(); - } - ErrorKind::PermissionDenied => { - return "权限不足,请检查文件/目录的访问权限".to_string(); - } - ErrorKind::AlreadyExists => { - return "文件或目录已存在".to_string(); - } - ErrorKind::DirectoryNotEmpty => { - return "目录不为空,请先清空目录内容".to_string(); - } - ErrorKind::StorageFull => { - return "磁盘空间不足,请清理后重试".to_string(); - } - ErrorKind::InvalidInput => { - return "路径包含无效字符".to_string(); - } - _ => {} - } - - // Then check platform-specific raw OS error codes - if let Some(code) = e.raw_os_error() { - #[cfg(unix)] - { - return match code { - // EACCES (macOS/Linux) - 13 => "权限不足,请检查文件/目录的访问权限".to_string(), - // EBUSY - 16 => "文件正在被其他程序占用,请关闭相关程序后重试".to_string(), - // EXDEV — cross-device move - 18 => "无法跨磁盘移动文件,请改用复制操作".to_string(), - // EISDIR - 21 => "目标是一个目录,而非文件".to_string(), - // ENOSPC - 28 => "磁盘空间不足,请清理后重试".to_string(), - // EROFS - 30 => "文件系统为只读,无法写入".to_string(), - // ENAMETOOLONG (macOS=63, Linux=36) - 36 | 63 => "路径或文件名过长,请将项目移到更短的路径下再试".to_string(), - // ENOTEMPTY (macOS=66, Linux=39) - 39 | 66 => "目录不为空,请先清空目录内容".to_string(), - _ => format!("操作失败(错误码 {}),请联系技术支持", code), - }; - } - #[cfg(windows)] - { - return match code { - // ERROR_FILE_NOT_FOUND - 2 => "文件不存在,请检查路径是否正确".to_string(), - // ERROR_PATH_NOT_FOUND - 3 => "路径不存在,请检查目录是否正确".to_string(), - // ERROR_ACCESS_DENIED - 5 => "权限不足,请尝试以管理员身份运行或检查文件是否为只读".to_string(), - // ERROR_SHARING_VIOLATION / ERROR_LOCK_VIOLATION - 32 | 33 => "文件正在被其他程序占用,请关闭相关程序后重试".to_string(), - // ERROR_FILE_EXISTS - 80 => "文件已存在".to_string(), - // ERROR_DISK_FULL - 112 => "磁盘空间不足,请清理后重试".to_string(), - // ERROR_INVALID_NAME - 123 => "文件名包含无效字符,请使用合法的文件名".to_string(), - // ERROR_DIR_NOT_EMPTY - 145 => "目录不为空,请先清空目录内容".to_string(), - // ERROR_FILENAME_EXCED_RANGE / ERROR_BUFFER_OVERFLOW - 111 | 206 => "路径或文件名过长,请将项目移到更短的路径下再试".to_string(), - _ => format!("操作失败(错误码 {}),请联系技术支持", code), - }; - } - } - - // Fallback: include the original error for debugging - format!("操作失败({}),请联系技术支持", e) -} - -/// Format a user-facing IO error with context prefix. -/// -/// Example: `friendly_fs_error("复制项目失败", &err)` → -/// `"复制项目失败:路径或文件名过长,请将项目移到更短的路径下再试"` -pub(crate) fn friendly_fs_error(context: &str, e: &std::io::Error) -> String { - format!("{}:{}", context, friendly_io_error(e)) -} - -#[cfg(test)] -mod tests { - use super::*; - use serial_test::serial; - use std::io::{Error, ErrorKind}; - - #[serial] - #[test] - fn truncate_log_text_limits_by_chars() { - let text = "好".repeat(2001); - - let truncated = truncate_log_text(&text, 2000); - - assert_eq!(truncated.chars().count(), 2000); - assert!(truncated.chars().all(|c| c == '好')); - } - - #[serial] - #[test] - fn truncate_log_text_leaves_short_text_unchanged() { - assert_eq!(truncate_log_text("short stderr", 2000), "short stderr"); - } - - #[serial] - #[test] - fn truncate_log_text_handles_zero_limit() { - assert_eq!(truncate_log_text("anything", 0), ""); - } - - #[serial] - #[test] - fn validate_git_ref_name_accepts_common_branch_names() { - assert!(validate_git_ref_name("feature/foo").is_ok()); - assert!(validate_git_ref_name("release/v1.2").is_ok()); - assert!(validate_git_ref_name("bugfix.JIRA-123").is_ok()); - assert!(validate_git_ref_name("unicode/功能").is_ok()); - } - - #[serial] - #[test] - fn validate_git_ref_name_rejects_option_like_and_invalid_names() { - for name in [ - "", - " ", - "-upload-pack=sh", - "feature/../main", - "feature branch", - "feature\nbranch", - "release.lock", - "bad~name", - "bad^name", - "bad:name", - "bad?name", - "bad*name", - "bad[name", - r"bad\name", - ] { - assert!(validate_git_ref_name(name).is_err(), "{name:?} should fail"); - } - } - - #[serial] - #[test] - fn mask_url_credentials_masks_https_userinfo() { - assert_eq!( - mask_url_credentials( - "fatal: Authentication failed for 'https://user:token@example.com/repo.git'" - ), - "fatal: Authentication failed for 'https://***@example.com/repo.git'" - ); - } - - #[serial] - #[test] - fn mask_url_credentials_leaves_urls_without_userinfo_unchanged() { - let text = "https://github.com/org/repo.git http://example.com/path"; - assert_eq!(mask_url_credentials(text), text); - } - - #[serial] - #[test] - fn mask_url_credentials_masks_multiple_urls_and_skips_ssh_scp() { - assert_eq!( - mask_url_credentials( - "https://u:p@one.example/repo git@git.example:org/repo.git ssh://git@example.com/org/repo" - ), - "https://***@one.example/repo git@git.example:org/repo.git ssh://***@example.com/org/repo" - ); - } - - #[serial] - #[test] - fn mask_url_credentials_masks_userinfo_with_ports_and_query_strings() { - assert_eq!( - mask_url_credentials("fetch https://user:token@example.com:8443/org/repo.git?x=1"), - "fetch https://***@example.com:8443/org/repo.git?x=1" - ); - } - - #[serial] - #[test] - fn normalize_path_handles_empty_string() { - assert_eq!(normalize_path(""), ""); - } - - #[serial] - #[test] - fn normalize_path_handles_trailing_separators_for_platform() { - #[cfg(target_os = "windows")] - assert_eq!(normalize_path(r"C:/tmp/project/"), r"C:\tmp\project\"); - - #[cfg(not(target_os = "windows"))] - assert_eq!(normalize_path("/tmp/project/"), "/tmp/project/"); - } - - #[serial] - #[test] - fn normalize_path_preserves_or_folds_windows_style_paths_for_platform() { - #[cfg(target_os = "windows")] - { - assert_eq!( - normalize_path(r"\\server\\share\\repo\\"), - r"\\server\share\repo\" - ); - assert_eq!(normalize_path(r"C:\\tmp\\\\project"), r"C:\tmp\project"); - } - - #[cfg(not(target_os = "windows"))] - { - let unc = r"\\server\\share\\repo\\"; - assert_eq!(normalize_path(unc), unc); - } - } - - #[serial] - #[test] - fn parse_repo_url_expands_github_shorthand() { - assert_eq!( - parse_repo_url("gh:owner/repo").unwrap(), - "https://github.com/owner/repo.git" - ); - assert_eq!( - parse_repo_url(" owner/repo ").unwrap(), - "https://github.com/owner/repo.git" - ); - } - - #[serial] - #[test] - fn parse_repo_url_preserves_ssh_and_http_urls() { - for url in [ - "git@github.com:user/repo.git", - "ssh://git@github.com:2222/user/repo.git", - "https://github.com/user/repo.git", - "http://example.com/user/repo.git", - ] { - assert_eq!(parse_repo_url(url).unwrap(), url); - } - } - - #[serial] - #[test] - fn parse_repo_url_rejects_unknown_url_schemes() { - let error = parse_repo_url("ftp://example.com/user/repo.git").unwrap_err(); - - assert_eq!( - error, - "Invalid repository URL format: ftp://example.com/user/repo.git" - ); - } - - #[serial] - #[test] - fn format_size_covers_boundaries_and_fractional_values() { - assert_eq!(format_size(0), "0 B"); - assert_eq!(format_size(1023), "1023 B"); - assert_eq!(format_size(1024), "1.0 KB"); - assert_eq!(format_size(1536), "1.5 KB"); - assert_eq!(format_size(1024 * 1024), "1.0 MB"); - assert_eq!(format_size(5 * 1024 * 1024 / 2), "2.5 MB"); - assert_eq!(format_size(1024 * 1024 * 1024), "1.0 GB"); - assert_eq!(format_size(3 * 1024 * 1024 * 1024 / 2), "1.5 GB"); - } - - #[serial] - #[test] - fn friendly_io_error_maps_standard_error_kinds() { - let cases = [ - (ErrorKind::NotFound, "文件或目录不存在,请检查路径是否正确"), - ( - ErrorKind::PermissionDenied, - "权限不足,请检查文件/目录的访问权限", - ), - (ErrorKind::AlreadyExists, "文件或目录已存在"), - (ErrorKind::DirectoryNotEmpty, "目录不为空,请先清空目录内容"), - (ErrorKind::StorageFull, "磁盘空间不足,请清理后重试"), - (ErrorKind::InvalidInput, "路径包含无效字符"), - ]; - - for (kind, expected) in cases { - assert_eq!(friendly_io_error(&Error::new(kind, "raw detail")), expected); - } - } - - #[serial] - #[test] - fn friendly_io_error_maps_unix_os_error_codes() { - #[cfg(unix)] - { - let cases = [ - (16, "文件正在被其他程序占用,请关闭相关程序后重试"), - (18, "无法跨磁盘移动文件,请改用复制操作"), - (21, "目标是一个目录,而非文件"), - (30, "文件系统为只读,无法写入"), - (36, "路径或文件名过长,请将项目移到更短的路径下再试"), - (39, "目录不为空,请先清空目录内容"), - (63, "路径或文件名过长,请将项目移到更短的路径下再试"), - (66, "目录不为空,请先清空目录内容"), - ]; - - for (code, expected) in cases { - assert_eq!( - friendly_io_error(&Error::from_raw_os_error(code)), - expected, - "unexpected mapping for errno {code}" - ); - } - } - } - - #[serial] - #[test] - fn friendly_io_error_maps_windows_os_error_codes() { - #[cfg(windows)] - { - let cases = [ - (2, "文件不存在,请检查路径是否正确"), - (3, "路径不存在,请检查目录是否正确"), - (5, "权限不足,请尝试以管理员身份运行或检查文件是否为只读"), - (32, "文件正在被其他程序占用,请关闭相关程序后重试"), - (33, "文件正在被其他程序占用,请关闭相关程序后重试"), - (80, "文件已存在"), - (112, "磁盘空间不足,请清理后重试"), - (123, "文件名包含无效字符,请使用合法的文件名"), - (145, "目录不为空,请先清空目录内容"), - (206, "路径或文件名过长,请将项目移到更短的路径下再试"), - ]; - - for (code, expected) in cases { - assert_eq!( - friendly_io_error(&Error::from_raw_os_error(code)), - expected, - "unexpected mapping for Windows error {code}" - ); - } - } - } - - #[serial] - #[test] - fn friendly_io_error_falls_back_for_unknown_errors() { - assert_eq!( - friendly_io_error(&Error::new(ErrorKind::Other, "disk went away")), - "操作失败(disk went away),请联系技术支持" - ); - assert_eq!( - friendly_io_error(&Error::from_raw_os_error(987_654)), - "操作失败(错误码 987654),请联系技术支持" - ); - } - - #[serial] - #[test] - fn friendly_fs_error_prefixes_context() { - let error = Error::new(ErrorKind::NotFound, "missing"); - - assert_eq!( - friendly_fs_error("复制项目失败", &error), - "复制项目失败:文件或目录不存在,请检查路径是否正确" - ); - } - - #[serial] - #[test] - fn calculate_dir_size_sums_nested_files_and_skips_symlinks() { - let temp = tempfile::tempdir().expect("create temp dir"); - let nested = temp.path().join("nested"); - std::fs::create_dir(&nested).expect("create nested dir"); - - let root_file = temp.path().join("root.bin"); - std::fs::write(&root_file, [0_u8; 3]).expect("write root file"); - std::fs::write(nested.join("child.bin"), [0_u8; 5]).expect("write child file"); - - #[cfg(unix)] - std::os::unix::fs::symlink(&root_file, temp.path().join("root-link.bin")) - .expect("create file symlink"); - - assert_eq!(calculate_dir_size(temp.path()), 8); - } - - #[serial] - #[test] - fn scan_dir_for_linkable_folders_filters_known_skip_hidden_and_symlink_dirs() { - let temp = tempfile::tempdir().expect("create temp dir"); - let base = temp.path(); - - std::fs::create_dir(base.join("node_modules")).expect("create node_modules"); - std::fs::write(base.join("node_modules").join("dep.bin"), [0_u8; 1024]).expect("write dep"); - - std::fs::create_dir(base.join(".venv")).expect("create .venv"); - std::fs::write(base.join(".venv").join("pyvenv.cfg"), [0_u8; 7]).expect("write venv file"); - - std::fs::create_dir(base.join(".git")).expect("create .git"); - std::fs::create_dir(base.join(".git").join("target")).expect("create skipped target"); - std::fs::write( - base.join(".git").join("target").join("ignored.bin"), - [0_u8; 11], - ) - .expect("write ignored target file"); - - std::fs::create_dir(base.join(".hidden")).expect("create hidden dir"); - std::fs::create_dir(base.join(".hidden").join("node_modules")) - .expect("create hidden node_modules"); - - #[cfg(unix)] - std::os::unix::fs::symlink(base.join("node_modules"), base.join("linked_node_modules")) - .expect("create directory symlink"); - - let mut results = Vec::new(); - scan_dir_for_linkable_folders(base, base, &mut results); - results.sort_by(|a, b| a.relative_path.cmp(&b.relative_path)); - - assert_eq!(results.len(), 2); - assert_eq!(results[0].relative_path, ".venv"); - assert_eq!(results[0].display_name, ".venv"); - assert_eq!(results[0].size_bytes, 7); - assert_eq!(results[0].size_display, "7 B"); - assert!(results[0].is_recommended); - - assert_eq!(results[1].relative_path, "node_modules"); - assert_eq!(results[1].display_name, "node_modules"); - assert_eq!(results[1].size_bytes, 1024); - assert_eq!(results[1].size_display, "1.0 KB"); - assert!(results[1].is_recommended); - } -} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json deleted file mode 100644 index 143eabc..0000000 --- a/src-tauri/tauri.conf.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "$schema": "https://schema.tauri.app/config/2", - "productName": "Worktree Manager", - "version": "0.1.2", - "identifier": "com.guo.worktree-manager", - "build": { - "beforeDevCommand": "npm run dev", - "devUrl": "http://localhost:1420", - "beforeBuildCommand": "npm run build", - "frontendDist": "../dist" - }, - "app": { - "windows": [ - { - "label": "main", - "title": "Worktree Manager", - "width": 1300, - "height": 900, - "minWidth": 900, - "minHeight": 500, - "devtools": true - } - ], - "security": { - "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:" - } - }, - "bundle": { - "active": true, - "targets": "all", - "createUpdaterArtifacts": "v1Compatible", - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ], - "resources": { - "../dist": "dist", - "shell-integration": "shell-integration" - }, - "windows": { - "nsis": { - "installMode": "perMachine" - } - }, - "publisher": "Worktree Manager Team", - "homepage": "https://github.com/guoyongchang/worktree-manager", - "license": "MIT" - }, - "plugins": { - "updater": { - "active": true, - "endpoints": [ - "https://github.com/guoyongchang/worktree-manager/releases/latest/download/latest.json" - ], - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDVFQ0QzNjE5NDBBNTFGRjEKUldUeEg2VkFHVGJOWHUwZWphQmRHMHA5bTVoNDJRTy9ORW54cTlhY1JRZCtXaXRYOHZPUG9tVVMK", - "dangerousInsecureTransportProtocol": true - } - } -} diff --git a/src-tauri/tests/devtools_config.rs b/src-tauri/tests/devtools_config.rs deleted file mode 100644 index f387361..0000000 --- a/src-tauri/tests/devtools_config.rs +++ /dev/null @@ -1,10 +0,0 @@ -#[test] -fn release_build_enables_tauri_devtools_feature() { - let manifest = include_str!("../Cargo.toml"); - - assert!( - manifest.contains("default = [\"devtools\"]") - && manifest.contains("devtools = [\"tauri/devtools\"]"), - "release DevTools requires this crate's default devtools feature to enable tauri/devtools" - ); -} diff --git a/src-tauri/tests/http_server_routing_smoke.rs b/src-tauri/tests/http_server_routing_smoke.rs deleted file mode 100644 index 44c5940..0000000 --- a/src-tauri/tests/http_server_routing_smoke.rs +++ /dev/null @@ -1,80 +0,0 @@ -use axum::http::Method; - -fn extract_api_routes_from_routing_source() -> Vec<(Method, String)> { - let src = include_str!("../src/http_server/routing.rs"); - let mut routes = Vec::new(); - - let mut i = 0usize; - while let Some(found) = src[i..].find(".route(") { - let start = i + found + ".route(".len(); - let bytes = src.as_bytes(); - let mut depth = 1i32; - let mut j = start; - while j < bytes.len() && depth > 0 { - match bytes[j] as char { - '(' => depth += 1, - ')' => depth -= 1, - _ => {} - } - j += 1; - } - if depth != 0 { - break; - } - - let call = &src[start..(j - 1)]; - let q1 = match call.find('"') { - Some(x) => x, - None => { - i = j; - continue; - } - }; - let q2 = match call[q1 + 1..].find('"') { - Some(x) => q1 + 1 + x, - None => { - i = j; - continue; - } - }; - let path = call[q1 + 1..q2].to_string(); - - let method = if call.contains("get(") { - Method::GET - } else if call.contains("post(") { - Method::POST - } else { - i = j; - continue; - }; - - if path.starts_with("/api/") { - routes.push((method, path)); - } - - i = j; - } - - routes.sort_by(|a, b| a.1.cmp(&b.1)); - routes -} - -#[test] -fn routing_source_contains_expected_api_routes() { - let routes = extract_api_routes_from_routing_source(); - assert!(!routes.is_empty()); - assert!(routes.contains(&(Method::POST, "/api/get_share_state".to_string()))); - assert!(routes.contains(&(Method::POST, "/api/auth/challenge".to_string()))); - assert!(routes.contains(&(Method::POST, "/api/auth/verify".to_string()))); -} - -#[test] -fn create_router_source_keeps_required_middlewares_wired() { - let src = include_str!("../src/http_server.rs"); - assert!(src.contains("auth_middleware")); - assert!(src.contains("localhost_only_middleware")); - assert!(src.contains("security_headers_middleware")); - assert!(src.contains("RequestBodyLimitLayer::new")); - assert!(src.contains("header::ORIGIN")); - assert!(src.contains("Origin not allowed")); -} diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 74fdef5..0000000 --- a/src/App.tsx +++ /dev/null @@ -1,409 +0,0 @@ -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Button } from "@/components/ui/button"; -import { - WelcomeView, - AddWorkspaceModal, - CreateWorktreeModal, - AddProjectToWorktreeModal, - ArchiveConfirmationModal, - CrashReportModal, - WorktreeContextMenu, - TerminalTabContextMenu, - RefreshIcon, - ToastProvider, - GlobalDialogs, - MobileWorktreeList, - MobileWorktreeDetail, - WorkspaceGrid, - WorkspaceCell, -} from "./components"; -import { useAppShellState } from "./hooks/useAppShellState"; -import { Input } from "@/components/ui/input"; -import { isTauri, callBackend } from "./lib/backend"; -import type { CrashReport } from "./types"; -import "./index.css"; - -// Disable browser-like behaviors (only in Tauri desktop mode) -if (typeof window !== 'undefined' && isTauri()) { - document.body.classList.add('tauri'); - window.addEventListener('contextmenu', (e) => e.preventDefault()); - window.addEventListener('keydown', (e) => { - if (e.key === 'F5' || (e.metaKey && e.key === 'r') || (e.ctrlKey && e.key === 'r')) { - e.preventDefault(); - } - if ((e.metaKey || e.ctrlKey) && e.key === 'p') { - e.preventDefault(); - } - // DEV: Open native WebView devtools on F12 if enabled in settings - if (e.key === 'F12') { - const devConsoleEnabled = localStorage.getItem('dev-console-enabled') === 'true'; - if (devConsoleEnabled) { - e.preventDefault(); - callBackend('open_devtools').catch(() => {}); - } - } - }, true); -} else if (typeof window !== 'undefined') { - document.body.classList.add('browser'); -} - -function App() { - const { t } = useTranslation(); - const [crashReport, setCrashReport] = useState(null); - // In Tauri desktop or web non-mobile, cells handle their own state. - // App shell only needs workspace list for routing. Mobile needs full state. - const shellMode = isTauri() || !window.matchMedia("(max-width: 639px)").matches; - const { - browserAuth, - workspace, - shareWorkspaceName, - isMobileWeb, - mobileView, - setMobileView, - showShortcutHelp, - setShowShortcutHelp, - terminalTabMenu, - setTerminalTabMenu, - modals, - share, - locks, - mainOccupation, - terminalHook, - actions, - updater, - wsConnected, - wasKicked, - setWasKicked, - voice, - openSettings, - handleTerminalTabContextMenu, - } = useAppShellState(t, undefined, shellMode); - - useEffect(() => { - let cancelled = false; - - callBackend('get_crash_report') - .then((report) => { - if (!cancelled && report) { - setCrashReport(report); - } - }) - .catch((err) => { - console.warn('[crash-report] failed to load crash report:', err); - }); - - return () => { - cancelled = true; - }; - }, []); - - // Browser mode: kicked screen - if (!isTauri() && wasKicked) { - return ( -
-
-
- ! -
-

{t('app.kickedTitle')}

-

{t('app.kickedDesc')}

- -
-
- ); - } - - // Browser mode: login screen - if (!isTauri() && !browserAuth.browserAuthenticated) { - return ( -
-
-
-
- -
-

Worktree Manager

- {shareWorkspaceName && ( -

{t('app.loginWorkspaceName', { name: shareWorkspaceName })}

- )} -

{t('app.loginPasswordLabel')}

-
-
{ e.preventDefault(); browserAuth.handleBrowserLogin(); }} className="space-y-3"> - browserAuth.setBrowserLoginPassword(e.target.value)} - autoFocus - className="bg-[var(--color-bg-surface)] border-[var(--color-border)]" - /> - {browserAuth.browserLoginError && ( -

{browserAuth.browserLoginError}

- )} - -
-
-
- ); - } - - // No workspace welcome - if (!workspace.loading && workspace.workspaces.length === 0) { - return ( - <> - modals.setModal('showAddWorkspaceModal', true)} - onCreateWorkspace={() => modals.setModal('showAddWorkspaceModal', true)} - /> - modals.setModal('showAddWorkspaceModal', v)} - name={actions.newWorkspaceName} - onNameChange={actions.setNewWorkspaceName} - path={actions.newWorkspacePath} - onPathChange={actions.setNewWorkspacePath} - onSubmit={actions.handleAddWorkspace} - loading={actions.addingWorkspace} - createName={actions.createWorkspaceName} - onCreateNameChange={actions.setCreateWorkspaceName} - createPath={actions.createWorkspacePath} - onCreatePathChange={actions.setCreateWorkspacePath} - onCreateSubmit={actions.handleCreateWorkspace} - createLoading={actions.creatingWorkspace} - /> - {crashReport && ( - setCrashReport(null)} - /> - )} - - ); - } - - return ( - - <> - {/* Loading overlay */} - {workspace.loading && ( -
-
- - {t('common.loading')} -
-
- )} - - {/* Browser mode: WebSocket disconnected overlay */} - {!isTauri() && browserAuth.browserAuthenticated && !wsConnected && ( -
- - {t('app.wsDisconnected')} -
- )} - - {/* Desktop Layout */} - {!isMobileWeb && isTauri() && workspace.currentWorkspace && ( - - )} - - {/* Web Browser Layout (single cell, no grid) */} - {!isMobileWeb && !isTauri() && workspace.currentWorkspace && ( -
- -
- )} - - {/* ==================== Mobile Layout ==================== */} - {isMobileWeb && ( -
- {/* Mobile content area */} -
- {/* List view */} - {mobileView === 'list' && ( - { - actions.handleSelectWorktree(wt); - setMobileView('detail'); - }} - onRefresh={workspace.loadData} - lockedWorktrees={locks.lockedWorktrees} - shareActive={share.shareActive} - onOpenCreateModal={actions.openCreateModal} - /> - )} - - {/* Detail view (with embedded Projects/Terminals tabs) */} - {mobileView === 'detail' && ( - setMobileView('list')} - onSwitchBranch={workspace.switchBranch} - onArchive={() => actions.selectedWorktree && actions.openArchiveModal(actions.selectedWorktree)} - onRestore={actions.handleRestoreWorktree} - onDelete={actions.selectedWorktree?.is_archived ? () => actions.setDeleteConfirmWorktree(actions.selectedWorktree) : undefined} - onOpenInEditor={actions.handleOpenInEditor} - onRevealInFinder={workspace.revealInFinder} - onOpenTerminalPanel={terminalHook.handleTerminalTabClick} - onAddProjectToWorktree={() => modals.setModal('showAddProjectToWorktreeModal', true)} - onRefresh={workspace.loadData} - selectedEditor={actions.selectedEditor} - error={workspace.error} - onClearError={() => workspace.setError(null)} - restoring={actions.restoringWorktree} - occupation={mainOccupation.occupation} - deploying={mainOccupation.deploying} - exiting={mainOccupation.exiting} - onDeployToMain={mainOccupation.deployToMain} - onExitOccupation={mainOccupation.exitOccupation} - onRefreshAfterDeploy={() => { actions.handleSelectWorktree(null as any); workspace.loadData(); }} - terminalTabs={terminalHook.terminalTabs} - activatedTerminals={terminalHook.activatedTerminals} - mountedTerminals={terminalHook.mountedTerminals} - activeTerminalTab={terminalHook.activeTerminalTab} - onTerminalTabClick={terminalHook.handleTerminalTabClick} - onTerminalTabContextMenu={handleTerminalTabContextMenu} - onCloseTerminalTab={terminalHook.handleCloseTerminalTab} - onCloseAllTerminalTabs={terminalHook.handleCloseAllTerminalTabs} - clientId={terminalHook.clientId} - voiceStatus={voice.voiceStatus} - voiceError={voice.voiceError} - voiceWarning={voice.voiceWarning} - isKeyHeld={voice.isKeyHeld} - analyserNode={voice.analyserNode} - onToggleVoice={voice.toggleVoice} - onStopRecording={voice.stopRecording} - staging={voice.staging} - /> - )} - -
-
- )} - - {/* Mobile-only Modals (desktop/web modals are inside WorkspaceCell) */} - {isMobileWeb && ( - <> - modals.setModal('showCreateModal', v)} - config={workspace.config} - worktreeName={actions.newWorktreeName} - onWorktreeNameChange={actions.setNewWorktreeName} - folderAlias={actions.folderAlias} - onFolderAliasChange={actions.setFolderAlias} - useFolderAlias={actions.useFolderAlias} - onUseFolderAliasChange={actions.setUseFolderAlias} - selectedProjects={actions.selectedProjects} - onToggleProject={actions.toggleProjectSelection} - onUpdateBaseBranch={actions.updateProjectBaseBranch} - onSubmit={actions.handleCreateWorktree} - creating={actions.creating} - syncBeforeCreate={actions.syncBeforeCreate} - onSyncBeforeCreateChange={actions.setSyncBeforeCreate} - /> - - modals.setModal('showAddProjectToWorktreeModal', v)} - config={workspace.config} - worktree={actions.selectedWorktree} - onSubmit={actions.handleAddProjectToWorktree} - adding={actions.addingProjectToWorktree} - /> - - {/* Context Menus */} - {actions.contextMenu && ( - actions.setContextMenu(null)} - onArchive={() => actions.openArchiveModal(actions.contextMenu!.worktree)} - /> - )} - - {terminalTabMenu && ( - setTerminalTabMenu(null)} - onDuplicate={() => { - terminalHook.handleDuplicateTerminal(terminalTabMenu.path); - setTerminalTabMenu(null); - }} - onCloseTab={() => { - terminalHook.handleCloseTerminalTab(terminalTabMenu.path); - setTerminalTabMenu(null); - }} - onCloseOtherTabs={() => { - terminalHook.handleCloseOtherTerminalTabs(terminalTabMenu.path); - setTerminalTabMenu(null); - }} - onCloseAllTabs={() => { - terminalHook.handleCloseAllTerminalTabs(); - setTerminalTabMenu(null); - }} - /> - )} - - {/* Archive Confirmation Modal */} - {actions.archiveModal && ( - actions.setArchiveModal(null)} - onConfirmIssue={actions.confirmArchiveIssue} - onTerminateProcess={actions.terminateArchiveLockProcess} - onArchive={actions.handleArchiveWorktree} - areAllIssuesConfirmed={actions.allArchiveIssuesConfirmed} - archiving={actions.archiving} - terminatingProcessPid={actions.terminatingArchiveLockPid} - /> - )} - - - - )} - - {crashReport && ( - setCrashReport(null)} - /> - )} - -
- ); -} - -export default App; diff --git a/src/assets/fonts/Inter-Variable.woff2 b/src/assets/fonts/Inter-Variable.woff2 deleted file mode 100644 index 5a8d3e7..0000000 Binary files a/src/assets/fonts/Inter-Variable.woff2 and /dev/null differ diff --git a/src/assets/fonts/MapleMono-NF-CN-Bold.woff2 b/src/assets/fonts/MapleMono-NF-CN-Bold.woff2 deleted file mode 100644 index 63a20b4..0000000 Binary files a/src/assets/fonts/MapleMono-NF-CN-Bold.woff2 and /dev/null differ diff --git a/src/assets/fonts/MapleMono-NF-CN-Regular.woff2 b/src/assets/fonts/MapleMono-NF-CN-Regular.woff2 deleted file mode 100644 index bcecc3f..0000000 Binary files a/src/assets/fonts/MapleMono-NF-CN-Regular.woff2 and /dev/null differ diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/AddProjectModal.tsx b/src/components/AddProjectModal.tsx deleted file mode 100644 index 514d2fc..0000000 --- a/src/components/AddProjectModal.tsx +++ /dev/null @@ -1,796 +0,0 @@ -import { type FC, useState, useEffect, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Checkbox } from '@/components/ui/checkbox'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { BranchCombobox } from './BranchCombobox'; -import { GitBranchIcon, RefreshIcon } from './Icons'; -import type { ScannedFolder } from '../types'; -import { scanExistingProjects, addExistingProject, importExternalProject, openDirectoryDialog, type ExistingProjectInfo } from '@/lib/backend'; - -interface AddProjectModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; - onSubmit: (project: { - name: string; - repo_url: string; - base_branch: string; - test_branch: string; - merge_strategy: string; - linked_folders: string[]; - }) => Promise; - loading?: boolean; - scanLinkedFolders?: (projectPath: string) => Promise; - workspacePath?: string; - onUpdateLinkedFolders?: (projectName: string, folders: string[]) => Promise; - onSuccess?: () => void; -} - -export const AddProjectModal: FC = ({ - open, - onOpenChange, - onSubmit, - loading = false, - scanLinkedFolders, - workspacePath, - onUpdateLinkedFolders, - onSuccess, -}) => { - const { t } = useTranslation(); - - // Elapsed time tracking for clone operation - const [elapsedSeconds, setElapsedSeconds] = useState(0); - const elapsedTimerRef = useRef | null>(null); - - useEffect(() => { - if (loading) { - setElapsedSeconds(0); - elapsedTimerRef.current = setInterval(() => { - setElapsedSeconds((prev) => prev + 1); - }, 1000); - } else { - if (elapsedTimerRef.current) { - clearInterval(elapsedTimerRef.current); - elapsedTimerRef.current = null; - } - } - return () => { - if (elapsedTimerRef.current) { - clearInterval(elapsedTimerRef.current); - } - }; - }, [loading]); - - const formatElapsed = (s: number) => { - const min = Math.floor(s / 60); - const sec = s % 60; - return min > 0 ? `${min}:${sec.toString().padStart(2, '0')}` : `${sec}s`; - }; - - // Form state - const [name, setName] = useState(''); - const [nameManuallyEdited, setNameManuallyEdited] = useState(false); - const [repoUrl, setRepoUrl] = useState(''); - const [baseBranch, setBaseBranch] = useState('main'); - const [testBranch, setTestBranch] = useState('test'); - const [mergeStrategy, setMergeStrategy] = useState('merge'); - const [urlFormat, setUrlFormat] = useState<'gh' | 'ssh' | 'https'>('gh'); - - // Two-phase flow state - const [phase, setPhase] = useState<'form' | 'scanning' | 'results'>('form'); - const [scanResults, setScanResults] = useState([]); - const [scanError, setScanError] = useState(null); - const [selectedFolders, setSelectedFolders] = useState>(new Set()); - const [customFolder, setCustomFolder] = useState(''); - const [savingFolders, setSavingFolders] = useState(false); - - // Tab mode: clone vs existing - const [mode, setMode] = useState<'clone' | 'existing'>('clone'); - - // Existing project state - const [existingProjects, setExistingProjects] = useState([]); - const [existingLoading, setExistingLoading] = useState(false); - const [existingError, setExistingError] = useState(null); - const [selectedExisting, setSelectedExisting] = useState(null); - const [existingBaseBranch, setExistingBaseBranch] = useState(''); - const [existingTestBranch, setExistingTestBranch] = useState('test'); - const [existingMergeStrategy, setExistingMergeStrategy] = useState('merge'); - const [addingExisting, setAddingExisting] = useState(false); - const [importingExternal, setImportingExternal] = useState(false); - - const extractProjectName = (url: string): string => { - const trimmed = url.trim(); - if (!trimmed) return ''; - // gh:owner/repo or owner/repo - if (!trimmed.includes('://') && !trimmed.startsWith('git@')) { - const repo = trimmed.replace(/^gh:/, ''); - const parts = repo.split('/'); - return (parts[parts.length - 1] || '').replace(/\.git$/, ''); - } - // git@github.com:owner/repo.git - if (trimmed.startsWith('git@')) { - const match = trimmed.match(/:(.+?)(?:\.git)?$/); - if (match) { - const parts = match[1].split('/'); - return parts[parts.length - 1] || ''; - } - } - // https://github.com/owner/repo.git - try { - const pathname = new URL(trimmed).pathname; - const parts = pathname.split('/').filter(Boolean); - return (parts[parts.length - 1] || '').replace(/\.git$/, ''); - } catch { - return ''; - } - }; - - const handleRepoUrlChange = (url: string) => { - setRepoUrl(url); - if (!nameManuallyEdited) { - const derived = extractProjectName(url); - if (derived) setName(derived); - } - }; - - const handleNameChange = (value: string) => { - setName(value); - setNameManuallyEdited(true); - }; - - const resetForm = () => { - setName(''); - setNameManuallyEdited(false); - setRepoUrl(''); - setBaseBranch('main'); - setTestBranch('test'); - setMergeStrategy('merge'); - setUrlFormat('gh'); - setPhase('form'); - setScanResults([]); - setScanError(null); - setSelectedFolders(new Set()); - setCustomFolder(''); - setSavingFolders(false); - setMode('clone'); - setExistingProjects([]); - setExistingLoading(false); - setExistingError(null); - setSelectedExisting(null); - setExistingBaseBranch(''); - setExistingTestBranch('test'); - setExistingMergeStrategy('merge'); - setAddingExisting(false); - setImportingExternal(false); - }; - - const handleBrowseProject = async () => { - try { - const selectedPath = await openDirectoryDialog(t('addExistingProject.browseTitle')); - if (!selectedPath) return; - - setImportingExternal(true); - setExistingError(null); - const imported = await importExternalProject(selectedPath); - - // Reload list so the imported project appears - await loadExistingProjects(); - // Auto-select the imported project - setSelectedExisting(imported.name); - setExistingBaseBranch(imported.current_branch); - } catch (e) { - setExistingError(String(e)); - } finally { - setImportingExternal(false); - } - }; - - const handleSubmit = async () => { - if (!name.trim() || !repoUrl.trim()) return; - - try { - // Clone with empty linked_folders first - await onSubmit({ - name: name.trim(), - repo_url: repoUrl.trim(), - base_branch: baseBranch.trim(), - test_branch: testBranch.trim(), - merge_strategy: mergeStrategy, - linked_folders: [], - }); - - // After successful clone, start scanning if available - if (scanLinkedFolders && workspacePath) { - setPhase('scanning'); - setScanError(null); - try { - const projectPath = `${workspacePath}/projects/${name.trim()}`; - const results = await scanLinkedFolders(projectPath); - setScanResults(results); - - // Pre-select recommended folders - const recommended = new Set(); - results.forEach(r => { - if (r.is_recommended) { - recommended.add(r.relative_path); - } - }); - setSelectedFolders(recommended); - - setPhase('results'); - } catch (e) { - setScanError(String(e)); - setPhase('results'); - } - } else { - // No scanning available, close modal - onOpenChange(false); - resetForm(); - } - } catch { - // Clone failed, stay on form (error handled by parent) - } - }; - - const toggleFolder = (relativePath: string) => { - setSelectedFolders(prev => { - const next = new Set(prev); - if (next.has(relativePath)) { - next.delete(relativePath); - } else { - next.add(relativePath); - } - return next; - }); - }; - - const addCustomFolder = () => { - const folder = customFolder.trim(); - if (!folder) return; - setSelectedFolders(prev => { - const next = new Set(prev); - next.add(folder); - return next; - }); - setCustomFolder(''); - }; - - const handleSaveFolders = async () => { - if (!onUpdateLinkedFolders) return; - setSavingFolders(true); - try { - await onUpdateLinkedFolders(name.trim(), Array.from(selectedFolders)); - onOpenChange(false); - resetForm(); - } catch { - // Error handled by parent - } finally { - setSavingFolders(false); - } - }; - - const handleSkip = () => { - onOpenChange(false); - resetForm(); - }; - - const handleClose = (newOpen: boolean) => { - if (!newOpen) { - resetForm(); - } - onOpenChange(newOpen); - }; - - const getPlaceholder = () => { - switch (urlFormat) { - case 'gh': - return t('addProject.ghPlaceholder'); - case 'ssh': - return 'git@github.com:owner/repo.git'; - case 'https': - return 'https://github.com/owner/repo.git'; - } - }; - - // --- Existing project helpers --- - const loadExistingProjects = async () => { - setExistingLoading(true); - setExistingError(null); - try { - const projects = await scanExistingProjects(); - setExistingProjects(projects); - } catch (e) { - setExistingError(String(e)); - } finally { - setExistingLoading(false); - } - }; - - const handleSelectExisting = (proj: ExistingProjectInfo) => { - setSelectedExisting(proj.name); - setExistingBaseBranch(proj.current_branch); - }; - - const handleAddExisting = async () => { - if (!selectedExisting || !existingBaseBranch) return; - setAddingExisting(true); - try { - await addExistingProject(selectedExisting, existingBaseBranch, existingTestBranch || 'test', existingMergeStrategy); - onSuccess?.(); - // Reload list so newly-added project shows as "registered" - setSelectedExisting(null); - setExistingBaseBranch(''); - setExistingTestBranch('test'); - setExistingMergeStrategy('merge'); - await loadExistingProjects(); - } catch (e) { - setExistingError(String(e)); - } finally { - setAddingExisting(false); - } - }; - - // Custom folders that aren't from scan results - const scanResultPaths = new Set(scanResults.map(r => r.relative_path)); - const customSelectedFolders = Array.from(selectedFolders).filter(f => !scanResultPaths.has(f)); - - return ( - - - - - {phase === 'form' ? t('addProject.title') : t('addProject.selectLinkedFolders')} - - - {phase === 'form' - ? (mode === 'clone' ? t('addProject.cloneDesc') : t('addExistingProject.desc')) - : t('addProject.selectLinkedFoldersDesc')} - - - - {/* Phase 1: Form */} - {phase === 'form' && ( - <> - {/* Tab switching */} -
- - -
- - {/* Clone form */} - {mode === 'clone' && ( - <> -
- {/* Project Name */} -
- - handleNameChange(e.target.value)} - placeholder="my-project" - /> -
- - {/* URL Format Selector */} -
- -
- - - -
-
- - {/* Repository URL */} -
- - handleRepoUrlChange(e.target.value)} - placeholder={getPlaceholder()} - autoFocus - onKeyDown={(e) => { if (e.key === 'Enter' && name.trim() && repoUrl.trim() && !loading) handleSubmit(); }} - /> -

- {urlFormat === 'gh' && t('addProject.ghShortFormat')} - {urlFormat === 'ssh' && t('addProject.sshFormat')} - {urlFormat === 'https' && t('addProject.httpsFormat')} -

-
- - {/* Base Branch */} -
-
- - -
- - {/* Test Branch */} -
- - -
-
- - {/* Merge Strategy */} -
- - -
-
- - {loading && ( -
-
-
-
-
- {t('addProject.cloning')} {formatElapsed(elapsedSeconds)} -
-
- )} - - - - - - )} - - {/* Existing project tab */} - {mode === 'existing' && ( - <> -
- {/* Browse external project button */} - - - {existingError && ( -
-
{existingError}
-
- )} - - {existingLoading ? ( -
- - {t('addExistingProject.scanning')} -
- ) : existingProjects.length === 0 ? ( -
-

{t('addExistingProject.noProjects')}

-

{t('addExistingProject.noProjectsHint')}

-
- ) : ( - <> -
- {existingProjects.map(proj => ( - - ))} -
- - {selectedExisting && ( -
-
-
- - setExistingBaseBranch(e.target.value)} - placeholder="e.g. uat, main, master" - /> -
-
- - setExistingTestBranch(e.target.value)} - placeholder="e.g. test, develop" - /> -
-
-
- - -
-
- )} - - )} -
- - - - - - - )} - - )} - - {/* Phase 2: Scanning */} - {phase === 'scanning' && ( -
-
-

{t('addProject.scanning')}

-
- )} - - {/* Phase 3: Results */} - {phase === 'results' && ( - <> -
- {scanError && ( -
-

{t('addProject.scanError', { error: scanError })}

-
- )} - - {scanResults.length === 0 && !scanError && ( -
-

{t('addProject.noLinkedFoldersFound')}

-
- )} - - {scanResults.length > 0 && ( -
- {scanResults.map(result => ( -
- toggleFolder(result.relative_path)} - /> - - - {result.size_display} - -
- ))} -
- )} - - {/* Custom selected folders */} - {customSelectedFolders.length > 0 && ( -
-
{t('addProject.customFolders')}
- {customSelectedFolders.map(folder => ( -
- {folder} - -
- ))} -
- )} - - {/* Add Custom Folder */} -
- -
- setCustomFolder(e.target.value)} - placeholder={t('addProject.customFolderPlaceholder')} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - addCustomFolder(); - } - }} - className="flex-1" - /> - -
-
-
- - - - - - - )} - -
- ); -}; diff --git a/src/components/AddProjectToWorktreeModal.tsx b/src/components/AddProjectToWorktreeModal.tsx deleted file mode 100644 index b77b554..0000000 --- a/src/components/AddProjectToWorktreeModal.tsx +++ /dev/null @@ -1,317 +0,0 @@ -import { useState, useMemo, type FC } from 'react'; -import { useTranslation, Trans } from 'react-i18next'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { PlusIcon } from './Icons'; -import type { WorkspaceConfig, WorktreeListItem, TagDefinition } from '../types'; - -interface AddProjectToWorktreeModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; - config: WorkspaceConfig | null; - worktree: WorktreeListItem | null; - onSubmit: (projectName: string, baseBranch: string) => Promise; - adding: boolean; -} - -export const AddProjectToWorktreeModal: FC = ({ - open, - onOpenChange, - config, - worktree, - onSubmit, - adding, -}) => { - const { t } = useTranslation(); - const [selectedProject, setSelectedProject] = useState(null); - const [baseBranch, setBaseBranch] = useState(''); - const [viewMode, setViewMode] = useState<'all' | 'byTag'>('all'); - const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); - - // Filter out projects already in the worktree - const availableProjects = useMemo(() => { - if (!config || !worktree) return []; - const existingProjectNames = new Set(worktree.projects.map(p => p.name)); - return config.projects.filter(p => !existingProjectNames.has(p.name)); - }, [config, worktree]); - - const selectedProjectConfig = availableProjects.find(p => p.name === selectedProject); - - // Tag grouping logic - const tagGroups = useMemo(() => { - if (!config) return null; - const tags = config.tags ?? []; - if (tags.length === 0) return null; - - const groups: Array<{ tag: TagDefinition | null; projects: typeof availableProjects }> = []; - - for (const tag of tags) { - const tagProjects = availableProjects.filter(p => { - const pc = config.projects.find(pc => pc.name === p.name); - return (pc?.tags ?? []).includes(tag.id); - }); - if (tagProjects.length > 0) { - groups.push({ tag, projects: tagProjects }); - } - } - - // Untagged - const taggedNames = new Set( - tags.flatMap(tag => - config.projects.filter(pc => (pc.tags ?? []).includes(tag.id)).map(pc => pc.name) - ) - ); - const untagged = availableProjects.filter(p => !taggedNames.has(p.name)); - if (untagged.length > 0) { - groups.push({ tag: null, projects: untagged }); - } - - return groups; - }, [config, availableProjects]); - - if (!config || !worktree) return null; - - const handleProjectSelect = (name: string) => { - setSelectedProject(name); - const proj = availableProjects.find(p => p.name === name); - if (proj) { - setBaseBranch(proj.base_branch); - } - }; - - const handleSubmit = async () => { - if (!selectedProject || !baseBranch) return; - await onSubmit(selectedProject, baseBranch); - setSelectedProject(null); - setBaseBranch(''); - }; - - const handleOpenChange = (open: boolean) => { - if (!open) { - setSelectedProject(null); - setBaseBranch(''); - setViewMode('all'); - setCollapsedGroups(new Set()); - } - onOpenChange(open); - }; - - const toggleGroupCollapse = (tagId: string) => { - setCollapsedGroups(prev => { - const next = new Set(prev); - if (next.has(tagId)) next.delete(tagId); - else next.add(tagId); - return next; - }); - }; - - const getProjectTags = (projectName: string): TagDefinition[] => { - const pc = config?.projects.find(p => p.name === projectName); - return (pc?.tags ?? []) - .map(tid => (config?.tags ?? []).find(t => t.id === tid)) - .filter((t): t is TagDefinition => !!t); - }; - - return ( - - - - {t('addProjectToWorktree.title')} - -
-

- ]} - /> -

- - {availableProjects.length === 0 ? ( -
- -

{t('addProjectToWorktree.noProjects')}

-

{t('addProjectToWorktree.allProjectsAdded')}

-
- ) : ( -
-
- - - {/* Tab switcher — only shown when tags exist */} - {tagGroups && ( -
- - -
- )} - - {viewMode === 'byTag' && tagGroups ? ( -
- {tagGroups.map(({ tag, projects: groupProjects }) => { - const tagId = tag?.id ?? '__untagged__'; - const isCollapsed = collapsedGroups.has(tagId); - - return ( -
- {/* Group header */} -
toggleGroupCollapse(tagId)} - > - - {isCollapsed ? '▶' : '▼'} - - - {tag?.name ?? t('tags.untagged')} - ({groupProjects.length}) -
- - {/* Projects in this group */} - {!isCollapsed && ( -
- {groupProjects.map(proj => ( -
handleProjectSelect(proj.name)} - > -
-
-
- {selectedProject === proj.name && ( -
- )} -
- {proj.name} -
- {/* Tag chips */} -
- {getProjectTags(proj.name).map(tag => ( - - {tag.name} - - ))} -
-
-
- {t('addProjectToWorktree.defaultBranch')}: {proj.base_branch} · {t('addProjectToWorktree.testBranch')}: {proj.test_branch} -
-
- ))} -
- )} -
- ); - })} -
- ) : ( - // Flat list view -
- {availableProjects.map(proj => ( -
handleProjectSelect(proj.name)} - > -
-
-
- {selectedProject === proj.name && ( -
- )} -
- {proj.name} -
-
-
- {t('addProjectToWorktree.defaultBranch')}: {proj.base_branch} · {t('addProjectToWorktree.testBranch')}: {proj.test_branch} -
-
- ))} -
- )} -
- - {selectedProjectConfig && ( -
- - -
- )} -
- )} -
- - - - - -
- ); -}; diff --git a/src/components/AddWorkspaceModal.tsx b/src/components/AddWorkspaceModal.tsx deleted file mode 100644 index fb61045..0000000 --- a/src/components/AddWorkspaceModal.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { type FC, useCallback, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { openDirectoryDialog } from '../lib/backend'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { FolderIcon } from './Icons'; -import { basename } from '@/lib/utils'; - -interface AddWorkspaceModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; - name: string; - onNameChange: (name: string) => void; - path: string; - onPathChange: (path: string) => void; - onSubmit: () => void; - loading?: boolean; - // Create workspace props - createName: string; - onCreateNameChange: (name: string) => void; - createPath: string; - onCreatePathChange: (path: string) => void; - onCreateSubmit: () => void; - createLoading?: boolean; -} - -export const AddWorkspaceModal: FC = ({ - open: isOpen, - onOpenChange, - name, - onNameChange, - path, - onPathChange, - onSubmit, - loading = false, - createName, - onCreateNameChange, - createPath, - onCreatePathChange, - onCreateSubmit, - createLoading = false, -}) => { - const { t } = useTranslation(); - const [mode, setMode] = useState<'import' | 'create'>('import'); - - const handleSelectFolder = useCallback(async () => { - const selected = await openDirectoryDialog(t('addWorkspace.selectDir')); - if (selected) { - onPathChange(selected); - // Auto-fill name from folder name if empty - if (!name) { - const folderName = basename(selected); - if (folderName) { - onNameChange(folderName); - } - } - } - }, [name, onNameChange, onPathChange, t]); - - const handleSelectParentFolder = useCallback(async () => { - const selected = await openDirectoryDialog(t('addWorkspace.selectParentDir')); - if (selected) { - onCreatePathChange(selected); - } - }, [onCreatePathChange, t]); - - const fullPath = createPath && createName ? `${createPath}/${createName}` : ''; - - const handleOpenChange = useCallback((open: boolean) => { - if (!open) setMode('import'); - onOpenChange(open); - }, [onOpenChange]); - - return ( - - - - - {mode === 'import' ? t('addWorkspace.importTitle') : t('addWorkspace.createTitle')} - - - {mode === 'import' ? t('addWorkspace.importDesc') : t('addWorkspace.createDesc')} - - - - {/* Tab switcher */} -
- - -
- - {mode === 'import' ? ( - <> -
-
- -
- onPathChange(e.target.value)} - placeholder="/Users/xxx/Work/my-workspace" - className="flex-1" - autoFocus - onKeyDown={(e) => { if (e.key === 'Enter' && name.trim() && path.trim() && !loading) onSubmit(); }} - /> - -
-

{t('addWorkspace.dirPathHint')}

-
-
- - onNameChange(e.target.value)} - placeholder="My Workspace" - onKeyDown={(e) => { if (e.key === 'Enter' && name.trim() && path.trim() && !loading) onSubmit(); }} - /> -
-
- - - - - - ) : ( - <> -
-
- - onCreateNameChange(e.target.value)} - placeholder="my-workspace" - autoFocus - onKeyDown={(e) => { if (e.key === 'Enter' && createName.trim() && createPath.trim() && !createLoading) onCreateSubmit(); }} - /> -
-
- -
- onCreatePathChange(e.target.value)} - placeholder="/Users/xxx/Work" - className="flex-1" - onKeyDown={(e) => { if (e.key === 'Enter' && createName.trim() && createPath.trim() && !createLoading) onCreateSubmit(); }} - /> - -
-
- {fullPath && ( -
-

{t('addWorkspace.willCreate')}

-

{fullPath}

-
- )} -
- - - - - - )} -
-
- ); -}; diff --git a/src/components/ArchiveConfirmationModal.tsx b/src/components/ArchiveConfirmationModal.tsx deleted file mode 100644 index 99d3a51..0000000 --- a/src/components/ArchiveConfirmationModal.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import type { FC } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Button } from '@/components/ui/button'; -import { StatusDot, GitBranchIcon, RefreshIcon, CheckIcon, CheckCircleIcon, WarningIcon } from './Icons'; -import type { ArchiveModalState } from '../types'; - -interface ArchiveConfirmationModalProps { - archiveModal: ArchiveModalState; - onClose: () => void; - onConfirmIssue: (issueKey: string) => void; - onTerminateProcess: (pid: number) => void; - onArchive: () => void; - areAllIssuesConfirmed: boolean; - archiving?: boolean; - terminatingProcessPid?: number | null; -} - -export const ArchiveConfirmationModal: FC = ({ - archiveModal, - onClose, - onConfirmIssue, - onTerminateProcess, - onArchive, - areAllIssuesConfirmed, - archiving = false, - terminatingProcessPid = null, -}) => { - const { t } = useTranslation(); - return ( -
-
-
-

{t('archive.title')}

-

- {archiveModal.worktree.display_name || archiveModal.worktree.name} → {archiveModal.worktree.name}.archive -

-
- -
- {archiveModal.loading ? ( -
- - {t('archive.checkingStatus')} -
- ) : archiveModal.status ? ( -
- {archiveModal.status.locked_processes.length > 0 && ( -
-

{t('archive.fileUsage')}

-
- {archiveModal.status.locked_processes.map((process) => ( -
-
-
-
- - {process.name} - PID {process.pid} -
-
- {t('archive.lockedProcessDesc')} -
-
- -
-
- ))} -
-
- )} - - {archiveModal.status.lock_check_error && archiveModal.status.locked_processes.length === 0 && ( -
-
- - {archiveModal.status.lock_check_error} -
-
- )} - -
-

{t('archive.projectStatus')}

-
- {archiveModal.status.projects.map((proj) => { - const hasUncommitted = proj.has_uncommitted && proj.uncommitted_count > 0; - const hasUnpushed = proj.unpushed_commits > 0; - const uncommittedKey = `proj-uncommitted-${proj.project_name}`; - const unpushedKey = `proj-unpushed-${proj.project_name}`; - const uncommittedConfirmed = archiveModal.confirmedIssues.has(uncommittedKey); - const unpushedConfirmed = archiveModal.confirmedIssues.has(unpushedKey); - const hasIssues = hasUncommitted || hasUnpushed; - - return ( -
-
-
- - {proj.project_name} -
-
- - {proj.branch_name} -
-
- - {hasIssues ? ( -
- {hasUncommitted && ( -
- - {t('archive.uncommittedChanges', { count: proj.uncommitted_count })} - - {uncommittedConfirmed ? ( - - - {t('archive.confirmed')} - - ) : ( - - )} -
- )} - {hasUnpushed && ( -
- - {t('archive.unpushedCommits', { count: proj.unpushed_commits })} - - {unpushedConfirmed ? ( - - - {t('archive.confirmed')} - - ) : ( - - )} -
- )} -
- ) : ( -
- - {t('archive.noIssues')} -
- )} -
- ); - })} -
-
- - {areAllIssuesConfirmed && ( -
-
- - {t('archive.allConfirmedReady')} -
-
- )} -
- ) : null} -
- - {archiveModal.archiveError && ( -
-
-
- - {archiveModal.archiveError} -
-
-
- )} - -
- - -
-
-
- ); -}; diff --git a/src/components/BatchArchiveModal.tsx b/src/components/BatchArchiveModal.tsx deleted file mode 100644 index 2587e74..0000000 --- a/src/components/BatchArchiveModal.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { useState, type FC } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; -import { ArchiveIcon, RefreshIcon, TrashIcon } from './Icons'; -import type { WorktreeListItem } from '../types'; - -interface BatchArchiveModalProps { - open: boolean; - archivedWorktrees: WorktreeListItem[]; - onClose: () => void; - onRestore: (names: string[]) => Promise; - onDelete: (names: string[]) => Promise; -} - -export const BatchArchiveModal: FC = ({ - open, - archivedWorktrees, - onClose, - onRestore, - onDelete, -}) => { - const { t } = useTranslation(); - const [selected, setSelected] = useState>(new Set()); - const [restoring, setRestoring] = useState(false); - const [deleting, setDeleting] = useState(false); - - if (!open) return null; - - const toggleAll = () => { - if (selected.size === archivedWorktrees.length) { - setSelected(new Set()); - } else { - setSelected(new Set(archivedWorktrees.map((w) => w.name))); - } - }; - - const toggleOne = (name: string) => { - const next = new Set(selected); - if (next.has(name)) { - next.delete(name); - } else { - next.add(name); - } - setSelected(next); - }; - - const handleRestore = async () => { - if (selected.size === 0) return; - setRestoring(true); - try { - await onRestore(Array.from(selected)); - setSelected(new Set()); - } finally { - setRestoring(false); - } - }; - - const handleDelete = async () => { - if (selected.size === 0) return; - const ok = window.confirm( - t('batchArchive.confirmDelete', { count: selected.size }), - ); - if (!ok) return; - setDeleting(true); - try { - await onDelete(Array.from(selected)); - setSelected(new Set()); - } finally { - setDeleting(false); - } - }; - - const allSelected = selected.size === archivedWorktrees.length && archivedWorktrees.length > 0; - const someSelected = selected.size > 0; - - return ( -
-
-
-

{t('batchArchive.title')}

- -
- -
- - - {t('batchArchive.selectAll', { count: archivedWorktrees.length })} - - {someSelected && ( - - {t('batchArchive.selected', { count: selected.size })} - - )} -
- -
- {archivedWorktrees.length === 0 ? ( -
- {t('batchArchive.empty')} -
- ) : ( -
- {archivedWorktrees.map((worktree) => ( - - ))} -
- )} -
- -
- - - -
-
-
- ); -} diff --git a/src/components/BranchCombobox.tsx b/src/components/BranchCombobox.tsx deleted file mode 100644 index 3191bc7..0000000 --- a/src/components/BranchCombobox.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useState, useEffect, type FC } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@/components/ui/popover'; -import { ChevronDownIcon, RefreshIcon } from './Icons'; - -interface BranchComboboxProps { - value: string; - onChange: (value: string) => void; - onLoadBranches?: () => Promise; - placeholder?: string; - disabled?: boolean; -} - -export const BranchCombobox: FC = ({ - value, - onChange, - onLoadBranches, - placeholder, - disabled = false, -}) => { - const { t } = useTranslation(); - const resolvedPlaceholder = placeholder ?? t('branchCombobox.placeholder'); - const [open, setOpen] = useState(false); - const [branches, setBranches] = useState([]); - const [loading, setLoading] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const [inputValue, setInputValue] = useState(value); - - useEffect(() => { - setInputValue(value); - }, [value]); - - const loadBranches = async () => { - if (!onLoadBranches) return; - setLoading(true); - try { - const remoteBranches = await onLoadBranches(); - setBranches(remoteBranches); - } catch (err) { - console.error('Failed to load branches:', err); - setBranches([]); - } finally { - setLoading(false); - } - }; - - const handleOpenChange = (newOpen: boolean) => { - setOpen(newOpen); - if (newOpen && branches.length === 0 && onLoadBranches) { - loadBranches(); - } - }; - - const handleSelect = (branch: string) => { - setInputValue(branch); - onChange(branch); - setOpen(false); - setSearchQuery(''); - }; - - const handleInputChange = (newValue: string) => { - setInputValue(newValue); - onChange(newValue); - }; - - const handleInputBlur = () => { - // Commit the input value when focus is lost - if (inputValue !== value) { - onChange(inputValue); - } - }; - - const filteredBranches = branches.filter(branch => - branch.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - return ( -
-
- handleInputChange(e.target.value)} - onBlur={handleInputBlur} - placeholder={resolvedPlaceholder} - disabled={disabled} - className="flex-1" - /> - {onLoadBranches && ( - - - - - -
-
-
- setSearchQuery(e.target.value)} - placeholder={t('branchCombobox.search')} - className="flex-1 h-8 text-sm" - autoFocus - /> - -
-
-
- {loading ? ( -
- {t('common.loading')} -
- ) : filteredBranches.length === 0 ? ( -
- {searchQuery ? t('branchCombobox.noMatch') : t('branchCombobox.noBranches')} -
- ) : ( -
- {filteredBranches.map((branch) => ( - - ))} -
- )} -
-
-
-
- )} -
-
- ); -}; diff --git a/src/components/ChangedFilesPanel.tsx b/src/components/ChangedFilesPanel.tsx deleted file mode 100644 index b1e86d1..0000000 --- a/src/components/ChangedFilesPanel.tsx +++ /dev/null @@ -1,1140 +0,0 @@ -import { useState, useEffect, useRef, useCallback, useMemo, type FC } from 'react'; -import { useTranslation } from 'react-i18next'; -import { getChangedFiles, getFileDiff } from '@/lib/backend'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { ChevronIcon, FolderIcon, LogIcon, RefreshIcon } from '@/components/Icons'; -import type { ChangedFile, FileDiff, ProjectStatus } from '../types'; - -// ==================== Status helpers ==================== - -const STATUS_COLORS: Record = { - M: 'text-[var(--color-warning)]', - A: 'text-[var(--color-success)]', - D: 'text-[var(--color-error)]', - R: 'text-[var(--color-accent)]', - C: 'text-[var(--color-accent)]', - '?': 'text-[var(--color-text-muted)]', -}; - -const STATUS_LABELS: Record = { - M: 'Modified', - A: 'Added', - D: 'Deleted', - R: 'Renamed', - C: 'Copied', - '?': 'Untracked', -}; - -const STATUS_BG: Record = { - M: 'bg-amber-500/10 border-amber-500/30', - A: 'bg-emerald-500/10 border-emerald-500/30', - D: 'bg-[var(--color-error)]/10 border-[var(--color-error)]/30', - R: 'bg-[var(--color-accent)]/10 border-[var(--color-accent)]/30', - C: 'bg-sky-500/10 border-sky-500/30', - '?': 'bg-[var(--color-text-muted)]/10 border-[var(--color-text-muted)]/30', -}; - -const STATUS_ORDER = ['M', 'A', 'D', 'R', 'C', '?'] as const; -type ChangedFileEntry = ChangedFile & { projectName: string }; -const CHANGED_FILE_MEMORY_KEY = 'worktree-manager.changed-files.last-selection.v1'; -const LARGE_DIFF_THRESHOLD = 400; -const DIFF_CONTEXT_LINES = 3; - -function readSelectionMemory(): Record { - if (typeof window === 'undefined') return {}; - try { - const raw = window.localStorage.getItem(CHANGED_FILE_MEMORY_KEY); - if (!raw) return {}; - const parsed = JSON.parse(raw); - return parsed && typeof parsed === 'object' ? parsed : {}; - } catch { - return {}; - } -} - -function readRememberedSelection(reviewKey: string): string | null { - return readSelectionMemory()[reviewKey] ?? null; -} - -function writeRememberedSelection(reviewKey: string, fileKey: string): void { - if (typeof window === 'undefined') return; - try { - const next = readSelectionMemory(); - next[reviewKey] = fileKey; - window.localStorage.setItem(CHANGED_FILE_MEMORY_KEY, JSON.stringify(next)); - } catch { - // ignore storage failures - } -} - -function buildExpandedPathSet(files: ChangedFileEntry[]): Set { - const paths = new Set(); - for (const file of files) { - const parts = [file.projectName, ...file.path.split(/[/\\]/)]; - for (let i = 1; i < parts.length; i++) { - paths.add(parts.slice(0, i).join('/')); - } - } - return paths; -} - -function countStatuses(files: ChangedFileEntry[]): Record { - return files.reduce>((acc, file) => { - acc[file.status] = (acc[file.status] || 0) + 1; - return acc; - }, {}); -} - -type DiffRow = - | { type: 'pair'; pair: DiffPair } - | { type: 'gap'; hiddenCount: number }; - -function buildChangedOnlyRows(pairs: DiffPair[], contextLines: number): DiffRow[] { - const changedIndices = pairs - .map((pair, index) => { - const changed = pair.left.type !== 'same' || pair.right.type !== 'same'; - return changed ? index : -1; - }) - .filter((index) => index >= 0); - - if (changedIndices.length === 0) { - return pairs.map((pair) => ({ type: 'pair', pair })); - } - - const ranges: Array<{ start: number; end: number }> = []; - for (const index of changedIndices) { - const start = Math.max(0, index - contextLines); - const end = Math.min(pairs.length - 1, index + contextLines); - const previous = ranges[ranges.length - 1]; - if (!previous || start > previous.end + 1) { - ranges.push({ start, end }); - } else { - previous.end = Math.max(previous.end, end); - } - } - - const rows: DiffRow[] = []; - let cursor = 0; - for (const range of ranges) { - if (range.start > cursor) { - rows.push({ type: 'gap', hiddenCount: range.start - cursor }); - } - for (let index = range.start; index <= range.end; index++) { - rows.push({ type: 'pair', pair: pairs[index] }); - } - cursor = range.end + 1; - } - - if (cursor < pairs.length) { - rows.push({ type: 'gap', hiddenCount: pairs.length - cursor }); - } - - return rows; -} - -// ==================== Tree builder ==================== - -interface TreeNode { - name: string; - path: string; - children: Map; - file?: ChangedFileEntry; - expanded: boolean; -} - -function buildTree( - files: ChangedFileEntry[] -): TreeNode { - const root: TreeNode = { - name: '', - path: '', - children: new Map(), - expanded: true, - }; - - for (const file of files) { - const parts = [file.projectName, ...file.path.split(/[/\\]/)]; - let current = root; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - const isFile = i === parts.length - 1; - - if (!current.children.has(part)) { - current.children.set(part, { - name: part, - path: parts.slice(0, i + 1).join('/'), - children: new Map(), - expanded: true, - }); - } - - const node = current.children.get(part)!; - if (isFile) { - node.file = file; - } - current = node; - } - } - - // Collapse single-child directory chains (GitLab-style) - collapseTree(root); - return root; -} - -/** - * Recursively merges single-child directory chains. - * e.g., src -> main -> java -> com becomes src/main/java/com - * Keeps project root nodes (depth 1) separate for clarity. - */ -function collapseTree(node: TreeNode): void { - for (const [, child] of node.children) { - collapseTree(child); - } - - // Merge: if this node is a directory with exactly one child that is also a - // directory (not a file), merge the child into this node. - if (!node.file && node.children.size === 1) { - const [, onlyChild] = Array.from(node.children.entries())[0]; - if (!onlyChild.file && onlyChild.children.size > 0) { - // Don't collapse project root into the virtual root - if (node.path === '') return; - node.name = `${node.name}/${onlyChild.name}`; - node.path = onlyChild.path; - node.children = onlyChild.children; - } - } -} - -// ==================== Diff computation ==================== - -interface DiffLine { - type: 'same' | 'add' | 'remove' | 'empty'; - content: string; - oldLine?: number; - newLine?: number; -} - -interface DiffPair { - left: DiffLine; - right: DiffLine; -} - -function computeSideBySideDiff(oldText: string, newText: string): DiffPair[] { - const oldLines = oldText.split('\n'); - const newLines = newText.split('\n'); - - // Simple LCS-based diff - const m = oldLines.length; - const n = newLines.length; - - // For large files, use a simpler approach - if (m + n > 5000) { - return simpleDiff(oldLines, newLines); - } - - // Build LCS table - const dp: number[][] = Array.from({ length: m + 1 }, () => - new Array(n + 1).fill(0) - ); - for (let i = 1; i <= m; i++) { - for (let j = 1; j <= n; j++) { - if (oldLines[i - 1] === newLines[j - 1]) { - dp[i][j] = dp[i - 1][j - 1] + 1; - } else { - dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); - } - } - } - - // Backtrack to find diff - let i = m, j = n; - const tempPairs: DiffPair[] = []; - - while (i > 0 || j > 0) { - if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { - tempPairs.push({ - left: { type: 'same', content: oldLines[i - 1], oldLine: i }, - right: { type: 'same', content: newLines[j - 1], newLine: j }, - }); - i--; - j--; - } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { - tempPairs.push({ - left: { type: 'empty', content: '' }, - right: { type: 'add', content: newLines[j - 1], newLine: j }, - }); - j--; - } else { - tempPairs.push({ - left: { type: 'remove', content: oldLines[i - 1], oldLine: i }, - right: { type: 'empty', content: '' }, - }); - i--; - } - } - - tempPairs.reverse(); - return tempPairs; -} - -function simpleDiff(oldLines: string[], newLines: string[]): DiffPair[] { - const pairs: DiffPair[] = []; - const maxLen = Math.max(oldLines.length, newLines.length); - - for (let i = 0; i < maxLen; i++) { - const oldLine = i < oldLines.length ? oldLines[i] : undefined; - const newLine = i < newLines.length ? newLines[i] : undefined; - - if (oldLine === newLine) { - pairs.push({ - left: { type: 'same', content: oldLine!, oldLine: i + 1 }, - right: { type: 'same', content: newLine!, newLine: i + 1 }, - }); - } else if (oldLine !== undefined && newLine !== undefined) { - pairs.push({ - left: { type: 'remove', content: oldLine, oldLine: i + 1 }, - right: { type: 'add', content: newLine, newLine: i + 1 }, - }); - } else if (oldLine !== undefined) { - pairs.push({ - left: { type: 'remove', content: oldLine, oldLine: i + 1 }, - right: { type: 'empty', content: '' }, - }); - } else { - pairs.push({ - left: { type: 'empty', content: '' }, - right: { type: 'add', content: newLine!, newLine: i + 1 }, - }); - } - } - - return pairs; -} - -// ==================== FileTreeItem ==================== - -const FileTreeItem: FC<{ - node: TreeNode; - depth: number; - selectedFile: string | null; - onSelect: (projectName: string, filePath: string, key: string) => void; - expandedPaths: Set; - onToggleExpand: (path: string) => void; -}> = ({ node, depth, selectedFile, onSelect, expandedPaths, onToggleExpand }) => { - const isFile = !!node.file; - const hasChildren = node.children.size > 0; - const isExpanded = expandedPaths.has(node.path); - const isSelected = selectedFile === node.path; - - if (isFile) { - const file = node.file!; - return ( - - ); - } - - if (!hasChildren) return null; - - return ( -
- - {isExpanded && ( -
- {Array.from(node.children.values()) - .sort((a, b) => { - // Directories first, then files - const aIsDir = !a.file; - const bIsDir = !b.file; - if (aIsDir !== bIsDir) return aIsDir ? -1 : 1; - return a.name.localeCompare(b.name); - }) - .map((child) => ( - - ))} -
- )} -
- ); -}; - -function countFiles(node: TreeNode): number { - if (node.file) return 1; - let count = 0; - for (const child of node.children.values()) { - count += countFiles(child); - } - return count; -} - -// ==================== Syntax Highlighting ==================== - -import hljs from 'highlight.js/lib/core'; -import 'highlight.js/styles/vs2015.css'; - -// Register commonly used languages -import javascript from 'highlight.js/lib/languages/javascript'; -import typescript from 'highlight.js/lib/languages/typescript'; -import java from 'highlight.js/lib/languages/java'; -import xml from 'highlight.js/lib/languages/xml'; -import css from 'highlight.js/lib/languages/css'; -import json from 'highlight.js/lib/languages/json'; -import sql from 'highlight.js/lib/languages/sql'; -import python from 'highlight.js/lib/languages/python'; -import rust from 'highlight.js/lib/languages/rust'; -import go from 'highlight.js/lib/languages/go'; -import csharp from 'highlight.js/lib/languages/csharp'; -import shell from 'highlight.js/lib/languages/shell'; -import yaml from 'highlight.js/lib/languages/yaml'; -import properties from 'highlight.js/lib/languages/properties'; -import markdown from 'highlight.js/lib/languages/markdown'; - -hljs.registerLanguage('javascript', javascript); -hljs.registerLanguage('typescript', typescript); -hljs.registerLanguage('java', java); -hljs.registerLanguage('xml', xml); -hljs.registerLanguage('css', css); -hljs.registerLanguage('json', json); -hljs.registerLanguage('sql', sql); -hljs.registerLanguage('python', python); -hljs.registerLanguage('rust', rust); -hljs.registerLanguage('go', go); -hljs.registerLanguage('csharp', csharp); -hljs.registerLanguage('shell', shell); -hljs.registerLanguage('yaml', yaml); -hljs.registerLanguage('properties', properties); -hljs.registerLanguage('markdown', markdown); - -// File extension → hljs language mapping -function detectLanguage(filePath: string): string | undefined { - const ext = filePath.split('.').pop()?.toLowerCase() || ''; - const map: Record = { - ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript', mjs: 'javascript', - java: 'java', kt: 'java', - rs: 'rust', - py: 'python', - go: 'go', - cs: 'csharp', - c: 'csharp', cpp: 'csharp', h: 'csharp', - sql: 'sql', - sh: 'shell', bash: 'shell', zsh: 'shell', - xml: 'xml', html: 'xml', htm: 'xml', svg: 'xml', vue: 'xml', jsp: 'xml', aspx: 'xml', - css: 'css', scss: 'css', less: 'css', - json: 'json', - yaml: 'yaml', yml: 'yaml', toml: 'yaml', - properties: 'properties', ini: 'properties', conf: 'properties', cfg: 'properties', - md: 'markdown', mdx: 'markdown', - }; - return map[ext]; -} - -// Highlight a single line using highlight.js — returns HTML string -function highlightLine(line: string, lang: string | undefined): React.ReactNode { - if (!line || !lang) return line; - try { - const result = hljs.highlight(line, { language: lang, ignoreIllegals: true }); - return ; - } catch { - return line; - } -} - - -// ==================== DiffView ==================== - -const DiffView: FC<{ - diff: FileDiff; -}> = ({ diff }) => { - const { t } = useTranslation(); - const pairs = useMemo( - () => computeSideBySideDiff(diff.old_content, diff.new_content), - [diff.new_content, diff.old_content] - ); - const lang = detectLanguage(diff.file_path); - const [showChangedOnly, setShowChangedOnly] = useState(pairs.length > LARGE_DIFF_THRESHOLD); - - useEffect(() => { - setShowChangedOnly(pairs.length > LARGE_DIFF_THRESHOLD); - }, [diff.file_path, pairs.length]); - - const changedOnlyRows = useMemo( - () => buildChangedOnlyRows(pairs, DIFF_CONTEXT_LINES), - [pairs] - ); - const canUseChangedOnly = changedOnlyRows.some((row) => row.type === 'gap'); - const useChangedOnlyMode = showChangedOnly && canUseChangedOnly; - const renderedRows = useChangedOnlyMode - ? changedOnlyRows - : pairs.map((pair) => ({ type: 'pair', pair } as DiffRow)); - - // Limit rendering to avoid lag - const MAX_LINES = 2000; - const truncated = !useChangedOnlyMode && pairs.length > MAX_LINES; - const displayRows = truncated ? renderedRows.slice(0, MAX_LINES) : renderedRows; - - const renderLineCell = (line: DiffLine, side: 'left' | 'right') => { - const isChange = side === 'left' ? line.type === 'remove' : line.type === 'add'; - const isEmpty = line.type === 'empty'; - const lineNumber = side === 'left' ? line.oldLine : line.newLine; - const marker = side === 'left' - ? line.type === 'remove' ? '−' : ' ' - : line.type === 'add' ? '+' : ' '; - - return ( -
- - {lineNumber ?? ''} - - - {marker} - -
-                    {highlightLine(line.content, lang)}
-                
-
- ); - }; - - if (diff.is_binary) { - return ( -
-
- {diff.file_path} - Binary -
-
- Binary file — cannot display diff -
-
- ); - } - - return ( -
- {/* Sticky file header */} -
- {diff.file_path} - {diff.is_new && ( - New - )} - {diff.is_deleted && ( - Deleted - )} - {canUseChangedOnly && ( -
- - -
- )} -
- - {/* Side-by-side diff */} -
- {displayRows.map((row, index) => { - if (row.type === 'gap') { - return ( -
-
- {t('detail.hiddenUnchangedLines', { - count: row.hiddenCount, - defaultValue: '{{count}} unchanged lines hidden', - })} -
-
- ); - } - - return ( -
-
- {renderLineCell(row.pair.left, 'left')} -
-
- {renderLineCell(row.pair.right, 'right')} -
-
- ); - })} -
- - {truncated && ( -
- ... {pairs.length - MAX_LINES} more lines not shown ... -
- )} -
- ); -}; - - -// ==================== ChangedFilesPanel ==================== - -interface ChangedFilesPanelProps { - projects: ProjectStatus[]; - reviewKey: string; - focusProject?: string | null; -} - -export const ChangedFilesPanel: FC = ({ - projects, - reviewKey, - focusProject, -}) => { - const { t } = useTranslation(); - const [allFiles, setAllFiles] = useState([]); - const [loadingFiles, setLoadingFiles] = useState(false); - const [diffs, setDiffs] = useState>(new Map()); - const [loadingDiffs, setLoadingDiffs] = useState>(new Set()); - const [selectedFile, setSelectedFile] = useState(null); - const [searchQuery, setSearchQuery] = useState(''); - const [statusFilters, setStatusFilters] = useState>(new Set()); - const [projectFilter, setProjectFilter] = useState(focusProject ?? null); - const [expandedPaths, setExpandedPaths] = useState>(new Set()); - const [treeWidth, setTreeWidth] = useState(280); - const resizingRef = useRef(false); - const treeContainerRef = useRef(null); - - const totalChanges = projects.reduce( - (sum, p) => sum + p.uncommitted_count, - 0 - ); - - const projectNames = useMemo( - () => projects.filter((project) => project.uncommitted_count > 0).map((project) => project.name), - [projects] - ); - - // Load all changed files - useEffect(() => { - if (totalChanges === 0) return; - - let cancelled = false; - setLoadingFiles(true); - - const load = async () => { - const results: ChangedFileEntry[] = []; - await Promise.all( - projects.map(async (p) => { - if (p.uncommitted_count === 0) return; - try { - const files = await getChangedFiles(p.path); - if (!cancelled) { - for (const f of files) { - results.push({ ...f, projectName: p.name }); - } - } - } catch (e) { - console.error(`Failed to get changed files for ${p.name}:`, e); - } - }) - ); - - if (!cancelled) { - setAllFiles(results); - setLoadingFiles(false); - const rememberedSelection = readRememberedSelection(reviewKey); - setSelectedFile((prev) => { - if (prev && results.some((file) => `${file.projectName}/${file.path}` === prev)) { - return prev; - } - if ( - rememberedSelection && - results.some((file) => `${file.projectName}/${file.path}` === rememberedSelection) - ) { - return rememberedSelection; - } - return null; - }); - setDiffs((prev) => { - const next = new Map(); - for (const file of results) { - const key = `${file.projectName}/${file.path}`; - const cached = prev.get(key); - if (cached) { - next.set(key, cached); - } - } - return next; - }); - setExpandedPaths(buildExpandedPathSet(results)); - } - }; - - load(); - return () => { - cancelled = true; - }; - }, [projects, reviewKey, totalChanges]); - - useEffect(() => { - setProjectFilter(focusProject ?? null); - }, [focusProject]); - - useEffect(() => { - if (!selectedFile) return; - writeRememberedSelection(reviewKey, selectedFile); - }, [reviewKey, selectedFile]); - - const loadDiff = useCallback( - async (projectName: string, filePath: string, key: string) => { - if (diffs.has(key)) { - setSelectedFile(key); - return; - } - - const project = projects.find((p) => p.name === projectName); - if (!project) return; - - setLoadingDiffs((prev) => new Set(prev).add(key)); - setSelectedFile(key); - - try { - const diff = await getFileDiff(project.path, filePath); - setDiffs((prev) => new Map(prev).set(key, diff)); - } catch (e) { - console.error('Failed to load diff:', e); - } finally { - setLoadingDiffs((prev) => { - const next = new Set(prev); - next.delete(key); - return next; - }); - } - }, - [diffs, projects] - ); - - const handleSelectFile = useCallback( - (projectName: string, filePath: string, key: string) => { - loadDiff(projectName, filePath, key); - }, - [loadDiff] - ); - - const handleToggleStatusFilter = useCallback((status: string) => { - setStatusFilters((prev) => { - const next = new Set(prev); - if (next.has(status)) { - next.delete(status); - } else { - next.add(status); - } - return next; - }); - }, []); - - const handleToggleExpand = useCallback((path: string) => { - setExpandedPaths((prev) => { - const next = new Set(prev); - if (next.has(path)) { - next.delete(path); - } else { - next.add(path); - } - return next; - }); - }, []); - - const normalizedQuery = searchQuery.trim().toLowerCase(); - const filesInScope = useMemo(() => { - return allFiles.filter((file) => { - if (projectFilter && file.projectName !== projectFilter) return false; - if (!normalizedQuery) return true; - const haystack = `${file.projectName}/${file.path}`.toLowerCase(); - return haystack.includes(normalizedQuery); - }); - }, [allFiles, normalizedQuery, projectFilter]); - - const availableStatusCounts = useMemo( - () => countStatuses(filesInScope), - [filesInScope] - ); - - const visibleFiles = useMemo(() => { - if (statusFilters.size === 0) return filesInScope; - return filesInScope.filter((file) => statusFilters.has(file.status)); - }, [filesInScope, statusFilters]); - - const visibleStatusCounts = useMemo( - () => countStatuses(visibleFiles), - [visibleFiles] - ); - - const orderedVisibleFiles = useMemo( - () => [...visibleFiles].sort((a, b) => { - const byProject = a.projectName.localeCompare(b.projectName); - if (byProject !== 0) return byProject; - return a.path.localeCompare(b.path); - }), - [visibleFiles] - ); - - useEffect(() => { - setSelectedFile((prev) => { - if (!prev) return null; - return visibleFiles.some((file) => `${file.projectName}/${file.path}` === prev) - ? prev - : null; - }); - }, [visibleFiles]); - - const selectedFileMeta = useMemo( - () => selectedFile - ? visibleFiles.find((file) => `${file.projectName}/${file.path}` === selectedFile) - ?? allFiles.find((file) => `${file.projectName}/${file.path}` === selectedFile) - ?? null - : null, - [allFiles, selectedFile, visibleFiles] - ); - const selectedDiff = selectedFile ? diffs.get(selectedFile) ?? null : null; - const isSelectedDiffLoading = selectedFile ? loadingDiffs.has(selectedFile) : false; - const selectedIndex = selectedFile - ? orderedVisibleFiles.findIndex((file) => `${file.projectName}/${file.path}` === selectedFile) - : -1; - - useEffect(() => { - if (!selectedFileMeta || selectedDiff || isSelectedDiffLoading) return; - const key = `${selectedFileMeta.projectName}/${selectedFileMeta.path}`; - void loadDiff(selectedFileMeta.projectName, selectedFileMeta.path, key); - }, [isSelectedDiffLoading, loadDiff, selectedDiff, selectedFileMeta]); - - const effectiveExpandedPaths = useMemo(() => { - const next = new Set(expandedPaths); - const shouldForceExpand = Boolean(projectFilter || normalizedQuery || statusFilters.size > 0); - - if (shouldForceExpand) { - buildExpandedPathSet(visibleFiles).forEach((path) => next.add(path)); - } - - if (selectedFileMeta) { - buildExpandedPathSet([selectedFileMeta]).forEach((path) => next.add(path)); - } - - return next; - }, [expandedPaths, normalizedQuery, projectFilter, selectedFileMeta, statusFilters, visibleFiles]); - - useEffect(() => { - if (!selectedFile || !treeContainerRef.current) return; - const target = treeContainerRef.current.querySelector( - `[data-file-key="${CSS.escape(selectedFile)}"]` - ); - target?.scrollIntoView({ block: 'nearest' }); - }, [effectiveExpandedPaths, selectedFile]); - - const clearFilters = useCallback(() => { - setSearchQuery(''); - setStatusFilters(new Set()); - setProjectFilter(null); - }, []); - - const selectRelativeFile = useCallback((offset: -1 | 1) => { - if (selectedIndex < 0) return; - const target = orderedVisibleFiles[selectedIndex + offset]; - if (!target) return; - const key = `${target.projectName}/${target.path}`; - void loadDiff(target.projectName, target.path, key); - }, [loadDiff, orderedVisibleFiles, selectedIndex]); - - useEffect(() => { - if (selectedFile || orderedVisibleFiles.length === 0) return; - if (!focusProject && orderedVisibleFiles.length !== 1) return; - const target = orderedVisibleFiles[0]; - const key = `${target.projectName}/${target.path}`; - void loadDiff(target.projectName, target.path, key); - }, [focusProject, loadDiff, orderedVisibleFiles, selectedFile]); - - // Resize handler for tree panel - const handleMouseDown = useCallback(() => { - resizingRef.current = true; - const handleMouseMove = (e: MouseEvent) => { - if (!resizingRef.current) return; - setTreeWidth((prev) => Math.max(200, Math.min(500, prev + e.movementX))); - }; - const handleMouseUp = () => { - resizingRef.current = false; - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - }, []); - - const tree = useMemo(() => buildTree(visibleFiles), [visibleFiles]); - - if (totalChanges === 0) return null; - - return ( -
-
-
- - - {t('detail.changedFiles', 'Changed Files')} - - - {totalChanges} - - {visibleFiles.length !== allFiles.length && ( - - {visibleFiles.length}/{allFiles.length} - - )} -
-
-
- setSearchQuery(event.target.value)} - placeholder={t('detail.filterChangedFiles', 'Filter by project or file path')} - className="h-8 bg-[var(--color-bg-base)]/60 border-[var(--color-border)] text-sm" - /> - {(searchQuery || statusFilters.size > 0 || projectFilter) && ( - - )} -
- - {projectNames.length > 1 && ( -
- - {projectNames.map((projectName) => { - const projectCount = allFiles.filter((file) => file.projectName === projectName).length; - return ( - - ); - })} -
- )} - -
- {STATUS_ORDER.map((status) => { - const count = availableStatusCounts[status] || 0; - if (count === 0) return null; - const active = statusFilters.has(status); - return ( - - ); - })} -
-
-
- - {/* Content */} -
- {/* File tree sidebar */} -
- {loadingFiles ? ( -
- -
- ) : visibleFiles.length === 0 ? ( -
- -

{t('detail.noChangedFilesMatch', 'No changed files match the current filters')}

-
- ) : ( -
- {/* Summary */} -
- {STATUS_ORDER.map((s) => { - const count = visibleStatusCounts[s] || 0; - if (count === 0) return null; - return ( - - {count}{' '} - - {STATUS_LABELS[s]} - - - ); - })} -
- {Array.from(tree.children.values()) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((child) => ( - - ))} -
- )} -
- - {/* Resize handle */} -
- - {/* Diff content */} -
- {selectedFileMeta && ( -
- - {selectedFileMeta.status} - - - {selectedFileMeta.projectName} - - - {selectedFileMeta.path} - -
- - {selectedIndex >= 0 ? `${selectedIndex + 1}/${orderedVisibleFiles.length}` : null} - - - -
-
- )} - -
- {visibleFiles.length === 0 ? ( -
- -

{t('detail.noChangedFilesMatch', 'No changed files match the current filters')}

-
- ) : !selectedFile ? ( -
- -

{t('detail.selectFileToDiff', 'Select a file to view diff')}

-
- ) : isSelectedDiffLoading ? ( -
- -

- {selectedFileMeta?.path || t('common.loading')} -

-
- ) : selectedDiff ? ( - - ) : ( -
- -

{t('detail.selectFileToDiff', 'Select a file to view diff')}

-
- )} -
-
-
-
- ); -}; diff --git a/src/components/ContextMenus.tsx b/src/components/ContextMenus.tsx deleted file mode 100644 index a7ea602..0000000 --- a/src/components/ContextMenus.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import { type FC, type ReactNode, useEffect, useRef } from 'react'; -import { createPortal } from 'react-dom'; -import { useTranslation } from 'react-i18next'; -import { ArchiveIcon, EditorIcon, TerminalAppIcon } from './Icons'; -import { isTauri } from '@/lib/backend'; - -interface ContextMenuProps { - x: number; - y: number; - onClose: () => void; - onArchive: () => void; - currentColor?: string | null; - onSetColor?: (color: string | null) => void; -} - -const COLOR_OPTIONS = [ - { key: 'red', class: 'bg-red-400' }, - { key: 'orange', class: 'bg-orange-400' }, - { key: 'yellow', class: 'bg-yellow-400' }, - { key: 'green', class: 'bg-emerald-400' }, - { key: 'blue', class: 'bg-[var(--color-accent)]' }, - { key: 'purple', class: 'bg-purple-400' }, -]; - -export const WorktreeContextMenu: FC = ({ - x, - y, - onClose, - onArchive, - currentColor, - onSetColor, -}) => { - const { t } = useTranslation(); - return ( -
-
e.stopPropagation()} - > - {onSetColor && ( - <> -
-
{t('contextMenu.setColor', '标记颜色')}
-
- {COLOR_OPTIONS.map((c) => ( - - )} -
-
- - )} - {isTauri() && ( - <> -
- - - )} -
-
- ); -}; - -interface TerminalTabContextMenuProps { - x: number; - y: number; - onClose: () => void; - onDuplicate: () => void; - onCloseTab: () => void; - onCloseOtherTabs: () => void; - onCloseAllTabs: () => void; -} - -export const TerminalTabContextMenu: FC = ({ - x, - y, - onClose, - onDuplicate, - onCloseTab, - onCloseOtherTabs, - onCloseAllTabs, -}) => { - const { t } = useTranslation(); - return ( -
-
e.stopPropagation()} - > - -
- - - -
-
- ); -}; - -// Shared popover used by both IDE and Terminal pickers -interface AppPickerPopoverProps { - anchorRect: DOMRect; - items: Array<{ id: string; name: string }>; - onSelect: (id: string) => void; - onClose: () => void; - renderIcon: (id: string) => ReactNode; -} - -const AppPickerPopover: FC = ({ anchorRect, items, onSelect, onClose, renderIcon }) => { - const menuRef = useRef(null); - const onCloseRef = useRef(onClose); - onCloseRef.current = onClose; - - // 3 cols × 32px icon + 2 gaps × 4px + 8px padding ≈ 112px - const popoverWidth = 116; - const rows = Math.ceil(items.length / 3); - const popoverHeight = rows * 36 + 8; - // Right-align: popover's right edge = button's right edge - const left = Math.max(8, anchorRect.right - popoverWidth); - const spaceBelow = window.innerHeight - anchorRect.bottom; - const top = spaceBelow >= popoverHeight + 8 ? anchorRect.bottom + 4 : anchorRect.top - popoverHeight - 4; - - useEffect(() => { - let removeListener: (() => void) | undefined; - const timer = setTimeout(() => { - const handle = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - onCloseRef.current(); - } - }; - document.addEventListener('mousedown', handle, true); - removeListener = () => document.removeEventListener('mousedown', handle, true); - }, 0); - return () => { - clearTimeout(timer); - removeListener?.(); - }; - }, []); - - return createPortal( -
e.preventDefault()} - > - {items.map((item) => ( - - ))} -
, - document.body, - ); -}; - -interface IdePickerContextMenuProps { - anchorRect: DOMRect; - editors: Array<{ id: string; name: string }>; - onSelect: (editorId: string) => void; - onClose: () => void; -} - -export const IdePickerContextMenu: FC = ({ anchorRect, editors, onSelect, onClose }) => ( - } - /> -); - -interface TerminalPickerPopoverProps { - anchorRect: DOMRect; - terminals: Array<{ id: string; name: string }>; - onSelect: (terminalId: string) => void; - onClose: () => void; -} - -export const TerminalPickerPopover: FC = ({ anchorRect, terminals, onSelect, onClose }) => ( - } - /> -); diff --git a/src/components/CrashReportModal.tsx b/src/components/CrashReportModal.tsx deleted file mode 100644 index aa79dbf..0000000 --- a/src/components/CrashReportModal.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { FC } from 'react'; -import { Button } from '@/components/ui/button'; -import type { CrashReport } from '../types'; - -interface CrashReportModalProps { - report: CrashReport; - onClose: () => void; -} - -export const CrashReportModal: FC = ({ report, onClose }) => { - return ( -
-
-
-

检测到上次异常退出

-

- 上次程序未正常关闭,可能是崩溃、强制退出或系统关机导致。 -

-
- -
- {report.previousSessionInfo && ( -
-

上次会话

-
-                {report.previousSessionInfo}
-              
-
- )} - - {report.crashDetail && ( -
-

崩溃详情

-
-                {report.crashDetail}
-              
-
- )} - -
-
日志路径:
-
macOS: ~/Library/Logs/com.guo.worktree-manager/
-
Windows: %LOCALAPPDATA%\com.guo.worktree-manager\logs\
-
-
- -
- -
-
-
- ); -}; diff --git a/src/components/CreatePRModal.tsx b/src/components/CreatePRModal.tsx deleted file mode 100644 index 3d1e56d..0000000 --- a/src/components/CreatePRModal.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { useState, type FC } from 'react'; -import { useTranslation } from 'react-i18next'; -import { addLog } from '@/lib/operationLog'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { createPullRequest, openLink } from '@/lib/backend'; -import { useToast } from './Toast'; - -interface CreatePRModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; - projectPath: string; - baseBranch: string; - currentBranch: string; - onSuccess?: () => void; -} - -export const CreatePRModal: FC = ({ - open, - onOpenChange, - projectPath, - baseBranch, - currentBranch, - onSuccess, -}) => { - const { t } = useTranslation(); - const [title, setTitle] = useState(''); - const [body, setBody] = useState(''); - const [submitting, setSubmitting] = useState(false); - const { toast } = useToast(); - - const handleSubmit = async () => { - if (!title.trim()) return; - setSubmitting(true); - addLog(projectPath, { level: 'info', operation: 'pr', message: `Creating PR: ${title.trim()} → ${baseBranch}` }); - try { - const prUrl = await createPullRequest(projectPath, baseBranch, title.trim(), body.trim()); - addLog(projectPath, { level: 'success', operation: 'pr', message: `PR created: ${prUrl}`, detail: prUrl }); - toast('success', t('createPR.success', { url: prUrl })); - if (prUrl.startsWith('http')) { - openLink(prUrl).catch(() => {}); - } - onOpenChange(false); - setTitle(''); - setBody(''); - onSuccess?.(); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - addLog(projectPath, { level: 'error', operation: 'pr', message: 'PR creation failed', detail: msg }); - toast('error', msg); - } finally { - setSubmitting(false); - } - }; - - return ( - - - - {t('createPR.title')} - - {currentBranch} → {baseBranch} - - - -
-
- - setTitle(e.target.value)} - placeholder={t('createPR.titlePlaceholder')} - autoFocus - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey && title.trim()) { - handleSubmit(); - } - }} - /> -
-
- -